metrics-server源码中的缓存策略:LRU与TTL设计

metrics-server源码中的缓存策略:LRU与TTL设计

【免费下载链接】metrics-server Scalable and efficient source of container resource metrics for Kubernetes built-in autoscaling pipelines. 【免费下载链接】metrics-server 项目地址: https://gitcode.com/gh_mirrors/me/metrics-server

引言:缓存策略在Kubernetes监控中的关键作用

在Kubernetes(K8s)生态系统中,metrics-server作为资源指标聚合器,承担着为HPA(Horizontal Pod Autoscaler)等核心组件提供实时容器资源数据的重要角色。其缓存系统的设计直接影响着指标采集的实时性、系统资源占用以及整体稳定性。本文将深入剖析metrics-server源码中的缓存实现,重点解析LRU(Least Recently Used,最近最少使用)与TTL(Time-To-Live,生存时间)两种缓存淘汰策略的设计理念、实现细节及其在Kubernetes环境中的应用场景与挑战。

当集群规模达到数千节点和数万Pod时,metrics-server面临着每秒数千次的指标查询请求。若每次请求都直接从Kubelet拉取原始数据,不仅会显著增加网络带宽消耗,还会导致Kubelet API负载过高,甚至引发指标获取延迟,影响HPA的决策速度。因此,一个高效的缓存系统是metrics-server高性能运行的基石。metrics-server通过精妙的LRU与TTL结合的缓存策略,在保证数据新鲜度的同时,最大限度地减少了重复计算和网络请求,从而支撑起大规模Kubernetes集群的稳定运行。

metrics-server缓存系统架构概览

metrics-server的缓存系统主要集中在pkg/storage包中,通过storage.gonode.gopod.gotypes.go等核心文件实现。其整体架构采用了双层缓存结构,分别针对节点(Node)和Pod/容器(Container)的指标数据进行缓存管理。

mermaid

Storage结构体是缓存系统的入口,它内部维护了nodeStoragepodStorage两个子缓存,分别用于节点和Pod/容器的指标数据。nodeStoragepodStorage都采用了类似的设计模式,即通过两个映射(map)lastprev来存储最近两次抓取的指标数据点(MetricsPoint)。这种双缓存设计是实现TTL策略的核心基础,我们将在后续章节详细阐述。

MetricsBatch结构体用于封装一次指标抓取周期内获取到的所有节点和Pod指标数据。MetricsPoint则是指标数据的最小单元,包含了指标的开始时间(StartTime)、采集时间(Timestamp)、累积CPU使用量(CumulativeCPUUsed)和内存使用量(MemoryUsage)等关键信息。这些结构体的定义为缓存策略的实现提供了数据基础。

TTL(Time-To-Live)策略的实现与应用

TTL策略的核心思想

TTL策略是一种基于时间的缓存淘汰机制,它为每个缓存项设置一个固定的生存时间。当缓存项的存储时间超过其TTL值时,该缓存项将被自动淘汰。在metrics-server中,TTL策略主要通过维护两个连续的指标数据点(lastprev)来实现,这两个数据点之间的时间间隔即为缓存的有效窗口。

源码中的TTL实现细节

nodeStorageStore方法中,我们可以清晰地看到TTL策略的实现逻辑:

func (s *nodeStorage) Store(batch *MetricsBatch) {
    lastNodes := make(map[string]MetricsPoint, len(batch.Nodes))
    prevNodes := make(map[string]MetricsPoint, len(batch.Nodes))
    for nodeName, newPoint := range batch.Nodes {
        if _, exists := lastNodes[nodeName]; exists {
            klog.ErrorS(nil, "Got duplicate node point", "node", klog.KRef("", nodeName))
            continue
        }
        lastNodes[nodeName] = newPoint

        if lastNode, found := s.last[nodeName]; found {
            // If new point is different then one already stored
            if newPoint.Timestamp.After(lastNode.Timestamp) {
                // Move stored point to previous
                prevNodes[nodeName] = lastNode
            } else if prevPoint, found := s.prev[nodeName]; found {
                if prevPoint.Timestamp.Before(newPoint.Timestamp) {
                    // Keep previous point
                    prevNodes[nodeName] = prevPoint
                } else {
                    klog.V(2).InfoS("Found new node metrics point is older than stored previous, drop previous",
                        "node", nodeName,
                        "previousTimestamp", prevPoint.Timestamp,
                        "timestamp", newPoint.Timestamp)
                }
            }
        }
    }
    s.last = lastNodes
    s.prev = prevNodes

    // Only count last for which metrics can be returned.
    pointsStored.WithLabelValues("node").Set(float64(len(prevNodes)))
}

