为了帮助读者深入了解Kubernetes在各种应用场景下所面临的挑战和解决方案,以及如何进行性能优化。我们推出了<<Kubernetes经典案例30篇>>,该系列涵盖了不同的使用场景,从runc到containerd,从K8s到Istio等微服务架构,全面展示了Kubernetes在实际应用中的最佳实践。通过这些案例,读者可以掌握如何应对复杂的技术难题,并提升Kubernetes集群的性能和稳定性。
- Containerd CVE-2020–15257细节说明
- OpenAI关于Kubernetes集群近万节点的生产实践
- 一条K8s命令行引发的血案
- 揭开K8s适配CgroupV2内存虚高的迷局
- 探索Kubernetes 1.28调度器OOM的根源
- 解读Kubernetes常见错误码
- RLIMIT_NOFILE设置陷阱:容器应用高频异常的隐形元凶
- 容器干扰检测与治理(上篇)
问题描述
年前,同事升级K8s调度器至1.28.3,观察到内存异常现象,帮忙一起看看,在集群pod及node随业务潮汐变动的情况下,内存呈现不断上升的趋势,直至OOM
下面数据均为社区公开信息
触发场景有以下两种(社区还有其他复现方式):
- case 1
1 | for (( ; ; )) |
- case 2
1 | 1. Create a Pod with NodeAffinity under the situation where no Node can accommodate the Pod. |
我们在社区的发现多起类似内存异常场景,复现方式不尽相同,关于上述问题的结论是:
Kubernetes社区在1.28版本中默认开启了调度特性SchedulerQueueingHints,导致调度组件内存异常。为了临时解决内存等问题,社区在1.28.5中将该特性调整为默认关闭。因为问题并未完全修复,所以建议审慎开启该特性。
技术背景
该章节介绍以下内容:
- 介绍K8s调度器相关结构体
- 介绍K8s调度器QueueingHint
- golang的双向链表
调度器简介
PriorityQueue是SchedulingQueue的接口实现。它的头部存放着优先级最高的待调度Pod。PriorityQueue包含以下重要字段:
- activeQ:存放准备好调度的Pod。新添加的Pod会被放入该队列。调度队列需要执行调度时,会从该队列中获取Pod。activeQ由堆来实现。
- backoffQ:存放因各种原因(比如未满足节点要求)而被判定为无法调度的Pod。这些Pod会在一段退避时间后,被移到activeQ以尝试再次调度。backoffQ也由堆来实现。
- unschedulablePods:存放因各种原因无法调度的Pod,是一个map数据结构。这些Pod被认定为无法调度,不会直接放入backoffQ,而是被记录在这里。待条件满足时,它们将被移到activeQ或者backoffQ中,调度队列会定期清理unschedulablePods 中的 Pod。
- inFlightEvents:用于保存调度队列接收到的事件(entry的值是clusterEvent),以及正在处理中的Pod(entry的值是*v1.Pod),基于golang内部实现的双向链表
- inFlightPods:保存了所有已经Pop,但尚未调用Done的Pod的UID,换句话说,所有当前正在处理中的Pod(正在调度、在admit中或在绑定周期中)。
1 | // PriorityQueue implements a scheduling queue. |
关于K8s完整介绍,参看kuberneter调度由浅入深:框架,后续会更新最新的K8s调度器梳理
QueueingHint
K8s调度器引入了QueueingHint
特性,通过从每个插件获取有关Pod重新入队的建议,以减少不必要的调度重试,从而提升调度吞吐量。同时,在适当情况下跳过退避,进一步提高Pod调度效率。
需求背景
当前,每个插件可以通过EventsToRegister定义何时重试调度被插件拒绝的Pod。
比如,NodeAffinity会在节点添加或更新时重试调度Pod,因为新添加或更新的节点可能具有与Pod上的NodeAffinity匹配的标签。然而,实际上,在集群中会发生大量节点更新事件,这并不能保证之前被NodeAffinity拒绝的Pod能够成功调度。
为了解决这个问题,调度器引入了更精细的回调函数,以过滤掉无关的事件,从而在下一个调度周期中仅重试可能成功调度的Pod。
另外,DRA(动态资源分配)调度插件有时需要拒绝Pod以等待来自设备驱动程序的状态更新。因此,某些Pod可能需要经过几个调度周期才能完成调度。针对这种情况,与等待设备驱动程序状态更新相比,回退等待的时间更长。因此,希望能够使插件在特定情况下跳过回退以改善调度性能。
实现目标
为了提高调度吞吐量,社区提出以下改进:
- 引入QueueingHint
- 将
QueueingHint
引入到EventsToRegister
机制中,允许插件提供针对Pods重新入队的建议
- 将
- 增强 Pod 跟踪和重新入队机制:
- 优化追踪调度队列内正在处理的 Pods实现
- 实现一种机制,将被拒绝的 Pods 重新入队到适当的队列
- 优化被拒绝的Pods的退避策略,能够使插件在特定情况下跳过回退,从而提高调度吞吐量。
潜在风险
1. 实现中的错误可能导致 Pod 在 unschedulablePods 中长时间无法被调度
如果一个插件配置了 QueueingHint,但它错过了一些可以让 Pod 可调度的事件, 被该插件拒绝的 Pod 可能会长期困在 unschedulablePods 中。
虽然调度队列会定期清理unschedulablePods 中的 Pod。(默认为 5 分钟,可配)
2. 内存使用量的增加
因为调度队列需要保留调度过程中发生的事件,kube-scheduler的内存使用量会增加。 所以集群越繁忙,它可能需要的内存就越多。
虽然无法完全消除内存增长,但如果能够尽快释放缓存的事件,就可以延缓内存增长的速度。
3.EnqueueExtension
中 EventsToRegister
中的重大变更
自定义调度器插件的开发者需要进行兼容性升级, EnqueueExtension
中的 EventsToRegister
将返回值从 ClusterEvent
更改为 ClusterEventWithHint
。ClusterEventWithHint
允许每个插件通过名为 QueueingHintFn
的回调函数过滤更多无用的事件。
社区为了简化迁移工作,空的 QueueingHintFn
被视为始终返回 Queue
。 因此,如果他们只想保持现有行为,他们只需要将 ClusterEvent
更改为 ClusterEventWithHint
并不需要注册任何 QueueingHintFn
。
QueueingHints设计
EventsToRegister 方法的返回类型已更改为 []ClusterEventWithHint
1 | // EnqueueExtensions 是一个可选接口,插件可以实现在内部调度队列中移动无法调度的 Pod。可以导 |
每个 ClusterEventWithHint结构体包含一个 ClusterEvent 和一个 QueueingHintFn,当事件发生时执行 QueueingHintFn,并确定事件是否可以让 Pod满足调度。
1 | type ClusterEventWithHint struct { |
类型 QueueingHintFn 是一个函数,其返回类型为 (QueueingHint, error)。其中,QueueingHint 是一个枚举类型,可能的值有 QueueSkip 和 Queue。QueueingHintFn 调用时机位于将 Pod 从 unschedulableQ 移动到 backoffQ 或 activeQ 之前,如果返回错误,将把调用方返回的 QueueingHint 处理为 QueueAfterBackoff
,这种处理无论返回的结果是什么,都可以防止 Pod 永远待在unschedulableQ 队列中。
何时跳过/不跳过 backoff
BackoffQ 通过防止“长期无法调度”的 Pod 阻塞队列以保持高吞吐量的轻量级队列。
Pod 在调度周期中被拒绝的次数越多,Pod 需要等待的时间就越长,即在BackoffQ 待得时间就越长。
例如,当 NodeAffinity 拒绝了 Pod,后来在其 QueueingHintFn 中返回 Queue 时,Pod 需要等待 backoff 后才能重试调度。
但是,某些插件的设计本身就需要在调度周期中经历一些失败。比如内置插件DRA(动态资源分配),在 Reserve extension处,它告诉资源驱动程序调度结果,并拒绝 Pod 一次以等待资源驱动程序的响应。针对这种拒绝情况,不能将其视作调度周期的浪费,尽管特定调度周期失败了,但基于该周期的调度结果可以促进 Pod 的调度。因此,由于这种原因被拒绝的 Pod 不需要受到惩罚(backoff)。
为了支持这种情况,我们引入了一个新的状态 Pending。当 DRA 插件使用 Pending 拒绝 Pod,并且后续在其 QueueingHintFn 中返回 Queue 时,Pod 跳过 backoff,Pod 被重新调度。
QueueingHint 如何工作
当K8s集群事件发生时,调度队列将执行在之前调度周期中拒绝 Pod 的那些插件的 QueueingHintFn。
通过下述几个场景,描述一下它们如何被执行以及如何移动 Pod。
a. Pod被一个或多个插件拒绝
假设有三个节点。当 Pod 进入调度周期时,一个节点由于资源不足拒绝了Pod,其他两个节因为Pod 的 NodeAffinity不匹配拒绝了Pod。
在这种情况下,Pod 被 NodeResourceFit 和 NodeAffinity 插件拒绝,最终被放到 unschedulableQ 中。
此后,每当注册在这些插件中的集群事件发生时,调度队列通过 QueueingHint 通知它们。如果来自 NodeResourceFit 或 NodeAffinity 的任何一个的 QueueingHintFn 返回 Queue,则将 Pod 移动到 activeQ或者backoffQ中。 (例如,当 NodeAdded 事件发生时,NodeResourceFit 的 QueueingHint 返回 Queue,因为 Pod 可能可调度到该新节点。)
它是移动到 activeQ 还是 backoffQ,这取决于此 Pod 在unschedulableQ 中停留的时间有多长。如果在unschedulableQ 停留的时间超过了预期的 Pod 的 backoff 延迟时间,则它将直接移动到 activeQ。否则,它将移动到 backoffQ。
b. Pod因 Pending 状态而被拒绝
当 DRA 插件在 Reserve extension 阶段针对Pod返回 Pending时,调度队列将 DRA 插件添加到 Pod 的pendingPlugins 字典中的同时,Pod 返回调度队列。
当 DRA 插件的 QueueingHint 之后的调用中返回 Queue 时,调度队列将此 Pod 直接放入 activeQ。
1 | // Reserve reserves claims for the pod. |
c. 跟踪调度队列中正在处理的 Pod
通过引入 QueueingHint,我们只能在特定事件发生时重试调度。但是,如果这些事件发生在Pod 的调度期间呢?
调度器对集群数据进行快照,并根据快照调度 Pod。每次启动调度周期时都会更新快照,换句话说,相同的快照在相同的调度周期中使用。
考虑到这样一个情景,比如,在调度一个 Pod 时,由于没有任何节点符合 Pod 的节点亲和性(NodeAffinity),因此被拒绝,但是在调度过程中加入了一个新的节点,它与 Pod 的节点亲和性匹配。
如前所述,这个新节点在本次调度周期内不被视为候选节点,因此 Pod 仍然被节点亲和性插件拒绝。问题在于,如果调度队列将 Pod 放入unschedulableQ中,那么即使已经有一个节点匹配了 Pod 的节点亲和性要求,该 Pod 仍需要等待另一个事件。
为了避免类似Pod 在调度过程中错过事件的场景,调度队列会记录 Pod 调度期间发生的事件,并根据这些事件和QueueingHint来决定Pod 入队的位置。
因此,调度队列会缓存自 Pod 离开调度队列直到 Pod 返回调度队列或被调度的所有事件。当不再需要缓存的事件时,缓存的事件将被丢弃。
Golang双向链表
*list.List
是 Go 语言标准库 container/list
包中的一种数据结构,表示一个双向链表。在 Go 中,双向链表是一种常见的数据结构,用于在元素的插入、删除和遍历等操作上提供高效性能。
以下是 *list.List
结构的简要介绍:
- 定义:
*list.List
是一个指向双向链表的指针,它包含了链表的头部和尾部指针,以及链表的长度信息。 - 特性:双向链表中的每个节点都包含指向前一个节点和后一个节点的指针,这使得在链表中插入和删除元素的操作效率很高。
- 用途:
*list.List
常用于需要频繁插入和删除操作的场景,尤其是当元素的数量不固定或顺序可能经常变化时。
演示了如何在 Go 中使用 *list.List
:
1 | package main |
PushBack
方法会向链表的尾部添加一个新元素,并返回表示新元素的 *list.Element
指针。这个指针可以用于后续对该元素的操作,例如删除或修改。
*list.Element
结构体包含了指向链表中前一个和后一个元素的指针,以及一个存储元素值的字段。通过返回 *list.Element
指针,我们可以方便地在需要时访问到新添加的元素,以便进行进一步的操作。要从双向链表中删除元素,你可以使用list.Remove()
方法。这个方法需要传入一个链表元素,然后会将该元素从链表中移除。
1 | package main |
这段代码会输出:
1 | 1 |
在这个例子中,我们移除了链表中第二个元素(值为2)。
浅析一番
直接上pprof来分析一下内存使用情况,部分pprof列表,如下所示:
这里可以发现,内存主要集中在protobuf的Decode,在不具体分析pprof的前提下,我们的思路有三点:
- grpc-go是否有内存问题
- go本身是否问题
- K8s内存问题
针对第一个的假设,可以捞一下grpc-go的相关issue,可以发现近期未见相关内存异常的报告,go本身的问题,看起来也不太像,但倒是找到一个THP的相关问题,以后可以简单介绍一下,那么只剩一个结果,就是K8s本身存在问题,但其中(*FieldsV1).Unmarshal
5年没动了,大概率不会存在问题,那么我们深入分析一下pprof吧
1 | k8s.io/apimachinery/pkg/apis/meta/v1.(*FieldsV1).Unmarshal |
过段时间:
1 | k8s.io/apimachinery/pkg/apis/meta/v1.(*FieldsV1).Unmarshal |
在持续增长的 Pod 列表中,发现了一些未释放的数据似乎与先前使用 pprof 分析的结果吻合,仅发现 Pod 是持续变更的对象。因此,我尝试了另一种排查方法,验证社区是否已解决此问题。我使用 minikube 在本地启动了 Kubernetes 1.18.5 版本进行排查。幸运的是,我未能复现这一现象,表明问题可能在 1.18.5 版本后已修复。
为了进一步缩小排查范围,我让同事检查了这三个小版本之间的提交记录。最终发现了一个关闭了 SchedulerQueueingHints 特性的 PR。正如在技术背景中提到的,SchedulerQueueingHints 特性可能导致内存增长问题。
通过PriorityQueue结构体可以发现其通过isSchedulingQueueHintEnabled来控制特性的逻辑处理,如果开启了QueueingHint
特性,那么在执行Pop方法来调度Pod时,需要为inFlightPods对应pod的UID填充相同inFlightEvents的链表
1 | func (p *PriorityQueue) Pop(logger klog.Logger) (*framework.QueuedPodInfo, error) { |
那么链表字段何时移除?我们可以观察到移除的唯一时间点在pod完成调度周期时,也就是调用Done方法时
1 | func (p *PriorityQueue) Done(pod types.UID) { |
这里可以发现如何done的时机越晚,内存的增长将越明显,并且如果Pod的事件被忽视或者遗漏,链表的内存同样会出现异常增加的现象,可以看到针对上述场景的一些修复:
- 出现了call Done() as soon as possible这样的PR,参看PR#120586
- NodeAffinity/NodeUnschedulable插件的QueueingHint 遗漏相关Node事件,参看PR#122284
引用
- https://github.com/kubernetes/kubernetes/issues/122725
- https://github.com/kubernetes/kubernetes/issues/122284
- https://github.com/kubernetes/kubernetes/pull/122289
- https://github.com/kubernetes/kubernetes/issues/118893
- https://github.com/kubernetes/enhancements/blob/cf6ee34e37f00d838872d368ec66d7a0b40ee4e6/keps/sig-scheduling/4247-queueinghint/README.md?plain=1#L579
- https://github.com/kubernetes/kubernetes/issues/122661
- https://github.com/kubernetes/kubernetes/pull/120586
- https://github.com/kubernetes/kubernetes/issues/118059