探索Kubernetes 1.28調度器OOM的根源
1、問題描述
年前,同事升級K8s調度器至1.28.3,觀察到內存異常現象,幫忙一起看看,在集群pod及node隨業務潮汐變動的情況下,內存呈現不斷上升的趨勢,直至OOM.(下述數據均來源自社區)
圖片
觸發場景有以下兩種(社區還有其他復現方式):
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.
我們在社區的發現多起類似內存異常場景,復現方式不盡相同,關于上述問題的結論是:
Kubernetes社區在1.28版本中默認開啟了調度特性SchedulerQueueingHints,導致調度組件內存異常。為了臨時解決內存等問題,社區在1.28.5中將該特性調整為默認關閉。因為問題并未完全修復,所以建議審慎開啟該特性。
2、技術背景
該章節介紹以下內容:
- 介紹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中或在綁定周期中)。
// 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調度由淺入深:框架,后續會更新最新的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
// EnqueueExtensions 是一個可選接口,插件可以實現在內部調度隊列中移動無法調度的 Pod。可以導
// 致Pod無法調度(例如,Filter 插件)的插件可以實現此接口。
type EnqueueExtensions interface {
Plugin
...
EventsToRegister() []ClusterEventWithHint
}
每個 ClusterEventWithHint結構體包含一個 ClusterEvent 和一個 QueueingHintFn,當事件發生時執行 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 是一個函數,其返回類型為 (QueueingHint, error)。其中,QueueingHint 是一個枚舉類型,可能的值有 QueueSkip 和 Queue。QueueingHintFn 調用時機位于將 Pod 從 unschedulableQ 移動到 backoffQ 或 activeQ 之前,如果返回錯誤,將把調用方返回的 QueueingHint 處理為 QueueAfterBackoff,這種處理無論返回的結果是什么,都可以防止 Pod 永遠待在unschedulableQ 隊列中。
a. 何時跳過/不跳過 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 被重新調度。
b. QueueingHint 如何工作
當K8s集群事件發生時,調度隊列將執行在之前調度周期中拒絕 Pod 的那些插件的 QueueingHintFn。
通過下述幾個場景,描述一下它們如何被執行以及如何移動 Pod。
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。
Pod因 Pending 狀態而被拒絕
當 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,我們只能在特定事件發生時重試調度。但是,如果這些事件發生在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 常用于需要頻繁插入和刪除操作的場景,尤其是當元素的數量不固定或順序可能經常變化時。
下面示例:
package main
import (
"container/list"
"fmt"
)
func main() {
// 創建一個新的雙向鏈表
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 指針。這個指針可以用于后續對該元素的操作,例如刪除或修改。
*list.Element 結構體包含了指向鏈表中前一個和后一個元素的指針,以及一個存儲元素值的字段。通過返回 *list.Element 指針,我們可以方便地在需要時訪問到新添加的元素,以便進行進一步的操作。要從雙向鏈表中刪除元素,你可以使用list.Remove()方法。這個方法需要傳入一個鏈表元素,然后會將該元素從鏈表中移除。
package main
import (
"container/list"
"fmt"
)
func main() {
// 創建一個新的雙向鏈表
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列表,如下所示:
圖片
這里可以發現,內存主要集中在protobuf的Decode,在不具體分析pprof的前提下,我們的思路有三點:
- grpc-go是否有內存問題
- go本身是否問題
- K8s內存問題
針對第一個的假設,可以撈一下grpc-go的相關issue,可以發現近期未見相關內存異常的報告,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 . . }
在持續增長的 Pod 列表中,發現了一些未釋放的數據似乎與先前使用 pprof 分析的結果吻合,僅發現 Pod 是持續變更的對象。因此,我嘗試了另一種排查方法,驗證社區是否已解決此問題。我使用 minikube 在本地啟動了 Kubernetes 1.18.5 版本進行排查。幸運的是,我未能復現這一現象,表明問題可能在 1.18.5 版本后已修復。
為了進一步縮小排查范圍,我讓同事檢查了這三個小版本之間的提交記錄。最終發現了一個關閉了 SchedulerQueueingHints 特性的 PR。正如在技術背景中提到的,SchedulerQueueingHints 特性可能導致內存增長問題。
通過PriorityQueue結構體可以發現其通過isSchedulingQueueHintEnabled來控制特性的邏輯處理,如果開啟了QueueingHint 特性,那么在執行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)
}
}
這里可以發現如何done的時機越晚,內存的增長將越明顯,并且如果Pod的事件被忽視或者遺漏,鏈表的內存同樣會出現異常增加的現象,可以看到針對上述場景的一些修復:
- 出現了call Done() as soon as possible這樣的PR,參看PR#120586
- NodeAffinity/NodeUnschedulable插件的QueueingHint 遺漏相關Node事件,參看PR#122284
由于筆者時間、視野、認知有限,本文難免出現錯誤、疏漏等問題,期待各位讀者朋友、業界專家指正交流。
參考文獻
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」,可以通過以下二維碼關注。
轉載本文請聯系「DCOS」公眾號。