上述代码展示了节点指标数据的存储过程。lastNodesprevNodes分别存储当前和上一次抓取的节点指标数据。对于每个新抓取到的指标点(newPoint),如果它的时间戳(Timestamp)晚于last中存储的时间戳,则将last中的旧数据移至prev,并将新数据存入last。这意味着prev中存储的是上一个周期的指标数据,其与last中数据的时间差即为TTL窗口。

GetMetrics方法中,只有当lastprev中都存在某个节点的指标数据时,才能计算出该节点的资源使用率:

func (s *nodeStorage) GetMetrics(nodes ...*corev1.Node) ([]metrics.NodeMetrics, error) {
    results := make([]metrics.NodeMetrics, 0, len(nodes))
    for _, node := range nodes {
        last, found := s.last[node.Name]
        if !found {
            continue
        }

        prev, found := s.prev[node.Name]
        if !found {
            continue
        }
        rl, ti, err := resourceUsage(last, prev)
        if err != nil {
            klog.ErrorS(err, "Skipping node usage metric", "node", node)
            continue
        }
        results = append(results, metrics.NodeMetrics{
            ObjectMeta: metav1.ObjectMeta{
                Name:              node.Name,
                Labels:            node.Labels,
                CreationTimestamp: metav1.NewTime(time.Now()),
            },
            Timestamp: metav1.NewTime(ti.Timestamp),
            Window:    metav1.Duration{Duration: ti.Window},
            Usage:     rl,
        })
    }
    return results, nil
}

resourceUsage函数利用lastprev两个数据点计算资源使用率:

func resourceUsage(last, prev MetricsPoint) (corev1.ResourceList, api.TimeInfo, error) {
    if last.StartTime.Before(prev.StartTime) {
        return corev1.ResourceList{}, api.TimeInfo{}, fmt.Errorf("unexpected decrease in startTime of node/container")
    }
    if last.CumulativeCPUUsed < prev.CumulativeCPUUsed {
        return corev1.ResourceList{}, api.TimeInfo{}, fmt.Errorf("unexpected decrease in cumulative CPU usage value")
    }
    window := last.Timestamp.Sub(prev.Timestamp)
    cpuUsage := float64(last.CumulativeCPUUsed-prev.CumulativeCPUUsed) / window.Seconds()
    return corev1.ResourceList{
            corev1.ResourceCPU:    uint64Quantity(uint64(cpuUsage), resource.DecimalSI, -9),
            corev1.ResourceMemory: uint64Quantity(last.MemoryUsage, resource.BinarySI, 0),
        }, api.TimeInfo{
            Timestamp: last.Timestamp,
            Window:    window,
        }, nil
}

这里的window变量就是lastprev两个数据点之间的时间差,即TTL窗口。CPU使用率的计算依赖于这个时间窗口内累积CPU使用量的变化。

TTL策略在metrics-server中的应用场景

TTL策略在metrics-server中主要应用于以下场景:

  1. 指标数据的新鲜度保证:通过固定的抓取周期(metricResolution),确保缓存中的数据不会过时太久。默认情况下,metrics-server的抓取周期为15秒,这意味着缓存数据的TTL约为15秒。
  2. 资源使用率计算:如前所述,资源使用率的计算需要两个连续的指标数据点。TTL策略确保了这两个数据点之间有足够的时间间隔,从而使计算结果更加准确。
  3. 平滑指标波动:通过使用两个连续周期的指标数据进行计算,可以在一定程度上平滑瞬时的指标波动,提供更稳定的资源使用率数据。

LRU(Least Recently Used)策略的隐含实现与分析

LRU策略的核心思想

LRU策略是一种基于访问频率的缓存淘汰机制,它认为最近被访问的数据在未来一段时间内被再次访问的概率更高,因此当缓存空间满时,会优先淘汰最久未被访问的数据。虽然metrics-server的源码中没有显式使用传统的LRU缓存结构(如带有双向链表的哈希表),但其缓存实现中蕴含了LRU的思想。

metrics-server中LRU思想的体现

podStorageStore方法中,我们可以看到LRU思想的体现:

