kubernetes 水平自动伸缩学习笔记(二)

目录


前言

上一篇笔记在介绍 kubernetes 水平自动伸缩实现前先预设了整体架构。本篇笔记将围绕 kubernetes 的采集器 Metrics Server 进行介绍。

架构

kubernetes 采集器的架构如下图所示:

对比 kubernetes 水平自动伸缩学习笔记(一) 中我们设计的架构主要有以下区别:

  • HPA 控制器和 API 服务器交互,而不是直接和采集器 Metrics-Server 交互,需要 API 服务器开启聚合服务。这种设计的优势在于:1. 规整,组件和 kube-apiserver 交互,不单独交互;2. 认证、鉴权、准入等由 kube-apiserver 支持,复用现有组件能力;
  • Metrics-Server 不仅支持 HPA 还支持 kubectl top 展示节点/pod 资源使用情况,方便 debug 等操作;

设计

本篇学习笔记基于 metrics-server release-0.6 进行介绍。

metrics-server release-0.6 有以下核心设计要点:

  • 并发+随机采集,防止并发同时采集导致网络拥塞等情况;
  • kubelet 采集 api 从 /metrics/summary 更新为 /metrics/resource,获取指标数据更加精简,节省内存和序列化/反序列化操作;
  • metrics-server 采集 cpu 和 memory 指标,引入 prev 和 last 存指标以计算 cpu 使用率;

这里面有一些要考虑的问题:

  • 如果资源指标缺失怎么办?比如 pod 的 container 资源没采集到或者 Deployment 的 pod 资源采集不到,metrics-server 会如何处理?
  • 如果 prev 和 last 数据不一致怎么办?比如 prev 有 pod-a 的 cpu 指标,但是 last 却没有 pod-a 的 cpu 指标,metrics-server 会如何处理?

这两个问题可以归为一类回答,采集器作为独立的组件并不和具体业务耦合。那么它要做的就是尊重事实(而不是像 hpa controller 尊重业务)。

metrics-server 会尽可能推导出 prev/last 的资源使用率但是尊重事实。

对于第一问,如果没有采集到则不采集,不推断/重采缺失指标。

对于第二问,如果不一致则清空 prev/last 不计算该 pod 的使用率。

源码

本节从源码角度分析 metrics-server 的工作流程。

轮询采集

go 复制代码
func (s *server) runScrape(ctx context.Context) {  
    ticker := time.NewTicker(s.resolution)  
    defer ticker.Stop()  
    s.tick(ctx, time.Now())  
  
    for {  
       select {  
       case startTime := <-ticker.C:  
          // 采集器开始采集
          s.tick(ctx, startTime)  
       case <-ctx.Done():  
          return  
       }  
    }  
}

创建定时器定时采集,采集周期默认为 15s,可配置。

并发随机采集

go 复制代码
func (c *scraper) Scrape(baseCtx context.Context) *storage.MetricsBatch {  
	// informer 从 apiserver 获取节点信息
    nodes, err := c.nodeLister.List(labels.Everything())  
    if err != nil {  
       ... 
    }   
    
    // 采集的节点资源写到 responseChannel 通道
    responseChannel := make(chan *storage.MetricsBatch, len(nodes))  
    defer close(responseChannel)  
  
    startTime := myClock.Now()  
  
    // 这段代码很有意思,设置随机延迟时间,防止同时并发采集导致网络拥塞
    delayMs := delayPerSourceMs * len(nodes)  
    if delayMs > maxDelayMs {  
       delayMs = maxDelayMs  
    }  
  
    for _, node := range nodes {  
       // 并发采集
       go func(node *corev1.Node) {  
          // 协程内部休眠,休眠会让出 CPU 避免网络拥塞 
          sleepDuration := time.Duration(rand.Intn(delayMs)) * time.Millisecond  
          time.Sleep(sleepDuration)     
          
		  // 防止请求超时     
          ctx, cancelTimeout := context.WithTimeout(baseCtx, c.scrapeTimeout-sleepDuration)  
          defer cancelTimeout()  
          
          // 采集节点指标
          m, err := c.collectNode(ctx, node)  
          if err != nil {  
             ... 
          }  
          // 将采集结果写到 responseChannel 通道
          responseChannel <- m  
       }(node)  
    }  
  
    res := &storage.MetricsBatch{  
       Nodes: map[string]storage.MetricsPoint{},  
       Pods:  map[apitypes.NamespacedName]storage.PodMetricsPoint{},  
    }  
  
    for range nodes {  
       srcBatch := <-responseChannel  
       if srcBatch == nil {  
          continue  
       }  
       // 收集 node 指标
       for nodeName, nodeMetricsPoint := range srcBatch.Nodes {  
          if _, nodeFind := res.Nodes[nodeName]; nodeFind {  
             klog.ErrorS(nil, "Got duplicate node point", "node", klog.KRef("", nodeName))  
             continue  
          }  
          res.Nodes[nodeName] = nodeMetricsPoint  
       }  
       // 收集 pod 指标
       for podRef, podMetricsPoint := range srcBatch.Pods {  
          if _, podFind := res.Pods[podRef]; podFind {  
             klog.ErrorS(nil, "Got duplicate pod point", "pod", klog.KRef(podRef.Namespace, podRef.Name))  
             continue  
          }  
          res.Pods[podRef] = podMetricsPoint  
       }  
    }  
   
    return res  
}

