探索Kubernetes 1.28調度器OOM的根源
1、問題描述
年前,同事升級K8s調度器至1.28.3,觀察到內存異?,F(xiàn)象,幫忙一起看看,在集群pod及node隨業(yè)務潮汐變動的情況下,內存呈現(xiàn)不斷上升的趨勢,直至OOM.(下述數(shù)據(jù)均來源自社區(qū))
圖片
觸發(fā)場景有以下兩種(社區(qū)還有其他復現(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)多起類似內存異常場景,復現(xiàn)方式不盡相同,關于上述問題的結論是:
Kubernetes社區(qū)在1.28版本中默認開啟了調度特性SchedulerQueueingHints,導致調度組件內存異常。為了臨時解決內存等問題,社區(qū)在1.28.5中將該特性調整為默認關閉。因為問題并未完全修復,所以建議審慎開啟該特性。
2、技術背景
該章節(jié)介紹以下內容:
- 介紹K8s調度器相關結構體
- 介紹K8s調度器QueueingHint
- golang的雙向鏈表
調度器簡介
PriorityQueue是SchedulingQueue的接口實現(xiàn)。它的頭部存放著優(yōu)先級最高的待調度Pod。PriorityQueue包含以下重要字段:
- activeQ:存放準備好調度的Pod。新添加的Pod會被放入該隊列。調度隊列需要執(zhí)行調度時,會從該隊列中獲取Pod。activeQ由堆來實現(xiàn)。
- backoffQ:存放因各種原因(比如未滿足節(jié)點要求)而被判定為無法調度的Pod。這些Pod會在一段退避時間后,被移到activeQ以嘗試再次調度。backoffQ也由堆來實現(xiàn)。
- unschedulablePods:存放因各種原因無法調度的Pod,是一個map數(shù)據(jù)結構。這些Pod被認定為無法調度,不會直接放入backoffQ,而是被記錄在這里。待條件滿足時,它們將被移到activeQ或者backoffQ中,調度隊列會定期清理unschedulablePods 中的 Pod。
- inFlightEvents:用于保存調度隊列接收到的事件(entry的值是clusterEvent),以及正在處理中的Pod(entry的值是*v1.Pod),基于golang內部實現(xiàn)的雙向鏈表
- inFlightPods:保存了所有已經(jīng)Pop,但尚未調用Done的Pod的UID,換句話說,所有當前正在處理中的Pod(正在調度、在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
}
關于K8s調度器介紹,參看kuberneter調度由淺入深:框架,后續(xù)會更新最新的K8s調度器梳理
QueueingHint簡介
K8s調度器引入了QueueingHint特性,通過從每個插件獲取有關Pod重新入隊的建議,以減少不必要的調度重試,從而提升調度吞吐量。同時,在適當情況下跳過退避,進一步提高Pod調度效率。
需求背景
當前,每個插件可以通過EventsToRegister定義何時重試調度被插件拒絕的Pod。
比如,NodeAffinity會在節(jié)點添加或更新時重試調度Pod,因為新添加或更新的節(jié)點可能具有與Pod上的NodeAffinity匹配的標簽。然而,實際上,在集群中會發(fā)生大量節(jié)點更新事件,這并不能保證之前被NodeAffinity拒絕的Pod能夠成功調度。
為了解決這個問題,調度器引入了更精細的回調函數(shù),以過濾掉無關的事件,從而在下一個調度周期中僅重試可能成功調度的Pod。
另外,DRA(動態(tài)資源分配)調度插件有時需要拒絕Pod以等待來自設備驅動程序的狀態(tài)更新。因此,某些Pod可能需要經(jīng)過幾個調度周期才能完成調度。針對這種情況,與等待設備驅動程序狀態(tài)更新相比,回退等待的時間更長。因此,希望能夠使插件在特定情況下跳過回退以改善調度性能。
實現(xiàn)目標
為了提高調度吞吐量,社區(qū)提出以下改進:
- 引入QueueingHint
- 將 QueueingHint 引入到 EventsToRegister 機制中,允許插件提供針對Pods重新入隊的建議
- 增強 Pod 跟蹤和重新入隊機制:
- 優(yōu)化追蹤調度隊列內正在處理的 Pods實現(xiàn)
- 實現(xiàn)一種機制,將被拒絕的 Pods 重新入隊到適當?shù)年犃?/li>
- 優(yōu)化被拒絕的Pods的退避策略,能夠使插件在特定情況下跳過回退,從而提高調度吞吐量。
潛在風險
1)實現(xiàn)中的錯誤可能導致 Pod 在 unschedulablePods 中長時間無法被調度
如果一個插件配置了 QueueingHint,但它錯過了一些可以讓 Pod 可調度的事件, 被該插件拒絕的 Pod 可能會長期困在 unschedulablePods 中。
雖然調度隊列會定期清理unschedulablePods 中的 Pod。(默認為 5 分鐘,可配)
2)內存使用量的增加
因為調度隊列需要保留調度過程中發(fā)生的事件,kube-scheduler的內存使用量會增加。所以集群越繁忙,它可能需要的內存就越多。
雖然無法完全消除內存增長,但如果能夠盡快釋放緩存的事件,就可以延緩內存增長的速度。
3)EnqueueExtension 中 EventsToRegister 中的重大變更
自定義調度器插件的開發(fā)者需要進行兼容性升級, EnqueueExtension 中的 EventsToRegister 將返回值從 ClusterEvent 更改為 ClusterEventWithHint。ClusterEventWithHint 允許每個插件通過名為 QueueingHintFn 的回調函數(shù)過濾更多無用的事件。
社區(qū)為了簡化遷移工作,空的 QueueingHintFn 被視為始終返回 Queue。因此,如果他們只想保持現(xiàn)有行為,他們只需要將 ClusterEvent 更改為 ClusterEventWithHint 并不需要注冊任何 QueueingHintFn。
QueueingHints設計
EventsToRegister 方法的返回類型已更改為 []ClusterEventWithHint
// EnqueueExtensions 是一個可選接口,插件可以實現(xiàn)在內部調度隊列中移動無法調度的 Pod??梢詫?// 致Pod無法調度(例如,F(xiàn)ilter 插件)的插件可以實現(xiàn)此接口。
type EnqueueExtensions interface {
Plugin
...
EventsToRegister() []ClusterEventWithHint
}
每個 ClusterEventWithHint結構體包含一個 ClusterEvent 和一個 QueueingHintFn,當事件發(fā)生時執(zhí)行 QueueingHintFn,并確定事件是否可以讓 Pod滿足調度。
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 調用時機位于將 Pod 從 unschedulableQ 移動到 backoffQ 或 activeQ 之前,如果返回錯誤,將把調用方返回的 QueueingHint 處理為 QueueAfterBackoff,這種處理無論返回的結果是什么,都可以防止 Pod 永遠待在unschedulableQ 隊列中。
a. 何時跳過/不跳過 backoff
BackoffQ 通過防止“長期無法調度”的 Pod 阻塞隊列以保持高吞吐量的輕量級隊列。
Pod 在調度周期中被拒絕的次數(shù)越多,Pod 需要等待的時間就越長,即在BackoffQ 待得時間就越長。
例如,當 NodeAffinity 拒絕了 Pod,后來在其 QueueingHintFn 中返回 Queue 時,Pod 需要等待 backoff 后才能重試調度。
但是,某些插件的設計本身就需要在調度周期中經(jīng)歷一些失敗。比如內置插件DRA(動態(tài)資源分配),在 Reserve extension處,它告訴資源驅動程序調度結果,并拒絕 Pod 一次以等待資源驅動程序的響應。針對這種拒絕情況,不能將其視作調度周期的浪費,盡管特定調度周期失敗了,但基于該周期的調度結果可以促進 Pod 的調度。因此,由于這種原因被拒絕的 Pod 不需要受到懲罰(backoff)。
為了支持這種情況,我們引入了一個新的狀態(tài) Pending。當 DRA 插件使用 Pending 拒絕 Pod,并且后續(xù)在其 QueueingHintFn 中返回 Queue 時,Pod 跳過 backoff,Pod 被重新調度。
b. QueueingHint 如何工作
當K8s集群事件發(fā)生時,調度隊列將執(zhí)行在之前調度周期中拒絕 Pod 的那些插件的 QueueingHintFn。
通過下述幾個場景,描述一下它們如何被執(zhí)行以及如何移動 Pod。
Pod被一個或多個插件拒絕
假設有三個節(jié)點。當 Pod 進入調度周期時,一個節(jié)點由于資源不足拒絕了Pod,其他兩個節(jié)因為Pod 的 NodeAffinity不匹配拒絕了Pod。
在這種情況下,Pod 被 NodeResourceFit 和 NodeAffinity 插件拒絕,最終被放到 unschedulableQ 中。
此后,每當注冊在這些插件中的集群事件發(fā)生時,調度隊列通過 QueueingHint 通知它們。如果來自 NodeResourceFit 或 NodeAffinity 的任何一個的 QueueingHintFn 返回 Queue,則將 Pod 移動到 activeQ或者backoffQ中。(例如,當 NodeAdded 事件發(fā)生時,NodeResourceFit 的 QueueingHint 返回 Queue,因為 Pod 可能可調度到該新節(jié)點。)
它是移動到 activeQ 還是 backoffQ,這取決于此 Pod 在unschedulableQ 中停留的時間有多長。如果在unschedulableQ 停留的時間超過了預期的 Pod 的 backoff 延遲時間,則它將直接移動到 activeQ。否則,它將移動到 backoffQ。
Pod因 Pending 狀態(tài)而被拒絕
當 DRA 插件在 Reserve extension 階段針對Pod返回 Pending時,調度隊列將 DRA 插件添加到 Pod 的pendingPlugins 字典中的同時,Pod 返回調度隊列。
當 DRA 插件的 QueueingHint 之后的調用中返回 Queue 時,調度隊列將此 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. 跟蹤調度隊列中正在處理的 Pod
通過引入 QueueingHint,我們只能在特定事件發(fā)生時重試調度。但是,如果這些事件發(fā)生在Pod 的調度期間呢?
調度器對集群數(shù)據(jù)進行快照,并根據(jù)快照調度 Pod。每次啟動調度周期時都會更新快照,換句話說,相同的快照在相同的調度周期中使用。
考慮到這樣一個情景,比如,在調度一個 Pod 時,由于沒有任何節(jié)點符合 Pod 的節(jié)點親和性(NodeAffinity),因此被拒絕,但是在調度過程中加入了一個新的節(jié)點,它與 Pod 的節(jié)點親和性匹配。
如前所述,這個新節(jié)點在本次調度周期內不被視為候選節(jié)點,因此 Pod 仍然被節(jié)點親和性插件拒絕。問題在于,如果調度隊列將 Pod 放入unschedulableQ中,那么即使已經(jīng)有一個節(jié)點匹配了 Pod 的節(jié)點親和性要求,該 Pod 仍需要等待另一個事件。
為了避免類似Pod 在調度過程中錯過事件的場景,調度隊列會記錄 Pod 調度期間發(fā)生的事件,并根據(jù)這些事件和QueueingHint來決定Pod 入隊的位置。
因此,調度隊列會緩存自 Pod 離開調度隊列直到 Pod 返回調度隊列或被調度的所有事件。當不再需要緩存的事件時,緩存的事件將被丟棄。
Golang雙向鏈表
*list.List 是 Go 語言標準庫 container/list 包中的一種數(shù)據(jù)結構,表示一個雙向鏈表。在 Go 中,雙向鏈表是一種常見的數(shù)據(jù)結構,用于在元素的插入、刪除和遍歷等操作上提供高效性能。
以下是 *list.List 結構的簡要介紹:
- 定義:*list.List 是一個指向雙向鏈表的指針,它包含了鏈表的頭部和尾部指針,以及鏈表的長度信息。
- 特性:雙向鏈表中的每個節(jié)點都包含指向前一個節(jié)點和后一個節(jié)點的指針,這使得在鏈表中插入和刪除元素的操作效率很高。
- 用途:*list.List 常用于需要頻繁插入和刪除操作的場景,尤其是當元素的數(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 結構體包含了指向鏈表中前一個和后一個元素的指針,以及一個存儲元素值的字段。通過返回 *list.Element 指針,我們可以方便地在需要時訪問到新添加的元素,以便進行進一步的操作。要從雙向鏈表中刪除元素,你可以使用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)
}
}
這段代碼輸出結果:
1
3
在這個例子中,我們移除了鏈表中第二個元素(值為2)。
3、淺析一番
直接上pprof來分析一下內存使用情況,部分pprof列表,如下所示:
圖片
這里可以發(fā)現(xiàn),內存主要集中在protobuf的Decode,在不具體分析pprof的前提下,我們的思路有三點:
- grpc-go是否有內存問題
- go本身是否問題
- K8s內存問題
針對第一個的假設,可以撈一下grpc-go的相關issue,可以發(fā)現(xiàn)近期未見相關內存異常的報告,go本身的問題,看起來也不太像,但倒是找到一個THP的相關問題,以后可以簡單介紹一下,那么只剩一個結果,就是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 分析的結果吻合,僅發(fā)現(xiàn) Pod 是持續(xù)變更的對象。因此,我嘗試了另一種排查方法,驗證社區(qū)是否已解決此問題。我使用 minikube 在本地啟動了 Kubernetes 1.18.5 版本進行排查。幸運的是,我未能復現(xiàn)這一現(xiàn)象,表明問題可能在 1.18.5 版本后已修復。
為了進一步縮小排查范圍,我讓同事檢查了這三個小版本之間的提交記錄。最終發(fā)現(xiàn)了一個關閉了 SchedulerQueueingHints 特性的 PR。正如在技術背景中提到的,SchedulerQueueingHints 特性可能導致內存增長問題。
通過PriorityQueue結構體可以發(fā)現(xiàn)其通過isSchedulingQueueHintEnabled來控制特性的邏輯處理,如果開啟了QueueingHint 特性,那么在執(zhí)行Pop方法來調度Pod時,需要為inFlightPods對應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
}
那么鏈表字段何時移除?我們可以觀察到移除的唯一時間點在pod完成調度周期時,也就是調用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的時機越晚,內存的增長將越明顯,并且如果Pod的事件被忽視或者遺漏,鏈表的內存同樣會出現(xiàn)異常增加的現(xiàn)象,可以看到針對上述場景的一些修復:
- 出現(xiàn)了call Done() as soon as possible這樣的PR,參看PR#120586
- NodeAffinity/NodeUnschedulable插件的QueueingHint 遺漏相關Node事件,參看PR#122284
由于筆者時間、視野、認知有限,本文難免出現(xiàn)錯誤、疏漏等問題,期待各位讀者朋友、業(yè)界專家指正交流。
參考文獻
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
本文轉載自微信公眾號「 DCOS」,作者「DCOS」,可以通過以下二維碼關注。
轉載本文請聯(lián)系「DCOS」公眾號。