func (s *podStorage) Store(newPods *MetricsBatch) {
    lastPods := make(map[apitypes.NamespacedName]PodMetricsPoint, len(newPods.Pods))
    prevPods := make(map[apitypes.NamespacedName]PodMetricsPoint, len(newPods.Pods))
    var containerCount int
    for podRef, newPod := range newPods.Pods {
        podRef := apitypes.NamespacedName{Name: podRef.Name, Namespace: podRef.Namespace}
        if _, found := lastPods[podRef]; found {
            klog.ErrorS(nil, "Got duplicate pod point", "pod", klog.KRef(podRef.Namespace, podRef.Name))
            continue
        }

        newLastPod := PodMetricsPoint{Containers: make(map[string]MetricsPoint, len(newPod.Containers))}
        newPrevPod := PodMetricsPoint{Containers: make(map[string]MetricsPoint, len(newPod.Containers))}
        for containerName, newPoint := range newPod.Containers {
            if _, exists := newLastPod.Containers[containerName]; exists {
                klog.ErrorS(nil, "Got duplicate Container point", "container", containerName, "pod", klog.KRef(podRef.Namespace, podRef.Name))
                continue
            }
            newLastPod.Containers[containerName] = newPoint
            if newPoint.StartTime.Before(newPoint.Timestamp) && newPoint.Timestamp.Sub(newPoint.StartTime) < s.metricResolution && newPoint.Timestamp.Sub(newPoint.StartTime) >= freshContainerMinMetricsResolution {
                copied := newPoint
                copied.Timestamp = newPoint.StartTime
                copied.CumulativeCPUUsed = 0
                newPrevPod.Containers[containerName] = copied
            } else if lastPod, found := s.last[podRef]; found {
                // Keep previous metric point if newPoint has not restarted (new metric start time < stored timestamp)
                if lastContainer, found := lastPod.Containers[containerName]; found && newPoint.StartTime.Before(lastContainer.Timestamp) {
                    // If new point is different then one already stored
                    if newPoint.Timestamp.After(lastContainer.Timestamp) {
                        // Move stored point to previous
                        newPrevPod.Containers[containerName] = lastContainer
                    } else if prevPod, found := s.prev[podRef]; found {
                        if prevPod.Containers[containerName].Timestamp.Before(newPoint.Timestamp) {
                            // Keep previous point
                            newPrevPod.Containers[containerName] = prevPod.Containers[containerName]
                        } else {
                            klog.V(2).InfoS("Found new containerName metrics point is older than stored previous , drop previous",
                                "containerName", containerName,
                                "pod", klog.KRef(podRef.Namespace, podRef.Name),
                                "previousTimestamp", prevPod.Containers[containerName].Timestamp,
                                "timestamp", newPoint.Timestamp)
                        }
                    }
                }
            }
        }
        containerPoints := len(newPrevPod.Containers)
        if containerPoints > 0 {
            prevPods[podRef] = newPrevPod
        }
        lastPods[podRef] = newLastPod

        // Only count containers for which metrics can be returned.
        containerCount += containerPoints
    }
    s.last = lastPods
    s.prev = prevPods

    pointsStored.WithLabelValues("container").Set(float64(containerCount))
}

在这段代码中,lastPodsprevPods是全新创建的映射,它们只包含当前抓取周期内获取到的Pod指标数据。这意味着,对于那些在当前抓取周期内没有新数据的Pod,它们的旧数据将不会被复制到新的lastPodsprevPods中,从而被自动淘汰。这种机制实际上实现了一种简化的LRU策略:只有最近被访问(抓取)的Pod数据才会被保留在缓存中,而长时间未被访问的Pod数据则会被自动剔除。

LRU与TTL的协同工作

在metrics-server的缓存系统中,LRU和TTL策略并非孤立存在,而是协同工作,共同优化缓存性能:

  1. TTL保证数据时效性:TTL策略确保缓存中的数据不会超过一定的时间窗口,从而保证了提供给HPA等组件的指标数据的新鲜度。
  2. LRU优化缓存空间:LRU思想(通过在每个抓取周期重建缓存映射实现)确保了缓存中只保留最近活跃的Pod和节点的指标数据,避免了缓存空间的无限增长。
  3. 双重淘汰机制:一个缓存项要被淘汰,需要同时满足两个条件:超过TTL时间未更新,并且在最近的抓取周期中没有被访问。这种双重淘汰机制使得缓存系统更加高效和可靠。

缓存策略的性能考量与调优

缓存命中率分析

缓存命中率是衡量缓存策略有效性的关键指标。在metrics-server中,缓存命中率主要受以下因素影响:

  1. 抓取周期(metricResolution):默认的抓取周期为15秒。如果抓取周期过短,会导致缓存中的数据更新过于频繁,可能降低命中率;如果抓取周期过长,则可能导致数据新鲜度不足。
  2. 集群规模与Pod生命周期:在大规模集群中,Pod的创建和销毁非常频繁。LRU策略(通过重建缓存映射实现)可以自动淘汰那些已被销毁或长时间未被访问的Pod的缓存数据,从而保持较高的缓存命中率。
  3. 指标查询模式:HPA等组件通常会定期查询特定Pod的指标数据。如果查询模式比较固定,缓存命中率会相对较高。