这里并发采集有点意思,如果用协程分批次收集可能是一种反模式。虽然能控制协程数,但是收集的总时间延长了,并不好。当然如果超大规模集群这么并发创建协程还是有风险的。

指标收集

通过 kubelet client 收集指标:

go 复制代码
func (c *scraper) collectNode(ctx context.Context, node *corev1.Node) (*storage.MetricsBatch, error) {  
    ...
    // kubelet client 收集指标
    ms, err := c.kubeletClient.GetMetrics(ctx, node)  
    ... 
    return ms, nil  
}

func (kc *kubeletClient) GetMetrics(ctx context.Context, node *corev1.Node) (*storage.MetricsBatch, error) {  
    ...
    url := url.URL{  
       Scheme: kc.scheme,  
       Host:   net.JoinHostPort(addr, strconv.Itoa(port)),  
       Path:   "/metrics/resource",  
    }  
    // get /metrics/resource api 收集指标
    return kc.getMetrics(ctx, url.String(), node.Name)  
}

存储指标

存储指标是重点,指标通过哈希表的形式存储以实现 O(1) 时间复杂度查询。查看实现如下:

go 复制代码
func (s *server) tick(ctx context.Context, startTime time.Time) {  
    ...
    // 采集所有节点的指标
    data := s.scraper.Scrape(ctx)  
  
    // 存储指标
    s.storage.Store(data)  
	...
}

func (s *storage) Store(batch *MetricsBatch) {  
    s.mu.Lock()  
    defer s.mu.Unlock()  
    // 存储指标到 nodes 
    s.nodes.Store(batch)  
    // 存储指标到 pods
    s.pods.Store(batch)  
}

收集的指标根据 node 或 pod 分开存储到内存 storage 中,代码见 这里。

把存储逻辑拆成如下分支:

分支 条件 行为
新鲜容器 (L121) StartTime < Timestamp 且 Δt ∈ [10s, metricResolution) 合成 prev:(Timestamp=StartTime, CumulativeCpuUsed=0),保证首次采集也能算 CPU rate
未重启且新点更新 (L130) last 中有该容器且 StartTime < last.Timestamp 且 new.Timestamp > last.Timestamp 把旧 last 点降级为 prev(滚动窗口)
时间未前进 (L133-L143) new.Timestamp ≤ last.Timestamp 时看 s.prev prev 比新点旧则保留 prev;否则丢弃(防止窗口倒退)
重启/无历史 StartTime ≥ last.Timestamp 或 last/prev 中无该容器 不写 prev,该容器本轮无法返回指标(allContainersPresent 在 GetMetrics 中会整 pod 丢弃)

从 metrics-server 的行为看,存储是尊重事实的,如果指标在 prev/last 缺失,不能构造 pre/last 区间指标的话就不存。

具体的业务逻辑由上层应用去处理,解耦业务和通用组件。

性能

metrics-server 的性能可以参考 官网,这里就不过多介绍了。

调优

对于 metrics-server 的调优可以从以下角度入手:

  • 调整采集周期;
  • 更新 cpu/memory 容量;

参考资料