探索Kubernetes 1.28調(diào)度器OOM的根源
1、問題描述
年前,同事升級K8s調(diào)度器至1.28.3,觀察到內(nèi)存異?,F(xiàn)象,幫忙一起看看,在集群pod及node隨業(yè)務(wù)潮汐變動的情況下,內(nèi)存呈現(xiàn)不斷上升的趨勢,直至OOM.(下述數(shù)據(jù)均來源自社區(qū))
圖片
觸發(fā)場景有以下兩種(社區(qū)還有其他復(fù)現(xiàn)方式):
Case 1
for (( ; ; ))
do
kubectl scale deployment nginx-test --replicas=0
sleep 30
kubectl scale deployment nginx-test --replicas=60
sleep 30
done
Case 2
1. Create a Pod with NodeAffinity under the situation where no Node can accommodate the Pod.
2. Create a new Node.
我們在社區(qū)的發(fā)現(xiàn)多起類似內(nèi)存異常場景,復(fù)現(xiàn)方式不盡相同,關(guān)于上述問題的結(jié)論是:
Kubernetes社區(qū)在1.28版本中默認(rèn)開啟了調(diào)度特性SchedulerQueueingHints,導(dǎo)致調(diào)度組件內(nèi)存異常。為了臨時解決內(nèi)存等問題,社區(qū)在1.28.5中將該特性調(diào)整為默認(rèn)關(guān)閉。因?yàn)閱栴}并未完全修復(fù),所以建議審慎開啟該特性。
2、技術(shù)背景
該章節(jié)介紹以下內(nèi)容:
- 介紹K8s調(diào)度器相關(guān)結(jié)構(gòu)體
- 介紹K8s調(diào)度器QueueingHint
- golang的雙向鏈表
調(diào)度器簡介
PriorityQueue是SchedulingQueue的接口實(shí)現(xiàn)。它的頭部存放著優(yōu)先級最高的待調(diào)度Pod。PriorityQueue包含以下重要字段:
- activeQ:存放準(zhǔn)備好調(diào)度的Pod。新添加的Pod會被放入該隊列。調(diào)度隊列需要執(zhí)行調(diào)度時,會從該隊列中獲取Pod。activeQ由堆來實(shí)現(xiàn)。
- backoffQ:存放因各種原因(比如未滿足節(jié)點(diǎn)要求)而被判定為無法調(diào)度的Pod。這些Pod會在一段退避時間后,被移到activeQ以嘗試再次調(diào)度。backoffQ也由堆來實(shí)現(xiàn)。
- unschedulablePods:存放因各種原因無法調(diào)度的Pod,是一個map數(shù)據(jù)結(jié)構(gòu)。這些Pod被認(rèn)定為無法調(diào)度,不會直接放入backoffQ,而是被記錄在這里。待條件滿足時,它們將被移到activeQ或者backoffQ中,調(diào)度隊列會定期清理unschedulablePods 中的 Pod。
- inFlightEvents:用于保存調(diào)度隊列接收到的事件(entry的值是clusterEvent),以及正在處理中的Pod(entry的值是*v1.Pod),基于golang內(nèi)部實(shí)現(xiàn)的雙向鏈表
- inFlightPods:保存了所有已經(jīng)Pop,但尚未調(diào)用Done的Pod的UID,換句話說,所有當(dāng)前正在處理中的Pod(正在調(diào)度、在admit中或在綁定周期中)。
// PriorityQueue implements a scheduling queue.
type PriorityQueue struct {
...
inFlightPods map[types.UID]*list.Element
inFlightEvents *list.List
activeQ *heap.Heap
podBackoffQ *heap.Heap
// unschedulablePods holds pods that have been tried and determined unschedulable.
unschedulablePods *UnschedulablePods
// schedulingCycle represents sequence number of scheduling cycle and is incremented
// when a pod is popped.
...
// preEnqueuePluginMap is keyed with profile name, valued with registered preEnqueue plugins.
preEnqueuePluginMap map[string][]framework.PreEnqueuePlugin
...
// isSchedulingQueueHintEnabled indicates whether the feature gate for the scheduling queue is enabled.
isSchedulingQueueHintEnabled bool
}
關(guān)于K8s調(diào)度器介紹,參看kuberneter調(diào)度由淺入深:框架,后續(xù)會更新最新的K8s調(diào)度器梳理
QueueingHint簡介
K8s調(diào)度器引入了QueueingHint特性,通過從每個插件獲取有關(guān)Pod重新入隊的建議,以減少不必要的調(diào)度重試,從而提升調(diào)度吞吐量。同時,在適當(dāng)情況下跳過退避,進(jìn)一步提高Pod調(diào)度效率。
需求背景
當(dāng)前,每個插件可以通過EventsToRegister定義何時重試調(diào)度被插件拒絕的Pod。
比如,NodeAffinity會在節(jié)點(diǎn)添加或更新時重試調(diào)度Pod,因?yàn)樾绿砑踊蚋碌墓?jié)點(diǎn)可能具有與Pod上的NodeAffinity匹配的標(biāo)簽。然而,實(shí)際上,在集群中會發(fā)生大量節(jié)點(diǎn)更新事件,這并不能保證之前被NodeAffinity拒絕的Pod能夠成功調(diào)度。
為了解決這個問題,調(diào)度器引入了更精細(xì)的回調(diào)函數(shù),以過濾掉無關(guān)的事件,從而在下一個調(diào)度周期中僅重試可能成功調(diào)度的Pod。
另外,DRA(動態(tài)資源分配)調(diào)度插件有時需要拒絕Pod以等待來自設(shè)備驅(qū)動程序的狀態(tài)更新。因此,某些Pod可能需要經(jīng)過幾個調(diào)度周期才能完成調(diào)度。針對這種情況,與等待設(shè)備驅(qū)動程序狀態(tài)更新相比,回退等待的時間更長。因此,希望能夠使插件在特定情況下跳過回退以改善調(diào)度性能。
實(shí)現(xiàn)目標(biāo)
為了提高調(diào)度吞吐量,社區(qū)提出以下改進(jìn):
- 引入QueueingHint
- 將 QueueingHint 引入到 EventsToRegister 機(jī)制中,允許插件提供針對Pods重新入隊的建議
- 增強(qiáng) Pod 跟蹤和重新入隊機(jī)制:
- 優(yōu)化追蹤調(diào)度隊列內(nèi)正在處理的 Pods實(shí)現(xiàn)
- 實(shí)現(xiàn)一種機(jī)制,將被拒絕的 Pods 重新入隊到適當(dāng)?shù)年犃?/li>
- 優(yōu)化被拒絕的Pods的退避策略,能夠使插件在特定情況下跳過回退,從而提高調(diào)度吞吐量。
潛在風(fēng)險
1)實(shí)現(xiàn)中的錯誤可能導(dǎo)致 Pod 在 unschedulablePods 中長時間無法被調(diào)度
如果一個插件配置了 QueueingHint,但它錯過了一些可以讓 Pod 可調(diào)度的事件, 被該插件拒絕的 Pod 可能會長期困在 unschedulablePods 中。
雖然調(diào)度隊列會定期清理unschedulablePods 中的 Pod。(默認(rèn)為 5 分鐘,可配)
2)內(nèi)存使用量的增加
因?yàn)檎{(diào)度隊列需要保留調(diào)度過程中發(fā)生的事件,kube-scheduler的內(nèi)存使用量會增加。所以集群越繁忙,它可能需要的內(nèi)存就越多。
雖然無法完全消除內(nèi)存增長,但如果能夠盡快釋放緩存的事件,就可以延緩內(nèi)存增長的速度。
3)EnqueueExtension 中 EventsToRegister 中的重大變更
自定義調(diào)度器插件的開發(fā)者需要進(jìn)行兼容性升級, EnqueueExtension 中的 EventsToRegister 將返回值從 ClusterEvent 更改為 ClusterEventWithHint。ClusterEventWithHint 允許每個插件通過名為 QueueingHintFn 的回調(diào)函數(shù)過濾更多無用的事件。
社區(qū)為了簡化遷移工作,空的 QueueingHintFn 被視為始終返回 Queue。因此,如果他們只想保持現(xiàn)有行為,他們只需要將 ClusterEvent 更改為 ClusterEventWithHint 并不需要注冊任何 QueueingHintFn。
QueueingHints設(shè)計
EventsToRegister 方法的返回類型已更改為 []ClusterEventWithHint
// EnqueueExtensions 是一個可選接口,插件可以實(shí)現(xiàn)在內(nèi)部調(diào)度隊列中移動無法調(diào)度的 Pod??梢詫?dǎo)
// 致Pod無法調(diào)度(例如,F(xiàn)ilter 插件)的插件可以實(shí)現(xiàn)此接口。
type EnqueueExtensions interface {
Plugin
...
EventsToRegister() []ClusterEventWithHint
}
每個 ClusterEventWithHint結(jié)構(gòu)體包含一個 ClusterEvent 和一個 QueueingHintFn,當(dāng)事件發(fā)生時執(zhí)行 QueueingHintFn,并確定事件是否可以讓 Pod滿足調(diào)度。
type ClusterEventWithHint struct {
Event ClusterEvent
QueueingHintFn QueueingHintFn
}
type QueueingHintFn func(logger klog.Logger, pod *v1.Pod, oldObj, newObj interface{}) (QueueingHint, error)
type QueueingHint int
const (
// QueueSkip implies that the cluster event has no impact on
// scheduling of the pod.
QueueSkip QueueingHint = iota
// Queue implies that the Pod may be schedulable by the event.
Queue
)
類型 QueueingHintFn 是一個函數(shù),其返回類型為 (QueueingHint, error)。其中,QueueingHint 是一個枚舉類型,可能的值有 QueueSkip 和 Queue。QueueingHintFn 調(diào)用時機(jī)位于將 Pod 從 unschedulableQ 移動到 backoffQ 或 activeQ 之前,如果返回錯誤,將把調(diào)用方返回的 QueueingHint 處理為 QueueAfterBackoff,這種處理無論返回的結(jié)果是什么,都可以防止 Pod 永遠(yuǎn)待在unschedulableQ 隊列中。
a. 何時跳過/不跳過 backoff
BackoffQ 通過防止“長期無法調(diào)度”的 Pod 阻塞隊列以保持高吞吐量的輕量級隊列。
Pod 在調(diào)度周期中被拒絕的次數(shù)越多,Pod 需要等待的時間就越長,即在BackoffQ 待得時間就越長。
例如,當(dāng) NodeAffinity 拒絕了 Pod,后來在其 QueueingHintFn 中返回 Queue 時,Pod 需要等待 backoff 后才能重試調(diào)度。
但是,某些插件的設(shè)計本身就需要在調(diào)度周期中經(jīng)歷一些失敗。比如內(nèi)置插件DRA(動態(tài)資源分配),在 Reserve extension處,它告訴資源驅(qū)動程序調(diào)度結(jié)果,并拒絕 Pod 一次以等待資源驅(qū)動程序的響應(yīng)。針對這種拒絕情況,不能將其視作調(diào)度周期的浪費(fèi),盡管特定調(diào)度周期失敗了,但基于該周期的調(diào)度結(jié)果可以促進(jìn) Pod 的調(diào)度。因此,由于這種原因被拒絕的 Pod 不需要受到懲罰(backoff)。
為了支持這種情況,我們引入了一個新的狀態(tài) Pending。當(dāng) DRA 插件使用 Pending 拒絕 Pod,并且后續(xù)在其 QueueingHintFn 中返回 Queue 時,Pod 跳過 backoff,Pod 被重新調(diào)度。
b. QueueingHint 如何工作
當(dāng)K8s集群事件發(fā)生時,調(diào)度隊列將執(zhí)行在之前調(diào)度周期中拒絕 Pod 的那些插件的 QueueingHintFn。
通過下述幾個場景,描述一下它們?nèi)绾伪粓?zhí)行以及如何移動 Pod。
Pod被一個或多個插件拒絕
假設(shè)有三個節(jié)點(diǎn)。當(dāng) Pod 進(jìn)入調(diào)度周期時,一個節(jié)點(diǎn)由于資源不足拒絕了Pod,其他兩個節(jié)因?yàn)镻od 的 NodeAffinity不匹配拒絕了Pod。
在這種情況下,Pod 被 NodeResourceFit 和 NodeAffinity 插件拒絕,最終被放到 unschedulableQ 中。
此后,每當(dāng)注冊在這些插件中的集群事件發(fā)生時,調(diào)度隊列通過 QueueingHint 通知它們。如果來自 NodeResourceFit 或 NodeAffinity 的任何一個的 QueueingHintFn 返回 Queue,則將 Pod 移動到 activeQ或者backoffQ中。(例如,當(dāng) NodeAdded 事件發(fā)生時,NodeResourceFit 的 QueueingHint 返回 Queue,因?yàn)?Pod 可能可調(diào)度到該新節(jié)點(diǎn)。)
它是移動到 activeQ 還是 backoffQ,這取決于此 Pod 在unschedulableQ 中停留的時間有多長。如果在unschedulableQ 停留的時間超過了預(yù)期的 Pod 的 backoff 延遲時間,則它將直接移動到 activeQ。否則,它將移動到 backoffQ。
Pod因 Pending 狀態(tài)而被拒絕
當(dāng) DRA 插件在 Reserve extension 階段針對Pod返回 Pending時,調(diào)度隊列將 DRA 插件添加到 Pod 的pendingPlugins 字典中的同時,Pod 返回調(diào)度隊列。
當(dāng) DRA 插件的 QueueingHint 之后的調(diào)用中返回 Queue 時,調(diào)度隊列將此 Pod 直接放入 activeQ。
// Reserve reserves claims for the pod.
func (pl *dynamicResources) Reserve(ctx context.Context, cs *framework.CycleState, pod *v1.Pod, nodeName string) *framework.Status {
...
if numDelayedAllocationPending == 1 || numClaimsWithStatusInfo == numDelayedAllocationPending {
...
schedulingCtx.Spec.SelectedNode = nodeName
logger.V(5).Info("start allocation", "pod", klog.KObj(pod), "node", klog.ObjectRef{Name: nodeName})
...
return statusUnschedulable(logger, "waiting for resource driver to allocate resource", "pod", klog.KObj(pod), "node", klog.ObjectRef{Name: nodeName})
}
...
return statusUnschedulable(logger, "waiting for resource driver to provide information", "pod", klog.KObj(pod))
}
c. 跟蹤調(diào)度隊列中正在處理的 Pod
通過引入 QueueingHint,我們只能在特定事件發(fā)生時重試調(diào)度。但是,如果這些事件發(fā)生在Pod 的調(diào)度期間呢?
調(diào)度器對集群數(shù)據(jù)進(jìn)行快照,并根據(jù)快照調(diào)度 Pod。每次啟動調(diào)度周期時都會更新快照,換句話說,相同的快照在相同的調(diào)度周期中使用。
考慮到這樣一個情景,比如,在調(diào)度一個 Pod 時,由于沒有任何節(jié)點(diǎn)符合 Pod 的節(jié)點(diǎn)親和性(NodeAffinity),因此被拒絕,但是在調(diào)度過程中加入了一個新的節(jié)點(diǎn),它與 Pod 的節(jié)點(diǎn)親和性匹配。
如前所述,這個新節(jié)點(diǎn)在本次調(diào)度周期內(nèi)不被視為候選節(jié)點(diǎn),因此 Pod 仍然被節(jié)點(diǎn)親和性插件拒絕。問題在于,如果調(diào)度隊列將 Pod 放入unschedulableQ中,那么即使已經(jīng)有一個節(jié)點(diǎn)匹配了 Pod 的節(jié)點(diǎn)親和性要求,該 Pod 仍需要等待另一個事件。
為了避免類似Pod 在調(diào)度過程中錯過事件的場景,調(diào)度隊列會記錄 Pod 調(diào)度期間發(fā)生的事件,并根據(jù)這些事件和QueueingHint來決定Pod 入隊的位置。
因此,調(diào)度隊列會緩存自 Pod 離開調(diào)度隊列直到 Pod 返回調(diào)度隊列或被調(diào)度的所有事件。當(dāng)不再需要緩存的事件時,緩存的事件將被丟棄。
Golang雙向鏈表
*list.List 是 Go 語言標(biāo)準(zhǔn)庫 container/list 包中的一種數(shù)據(jù)結(jié)構(gòu),表示一個雙向鏈表。在 Go 中,雙向鏈表是一種常見的數(shù)據(jù)結(jié)構(gòu),用于在元素的插入、刪除和遍歷等操作上提供高效性能。
以下是 *list.List 結(jié)構(gòu)的簡要介紹:
- 定義:*list.List 是一個指向雙向鏈表的指針,它包含了鏈表的頭部和尾部指針,以及鏈表的長度信息。
- 特性:雙向鏈表中的每個節(jié)點(diǎn)都包含指向前一個節(jié)點(diǎn)和后一個節(jié)點(diǎn)的指針,這使得在鏈表中插入和刪除元素的操作效率很高。
- 用途:*list.List 常用于需要頻繁插入和刪除操作的場景,尤其是當(dāng)元素的數(shù)量不固定或順序可能經(jīng)常變化時。
下面示例:
package main
import (
"container/list"
"fmt"
)
func main() {
// 創(chuàng)建一個新的雙向鏈表
l := list.New()
// 在鏈表尾部添加元素
l.PushBack(1)
l.PushBack(2)
l.PushBack(3)
// 遍歷鏈表并打印元素
for e := l.Front(); e != nil; e = e.Next() {
fmt.Println(e.Value)
}
}
PushBack 方法會向鏈表的尾部添加一個新元素,并返回表示新元素的 *list.Element 指針。這個指針可以用于后續(xù)對該元素的操作,例如刪除或修改。
*list.Element 結(jié)構(gòu)體包含了指向鏈表中前一個和后一個元素的指針,以及一個存儲元素值的字段。通過返回 *list.Element 指針,我們可以方便地在需要時訪問到新添加的元素,以便進(jìn)行進(jìn)一步的操作。要從雙向鏈表中刪除元素,你可以使用list.Remove()方法。這個方法需要傳入一個鏈表元素,然后會將該元素從鏈表中移除。
package main
import (
"container/list"
"fmt"
)
func main() {
// 創(chuàng)建一個新的雙向鏈表
myList := list.New()
// 在鏈表尾部添加元素
myList.PushBack(1)
myList.PushBack(2)
myList.PushBack(3)
// 找到要刪除的元素
elementToRemove := myList.Front().Next()
// 從鏈表中移除該元素
myList.Remove(elementToRemove)
// 打印剩余的元素
for element := myList.Front(); element != nil; element = element.Next() {
fmt.Println(element.Value)
}
}
這段代碼輸出結(jié)果:
1
3
在這個例子中,我們移除了鏈表中第二個元素(值為2)。
3、淺析一番
直接上pprof來分析一下內(nèi)存使用情況,部分pprof列表,如下所示:
圖片
這里可以發(fā)現(xiàn),內(nèi)存主要集中在protobuf的Decode,在不具體分析pprof的前提下,我們的思路有三點(diǎn):
- grpc-go是否有內(nèi)存問題
- go本身是否問題
- K8s內(nèi)存問題
針對第一個的假設(shè),可以撈一下grpc-go的相關(guān)issue,可以發(fā)現(xiàn)近期未見相關(guān)內(nèi)存異常的報告,go本身的問題,看起來也不太像,但倒是找到一個THP的相關(guān)問題,以后可以簡單介紹一下,那么只剩一個結(jié)果,就是K8s本身存在問題,但其中(*FieldsV1).Unmarshal5年沒動了,大概率不會存在問題,那么我們簡單分析一下pprof吧
k8s.io/apimachinery/pkg/apis/meta/v1.(*FieldsV1).Unmarshal
vendor/k8s.io/apimachinery/pkg/apis/meta/v1/generated.pb.go
Total: 309611 309611 (flat, cum) 2.62%
6502 . . if postIndex > l {
6503 . . return io.ErrUnexpectedEOF
6504 . . }
6505 309611 309611 m.Raw = append(m.Raw[:0], dAtA[iNdEx:postIndex]...)
6506 . . if m.Raw == nil {
6507 . . m.Raw = []byte{}
6508 . . }
過段時間:
k8s.io/apimachinery/pkg/apis/meta/v1.(*FieldsV1).Unmarshal
vendor/k8s.io/apimachinery/pkg/apis/meta/v1/generated.pb.go
Total: 2069705 2069705 (flat, cum) 2.49%
6502 . . if postIndex > l {
6503 . . return io.ErrUnexpectedEOF
6504 . . }
6505 2069705 2069705 m.Raw = append(m.Raw[:0], dAtA[iNdEx:postIndex]...)
6506 . . if m.Raw == nil {
6507 . . m.Raw = []byte{}
6508 . . }
在持續(xù)增長的 Pod 列表中,發(fā)現(xiàn)了一些未釋放的數(shù)據(jù)似乎與先前使用 pprof 分析的結(jié)果吻合,僅發(fā)現(xiàn) Pod 是持續(xù)變更的對象。因此,我嘗試了另一種排查方法,驗(yàn)證社區(qū)是否已解決此問題。我使用 minikube 在本地啟動了 Kubernetes 1.18.5 版本進(jìn)行排查。幸運(yùn)的是,我未能復(fù)現(xiàn)這一現(xiàn)象,表明問題可能在 1.18.5 版本后已修復(fù)。
為了進(jìn)一步縮小排查范圍,我讓同事檢查了這三個小版本之間的提交記錄。最終發(fā)現(xiàn)了一個關(guān)閉了 SchedulerQueueingHints 特性的 PR。正如在技術(shù)背景中提到的,SchedulerQueueingHints 特性可能導(dǎo)致內(nèi)存增長問題。
通過PriorityQueue結(jié)構(gòu)體可以發(fā)現(xiàn)其通過isSchedulingQueueHintEnabled來控制特性的邏輯處理,如果開啟了QueueingHint 特性,那么在執(zhí)行Pop方法來調(diào)度Pod時,需要為inFlightPods對應(yīng)pod的UID填充相同inFlightEvents的鏈表
func (p *PriorityQueue) Pop(logger klog.Logger) (*framework.QueuedPodInfo, error) {
p.lock.Lock()
defer p.lock.Unlock()
obj, err := p.activeQ.Pop()
...
// In flight, no concurrent events yet.
if p.isSchedulingQueueHintEnabled {
p.inFlightPods[pInfo.Pod.UID] = p.inFlightEvents.PushBack(pInfo.Pod)
}
...
return pInfo, nil
}
那么鏈表字段何時移除?我們可以觀察到移除的唯一時間點(diǎn)在pod完成調(diào)度周期時,也就是調(diào)用Done方法時
func (p *PriorityQueue) Done(pod types.UID) {
p.lock.Lock()
defer p.lock.Unlock()
p.done(pod)
}
func (p *PriorityQueue) done(pod types.UID) {
if !p.isSchedulingQueueHintEnabled {
// do nothing if schedulingQueueHint is disabled.
// In that case, we don't have inFlightPods and inFlightEvents.
return
}
inFlightPod, ok := p.inFlightPods[pod]
if !ok {
// This Pod is already done()ed.
return
}
delete(p.inFlightPods, pod)
// Remove the pod from the list.
p.inFlightEvents.Remove(inFlightPod)
for {
...
p.inFlightEvents.Remove(e)
}
}
這里可以發(fā)現(xiàn)如何done的時機(jī)越晚,內(nèi)存的增長將越明顯,并且如果Pod的事件被忽視或者遺漏,鏈表的內(nèi)存同樣會出現(xiàn)異常增加的現(xiàn)象,可以看到針對上述場景的一些修復(fù):
- 出現(xiàn)了call Done() as soon as possible這樣的PR,參看PR#120586
- NodeAffinity/NodeUnschedulable插件的QueueingHint 遺漏相關(guān)Node事件,參看PR#122284
由于筆者時間、視野、認(rèn)知有限,本文難免出現(xiàn)錯誤、疏漏等問題,期待各位讀者朋友、業(yè)界專家指正交流。
參考文獻(xiàn)
1. https://github.com/kubernetes/kubernetes/issues/122725
2. https://github.com/kubernetes/kubernetes/issues/122284
3. https://github.com/kubernetes/kubernetes/pull/122289
4. https://github.com/kubernetes/kubernetes/issues/118893
4. https://github.com/kubernetes/enhancements/blob/master/keps/sig-scheduling/4247-queueinghint/README.md?plain=1#L579
5. https://github.com/kubernetes/kubernetes/issues/122661
6. https://github.com/kubernetes/kubernetes/pull/120586
7. https://github.com/kubernetes/kubernetes/issues/118059
本文轉(zhuǎn)載自微信公眾號「 DCOS」,作者「DCOS」,可以通過以下二維碼關(guān)注。
轉(zhuǎn)載本文請聯(lián)系「DCOS」公眾號。