缓存大小的动态调整

metrics-server的缓存大小会随着集群中活跃Pod和节点的数量动态变化。在Store方法中,lastPodsprevPods的初始容量被设置为len(newPods.Pods),这意味着缓存大小会根据每次抓取到的Pod数量进行调整。这种动态调整机制使得缓存空间能够被高效利用,避免了内存资源的浪费。

缓存策略的调优建议

基于以上分析,我们可以提出以下缓存策略的调优建议:

  1. 根据集群规模调整抓取周期:对于大规模集群(节点数超过1000),可以适当延长抓取周期(如从15秒增加到30秒),以减少Kubelet API的负载和网络带宽消耗。但需要注意的是,过长的抓取周期可能会影响HPA的响应速度。
  2. 监控缓存命中率:虽然metrics-server没有直接提供缓存命中率的指标,但可以通过监控pointsStored指标(缓存中的有效数据点数)和查询请求数来间接评估缓存命中率。如果pointsStored指标稳定,而查询请求数增加,可能意味着缓存命中率在下降。
  3. 考虑使用更高级的LRU实现:目前metrics-server的LRU实现比较简单(通过重建缓存映射实现)。如果未来需要进一步优化缓存性能,可以考虑引入更高级的LRU实现,如带有访问频率记录和自适应淘汰策略的LRU-K算法。

缓存策略在故障场景下的表现

网络分区场景

当metrics-server与部分Kubelet之间发生网络分区时,缓存策略可以起到一定的缓冲作用。在网络分区期间,metrics-server无法从这些Kubelet获取新的指标数据。此时,TTL策略会导致这些节点和Pod的缓存数据在经过一个抓取周期后被标记为无效(prev中没有对应的数据)。当网络恢复后,metrics-server会重新开始抓取这些节点和Pod的指标数据,并在两个抓取周期后恢复正常的指标计算。

Kubelet重启场景

当Kubelet重启后,其提供的指标数据(尤其是累积CPU使用量)会被重置。metrics-server的缓存策略能够通过StartTime字段检测到这种情况:

if last.StartTime.Before(prev.StartTime) {
    return corev1.ResourceList{}, api.TimeInfo{}, fmt.Errorf("unexpected decrease in startTime of node/container")
}
if last.CumulativeCPUUsed < prev.CumulativeCPUUsed {
    return corev1.ResourceList{}, api.TimeInfo{}, fmt.Errorf("unexpected decrease in cumulative CPU usage value")
}

当检测到StartTime后退或累积CPU使用量减少时,metrics-server会记录错误日志并跳过该指标数据的计算。这种机制可以防止因Kubelet重启导致的错误指标数据被提供给HPA等组件。

总结与展望

缓存策略的核心贡献

metrics-server源码中的缓存策略(结合TTL和LRU思想)是其能够在大规模Kubernetes集群中高效运行的关键因素之一。通过TTL策略,metrics-server保证了指标数据的时效性;通过隐含的LRU策略,实现了缓存空间的动态优化。这种双重策略使得metrics-server能够在资源受限的情况下,为HPA等核心组件提供稳定、可靠的指标数据服务。

未来优化方向

尽管当前的缓存策略已经能够满足大多数场景的需求,但仍有一些潜在的优化方向:

  1. 引入显式的LRU缓存结构:目前的LRU思想是通过在每个抓取周期重建缓存映射实现的。引入显式的LRU缓存结构(如使用github.com/hashicorp/golang-lru/v2库)可以更精确地控制缓存的淘汰行为,提高缓存命中率。
  2. 实现多级缓存:可以考虑实现多级缓存,如一级缓存(内存)和二级缓存(磁盘或分布式缓存),以应对更大规模的集群场景。
  3. 自适应缓存策略:根据集群的负载情况、Pod生命周期特征等动态调整缓存策略参数(如TTL时间、LRU淘汰阈值等),以实现更优的性能。

通过不断优化缓存策略,metrics-server可以更好地满足Kubernetes集群在可扩展性、可靠性和性能方面的需求,为容器编排平台的稳定运行提供更加强有力的支持。

【免费下载链接】metrics-server Scalable and efficient source of container resource metrics for Kubernetes built-in autoscaling pipelines. 【免费下载链接】metrics-server 项目地址: https://gitcode.com/gh_mirrors/me/metrics-server

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值