Kube-apiserver 又 OOM 了?
由來
前一篇已經(jīng)介紹了 Informer 的實現(xiàn),Informer 對 kube-apiserver 發(fā)起了 list 和 watch 請求。我們知道大規(guī)模集群下,kube-apiserver 會成為瓶頸,尤其在內(nèi)存方面,相信很多人也遇到過 kube-apiserver OOM 等問題(碰巧的是最近線上連續(xù)出現(xiàn)兩次 kube-apiserver OOM 的問題)。本篇主要講 kube-apiserver 中 Informer 需要用到的兩個接口 list 和 watch 的實現(xiàn)。
網(wǎng)上搜索的話,可以找到大量相關(guān)的源碼解析的文章,這里我并不會去過多涉及代碼,主要還是以講原理、流程為主,最后簡單介紹下當前存在的問題,理論實踐相結(jié)合。本篇主要講當前實現(xiàn),只有了解了當前實現(xiàn),明白了為什么會有問題,才知道如何去解決問題,接下來的一篇會詳細分析如何解決這些問題。
原理
Cacher 加載
圖片
核心組件:Cacher,watchCache,cacheWatcher,reflector。其中 watchCache 作為 reflector 的 store,Etcd 作為 listerWatcher 的 storage,store 和 listerWatcher 作為參數(shù)用來構(gòu)造 reflector。數(shù)據(jù)流大致如下:
- kube-apiserver 啟動,針對每種資源類型,調(diào)用其對應(yīng) cacher 的 startCaching,進而調(diào)用 reflector.ListAndWatch,觸發(fā) listerWatcher 的 list 和 watch,對應(yīng) Etcd list 之后再 watch,watch 時會創(chuàng)建 watchChan,從 Etcd 讀到的結(jié)果會先進入到 watchChan 的 incomingEventChan 中,經(jīng)過 transform 處理后發(fā)送到 watchChan 的 resultChan 中,供 reflector 消費;
- reflector 會消費上述 resultChan 的數(shù)據(jù),即 watch.Event 對象,并根據(jù)事件類型調(diào)用 store 的增刪改方法,此處 store 即 watchCache,經(jīng)過 watchCache.processEvent 處理,組裝 watchCacheEvent 對象,更新 watchCache 的 cache(大小自適應(yīng)的喚醒緩沖區(qū),保留歷史 event)和 store(全量數(shù)據(jù)),并最終通過 eventHandler 將其發(fā)送到 cacher 的 incoming chan 中;
- cacher.dispatchEvents 消費 incoming chan 的數(shù)據(jù),經(jīng)過處理后發(fā)送給每個 cacheWatcher 的 input chan;
- 外部調(diào)用 kube-apiserver watch 請求后會創(chuàng)建一個對應(yīng)的 cacheWachter 對象,最終到 cacheWatcher 的 Watch 處理機中,消費 input chan,調(diào)用 watchCacheEvent 進行事件分發(fā);
圖片
Cacher 數(shù)據(jù)流
用來緩存數(shù)據(jù)的核心結(jié)構(gòu)是 watchCache,其內(nèi)部又兩個關(guān)鍵結(jié)構(gòu):cache(cyclic buffer),store(thread safe store),分別用來存儲歷史的 watchCacheEvent 和真實的資源對象,其中 store 里面存儲的是全量對象,而 cache 雖然是自適應(yīng)大小的,但還是有最大容量限制的,所以他存儲的 watchCacheEvent 所代表的對象集合并不一定能覆蓋 store 的全部數(shù)據(jù)。
歷史問題
kube-apiserver 在優(yōu)化自身內(nèi)存使用方面做了很多優(yōu)化了,不過至今仍然存在一些尚未完全解決的問題。
kube-apiserver OOM
內(nèi)存消耗來源
kube-apiserver 的內(nèi)存消耗,主要兩個來源:
- 一部分來自于他緩存了集群所有數(shù)據(jù)(Event 除外,此 Event 為 k8s 的資源類型),并且為每種資源緩存了歷史 watchCacheEvent,以及一些內(nèi)部的數(shù)據(jù)結(jié)構(gòu)和 chan 等,這部分是不可避免的,雖然可以適當優(yōu)化,但作用并不大;
- 另一部分來自于客戶端請求,尤其是 list 請求,kube-apiserver 需要在內(nèi)存中進行數(shù)據(jù)深拷貝,序列化等操作,所需內(nèi)存量和數(shù)據(jù)量、請求量正相關(guān),隨著數(shù)據(jù)量的增加,請求量的增加,所需要的內(nèi)存也越大,而且這部分的內(nèi)存通過 golang GC 是沒有辦法完全回收的,而 list 請求的主要來源就是 Informer;
list 請求占用內(nèi)存多的原因如下:
- 默認情況下(沒有指定 resourceversion 的情況下),直接從etcd獲取數(shù)據(jù)可能需要大量內(nèi)存,超過數(shù)據(jù)存儲的完整響應(yīng)大小數(shù)倍;
- 請求明確指定 ResourceVersion 參數(shù)來從緩存中獲取數(shù)據(jù)(例如,ResourceVersinotallow="0"),這實際上是大多數(shù)基于 client-go 的控制器因性能原因而使用的方法。內(nèi)存使用量將比第一種情況低得多。但這并不是完美的,因為我們?nèi)匀恍枰臻g來存儲序列化對象并保存完整響應(yīng),直到發(fā)送。
常見場景
有兩個常見的容易引起 kube-apiserver OOM 的場景:
- 一些 DaemonSet 之類的程序里面用到了 Informer,在進行變更,或者故障重啟的時候,隨著集群規(guī)模的增加,請求量也隨之增加,對 kube-apiserver 內(nèi)存的壓力也增加,在沒有任何防護措施(限流)的情況下,很容易造成 kube-apiserver 的 OOM,而且在 OOM 之后,異常連接轉(zhuǎn)移到其他 master 節(jié)點,引起雪崩。理論上也屬于一種容量問題,應(yīng)對措施擴容(加 master 節(jié)點)、限流(服務(wù)端限流:APF、MaxInflightRequest等,及客戶端限流)。
- 某種類型資源的數(shù)據(jù)量很大,kube-apiserver 配置的 timeout 參數(shù)太小,不足以支持完成一次 list 請求的情況下,Informer 會一直不斷地嘗試進行 list 操作,這種情況多發(fā)生在控制面組件,因為他們往往需要獲取全量數(shù)據(jù)。
too old resource version
原理
嚴格說,這并不能算是一個問題,機制如此,理論上單機資源無限的情況下是可以避免這個現(xiàn)象的。為了方便描述,用 RV 代指 resourceversion。
其本質(zhì)是客戶端在調(diào)用 watch api 時攜帶非 0 的 RV,服務(wù)端在走到 cacher 的 watch 實現(xiàn)邏輯時需要根據(jù)傳入的 RV 去 cyclic buffer 中二分查找大于 RV 的所有 watchCacheEvent 作為初始 event 返回給客戶端。當 cyclic buffer 的最小 RV 還要比傳入的 RV 大時,也就是說服務(wù)端緩存的事件的最小 RV 都要比客戶端傳過來的大,意味著緩存的歷史事件不全,可能是因為事件較多,緩存大小有限,較老的 watchCacheEvent 已經(jīng)被覆蓋了。
常見場景
- 這種情況多發(fā)生在 kubelet 連接 apiserver 的場景下,或者說 watch 帶了 labelselector 或 fieldselector 的情況下。因為每個 kubelet 只關(guān)心自己節(jié)點的 Pod,如果自身節(jié)點 Pod 一直沒有變化,而其他節(jié)點上的 Pod 變化頻繁,則可能 kubelet 本地 Informer 記錄的 last RV 就會比 cyclic buffer 中的最小的 RV 還要小,這時如果發(fā)生重連(網(wǎng)絡(luò)閃斷,或者 Informer 自身 timeout 重連),則可以在 kube-apiserver 的日志中看到 "too old resoure version" 的字樣。
- kube-apiserver 重啟的場景,如果集群中部分類型資源變更頻繁,部分變更不頻繁,則對于去 watch 變更不頻繁的資源類型的 Informer 來說起本地的 last RV 是要比最新的 RV 小甚至小很多的,在 kube-apiserver 發(fā)生重啟時,他以本地這個很小的 RV 去 watch,還是有可能會觸發(fā)這個問題;
客戶端 Informer 遇到這個報錯的話會退出 ListAndWatch,重新開始執(zhí)行 LIstAndWatch,進而造成 kube-apiserver 內(nèi)存增加甚至 OOM。問題本質(zhì)原因:RV 是全局的。場景的景本質(zhì)區(qū)別在于場景 1 是在一種資源中做了篩選導(dǎo)致的,場景 2 是多種資源類型之間的 RV 差異較大導(dǎo)致的。
優(yōu)化
經(jīng)過上述分析,造成這個問題的原因有兩個:
- cyclic buffer 長度有限;
- 客戶端 Informer 持有的 last RV 過于陳舊;
社區(qū)也已經(jīng)在多個版本之前進行了優(yōu)化來降低這個問題出現(xiàn)的概率。
針對問題一,采用了自適應(yīng)窗口大小,雖然還是會有問題,但相比之前寫死一個值出現(xiàn)問題的概率要小,同時在不必要的時候縮小長度,避免內(nèi)存資源的浪費。
針對問題二,有兩個優(yōu)化,引入了 BOOKMARK 機制來優(yōu)化同一種資源不同篩選條件導(dǎo)致的問題,BOOKMARK 是一種 event 類型,定期將最新的 RV 返回客戶端;引入 ProgressNotify 解決多種資源類型 RV 差異較大,在 kube-apiserver 重啟后,Informer resume 時導(dǎo)致的問題,本質(zhì)是利用了 Etcd 的 clientv3 ProgressNotify 的機制,kube-apiserver 在 Watch Etcd 的時候攜帶了特定的 Options 開啟此功能。ProgressNotify 參考 Etcd 官方文檔[1]:
WithProgressNotify makes watch server send periodic progress updates every 10 minutes when there is no incoming events. Progress updates have zero events in WatchResponse.
詳情可以參考如下 KEP 956-watch-bookmark[2] 和 1904-efficient-watch-resumption[3]
stale read
這更是一個歷史悠久的問題了,自從有了 watchCache 之后就有了這個問題,本質(zhì)是將之前直接訪問 Etcd 時的線性一致性讀(Etcd 提供的能力),降級成了讀 kube-apiserver cache 的順序一致性。
場景
- T1: StatefulSet controller creates pod-0 (uid 1) which is scheduled to node-1
- T2: pod-0 is deleted as part of a rolling upgrade
- node-1 sees that pod-0 is deleted and cleans it up, then deletes the pod in the api
- The StatefulSet controller creates a second pod pod-0 (uid 2) which is assigned to node-2
- node-2 sees that pod-0 has been scheduled to it and starts pod-0
- The kubelet on node-1 crashes and restarts, then performs an initial list of pods scheduled to it against an API server in an HA setup (more than one API server) that is partitioned from the master (watch cache is arbitrarily delayed). The watch cache returns a list of pods from before T2
- node-1 fills its local cache with a list of pods from before T2
- node-1 starts pod-0 (uid 1) and node-2 is already running pod-0 (uid 2).
詳情可以參考 issue 59848[4]。
思考
我們經(jīng)常看到各種源碼分析,原理解析的文章,容易輕信其內(nèi)容,但隨著版本迭代,以及一些細節(jié)的處理,可能會導(dǎo)致我們理解不到位,或者并不能真正的掌握。例如是否在 list 請求時傳 RV=0 就一定會走 kube-apiserver 的緩存?網(wǎng)上搜的話,應(yīng)該都是說會走,但看代碼你會發(fā)現(xiàn)并不是這樣,例如當 kube-apiserver 重啟后數(shù)據(jù)還沒有完全加載好的時候,遇到 list 帶了 RV=0 的請求會直接去訪問 Etcd 獲取數(shù)據(jù)??此撇黄鹧鄣募毠?jié),可能會影響我們處理問題的思路,比如 Etcd 負載較高要查原因,如果你知道這個細節(jié)的話,就會有意識的去看所有的 list 請求,而不只是那些 RV != 0 的請求。
最后留一個思考,kube-apiserver 的內(nèi)存壓力主要來自 list 請求,那么我們是否可以不使用 list 請求而是使用一種流式處理來實現(xiàn) list 的功能呢?這樣是不是就可以把內(nèi)存消耗限制在一個常數(shù)的空間復(fù)雜度范圍內(nèi)了?下一篇將會專門分析使用流式 api 解決 list 導(dǎo)致的內(nèi)存暴漲的問題,敬請期待~
參考資料
[1]Etcd: https://pkg.go.dev/github.com/coreos/etcd/clientv3#WithProgressNotify
[2]bookmark: https://github.com/kubernetes/enhancements/blob/master/keps/sig-api-machinery/956-watch-bookmark/README.md
[3]watch-resumption: https://github.com/kubernetes/enhancements/blob/c63ac8e05de716370d1e03e298d226dd12ffc3c2/keps/sig-api-machinery/1904-efficient-watch-resumption/README.md
[4]stale read: https://github.com/kubernetes/kubernetes/issues/59848