從 ListWatch 到 WatchList
分析
可以先設(shè)想一下如果自己去實(shí)現(xiàn)的話,該如何設(shè)計(jì)。Client 和 Server 端都要去適配這是必然的,因?yàn)?Informer 現(xiàn)在是 ListWatch 機(jī)制,服務(wù)端并不支持流式 List。因此可以有個(gè)初步的方向:
- Server 端支持流式 List 請(qǐng)求
- Informer 適配 Server 端 API 的變化
客戶端的適配相對(duì)簡(jiǎn)單,重點(diǎn)還是放在 Server 端如何實(shí)現(xiàn)。先回顧下之前 List 的邏輯,在前一篇 Stale Read 里面已經(jīng)介紹過(guò)了。
為方面描述,下文統(tǒng)一使用 RV 代指 Resourceversion,本節(jié)邏輯均基于 v1.26.9 版本,且忽略分頁(yè)查詢,因?yàn)榉猪?yè)是直接走 Etcd 的。
無(wú)論是 List 還是 Watch 請(qǐng)求,其 query 均支持傳入 RV,服務(wù)端會(huì)根據(jù)請(qǐng)求的 RV 的不同做相應(yīng)的處理,根據(jù) RV 的值可以分為三種情況
- 未設(shè)置或者顯示設(shè)置 RV=""
- RV = "0"
- RV = "非 0 值"
對(duì)于前兩種情況,List 會(huì)直接返回 WatchCache Store 中的內(nèi)容,即服務(wù)端緩存好的 Etcd 的全部相關(guān)數(shù)據(jù)。
對(duì)于第三種情況,會(huì)等待服務(wù)端緩存數(shù)據(jù)的最大版本要超過(guò)傳入的 RV 之后再返回緩存內(nèi)的數(shù)據(jù),如果等待了一段時(shí)間(3s)后緩存中的數(shù)據(jù)仍然沒(méi)有達(dá)到指定版本,則會(huì)報(bào)錯(cuò)返回 "Too large resource version",并告訴客戶端可以在 1s 之后重試。
新版中已經(jīng)修復(fù)了 List Stale Read 的問(wèn)題,對(duì)于前兩種情況,其會(huì)先從 kube-apiserver 獲取 Etcd 最新的 RV,等待 WatchCache Store 內(nèi)容追平 RV 后再一次性的返回。
也就是說(shuō)服務(wù)端是可以知道自己是否已經(jīng)包含最新全量數(shù)據(jù)的,在這個(gè)基礎(chǔ)上再以流式方式返回即可。當(dāng)前已有的流式 API 就是 Watch,所以可以在此基礎(chǔ)上支持 List 的效果。為什么不直接在 List 請(qǐng)求基礎(chǔ)上改呢,因?yàn)楦?List 的話,會(huì)涉及到太多的客戶端側(cè)的適配,List 會(huì)經(jīng)常單獨(dú)使用,而 Watch 基本是在 Informer 里面使用。
所以最終的工作就會(huì)變成如何使用 Watch API 實(shí)現(xiàn) List 的效果,但數(shù)據(jù)仍然以流式返回給客戶端,同時(shí) Informer 修改 ListWatch 方式為只使用 Watch API 實(shí)現(xiàn)之前的效果。下文以詳細(xì)介紹服務(wù)端實(shí)現(xiàn)為主,客戶端適配的部分會(huì)比較簡(jiǎn)單的介紹下。
原理
通過(guò)為 Watch API 添加一個(gè) SendInitialEvents=true 參數(shù)來(lái)支持 List 的效果。Server 端接收到 Watch 請(qǐng)求后判斷哪些數(shù)據(jù)是應(yīng)該作為 InitEvents 發(fā)送給客戶端,同時(shí)在發(fā)送完這些數(shù)據(jù)之后發(fā)送一個(gè)特定的 BOOKMARK Event(帶特定 Annotation 的 BOOKMARK,其 RV 對(duì)應(yīng)下文的 bookmarkAfterRV)給客戶端作為服務(wù)端通知客戶端 InitEvents 發(fā)送完畢的標(biāo)志,客戶端在接收到指定 BOOKMARK Event 后,將之前接收到的所有 InitEvent 作為 List 的結(jié)果處理。
時(shí)序圖
下面是基于 v1.29 代碼的分析,此時(shí) v1.29 還在 alpha 狀態(tài),提到的舊版代表 1.27 之前的版本,新版代表 v1.29。如果你看到的代碼和下面描述的不一致,有可能是代碼版本導(dǎo)致的。
圖片
從 WatchCache 開(kāi)始右面四個(gè)藍(lán)色的是在 kube-apiserver 啟動(dòng)的時(shí)候開(kāi)始執(zhí)行的,G1 G2 代表兩個(gè) goroutine,分別用來(lái)從 Etcd 獲取數(shù)據(jù),以及發(fā)送數(shù)據(jù)給客戶端 CacheWatcher 的 input chan
- G1.1 每種資源類型對(duì)應(yīng)一個(gè) Cacher,內(nèi)部包含一個(gè) Reflector,WatchCache 作為 Reflector 的 Store 存儲(chǔ)從 Etcd 獲取到的數(shù)據(jù);
- G1.2 Reflector 開(kāi)啟調(diào)用 Etcd List 和 Watch API 獲取數(shù)據(jù);
- G1.3 Reflector 利用獲取到的數(shù)據(jù)更新 WatchCache 的 store 和 cyclic buffer,兩者分別用來(lái)存儲(chǔ)全量的對(duì)象和對(duì)象的最近更新事件;
- G1.4 在更新完 WatchCache 后,會(huì)把 Event 發(fā)送到 Cacher 的 incoming chan 中;
- G2.1 從 Cacher 的 imcomming chan 中消費(fèi)數(shù)據(jù)發(fā)送給所有的 CacheWatcher 的 input chan,或者定時(shí)(1 ~ 1.25s)發(fā)送 RV > bookmarkAfterRV 的 BOOKMARK 事件給所有的 CacheWatcher 的 input chan;
上述過(guò)程描述了服務(wù)端啟動(dòng)時(shí)的數(shù)據(jù)處理流程,接下來(lái)看有客戶端請(qǐng)求時(shí)的處理流程
- Reflector 首次發(fā)起 Watch 請(qǐng)求,query 中指定 RV=""&sendInitialEvent=true&resourceVersinotallow=NotOlderThan&AllowWatchBookmarks=true,這里無(wú)論 RV="" 還是 RV="0" 都可以實(shí)現(xiàn) List 的效果,只不過(guò)相比舊版本的實(shí)現(xiàn),新版里面 Watch 請(qǐng)求針對(duì) RV="" 做了特殊處理,解決了 Watch API Stale Read 的問(wèn)題(List Stale Read 已經(jīng)在前一篇中介紹過(guò)了,針對(duì) List 提供了 FeatureGate 來(lái)控制是否開(kāi)啟 Consistent Read,但 Watch 這里并沒(méi)有對(duì)應(yīng)的 FeatureGate,也即是說(shuō)新版中針對(duì) RV="" 的請(qǐng)求一定是 Consistent Read),服務(wù)端接收到請(qǐng)求后為這個(gè)請(qǐng)求創(chuàng)建對(duì)應(yīng)的 CacheWachter 對(duì)象;
- Server 端在接收到請(qǐng)求后計(jì)算 bookmarkAfterRV 的值,如果 RV="0",則 bookmarkAfterRV 就是 WatchCache RV(WatchCache Store 數(shù)據(jù)中的最大 RV),如果 RV="",則去 Etcd 中獲取最大的 RV 作為 bookmarkAfterRV,將 bookmarkAfterRV 傳遞給 CacheWatcher,最后 CacheWatcher 會(huì)結(jié)合 WatchCache Store 和自身 input chan 中的數(shù)據(jù)準(zhǔn)備 InitEvents
2a 開(kāi)始從 WatchCache Store 中獲取需要返回的數(shù)據(jù),此時(shí)的處理邏輯舊版本相同,返回 Store 中的全部數(shù)據(jù),并記錄 Store 數(shù)據(jù)的最大 RV 供下一步使用;
2b 消費(fèi) input chan 中的事件,對(duì)比其 RV 是否比 2a 傳入的 RV 大,或者如果是 BOOKMARK 類型并且 RV 等與 2a 傳入的 RV,且尚未發(fā)送 bookmarkAfterRV 的事件,則此 BOOKMARK 事件就會(huì)被當(dāng)做 List 結(jié)束的標(biāo)志,為其設(shè)置 Annotation: k8s.io/initial-events-end,最后發(fā)送給客戶端;
至此,服務(wù)端的主要流程已經(jīng)介紹完,客戶端 Informer 也做了對(duì)應(yīng)的適配,如果開(kāi)啟 WathList 功能的話,會(huì)發(fā)送 Watch 請(qǐng)求來(lái)獲取一遍全量數(shù)據(jù),等到接收到攜帶 Annotation: k8s.io/initial-events-end 的 BOOKMARK 事件后,記錄其 RV,將在此期間接受并處理后的對(duì)象作為 List 的結(jié)果。最后再次以上述 RV 作為參數(shù)調(diào)用 Watch 請(qǐng)求,從這一步開(kāi)始就是 Informer 傳統(tǒng)意義上的 Watch 邏輯了。
數(shù)據(jù)流
圖片
圖片來(lái)自 KEP 3157 watch-list,其實(shí)里面也包含時(shí)序圖,不過(guò)里面的書(shū)序圖畫(huà)的有一些問(wèn)題,和代碼不一致,所以這里并沒(méi)有直接使用他的時(shí)序圖,而是重新畫(huà)了。
可以結(jié)合上面兩個(gè)圖理解整個(gè)過(guò)程,上圖中的 a 對(duì)應(yīng)時(shí)序圖中的 2a,b 對(duì)應(yīng)時(shí)序圖中的 2b,c 對(duì)應(yīng)時(shí)序圖中的 G2.1。最下面白色部分對(duì)應(yīng)時(shí)序圖中 G1 的邏輯,即從 Etcd 獲取數(shù)據(jù),客戶端請(qǐng)求的處理是自上到下的,而數(shù)據(jù)返回是自下而上的。
注意
上述處理邏輯中存在很多的細(xì)節(jié),需要額外注意下
- 為 Watch API 修復(fù)了 Stale Read 的問(wèn)題(RV="" WatchList 功能),本質(zhì)上也是消除 List 的 Stale Read,只不過(guò)是在 Watch API 中實(shí)現(xiàn)的,這樣結(jié)合上一篇,不管是直接使用 List API 還是使用 WatchList 都能避免 Stale Read 的問(wèn)題;
- WatchCache Store 中的數(shù)據(jù)和 Cacher imcomming chan 數(shù)據(jù)是有交叉的,所以在 2a 處理完所有 Store 數(shù)據(jù)后記錄了最大的 RV 傳遞給 2b 在處理 imcomming chan 的數(shù)據(jù)時(shí)使用,event RV > RV 的非 BOOKMARK 事件才會(huì)發(fā)回客戶端,這樣是為了避免時(shí)間回流;
- CacheWatcher 的 input chan 中是不存在 RV < bookmarkAfterRV 的事件的,在 G2.1 從 Cacher incoming chan 消費(fèi)并發(fā)往所有 CacheWatcher input 的時(shí)候判斷了如果事件類型是 BOOKMARK 且 RV < bookmarkAfterRV,則直接丟棄此事件,因?yàn)?input chan 緩沖區(qū)大小有限,在其創(chuàng)建后 Cacher 就開(kāi)始往其 input 寫數(shù)據(jù),而開(kāi)始消費(fèi) input chan 是在 2a 處理完所有 Store 中的數(shù)據(jù)之后,中間存在一段時(shí)間差,事件的長(zhǎng)短和 Store 中的數(shù)據(jù)量有關(guān)系,丟棄不必要的 BOOKMARK 事件就可以緩解 input chan 的壓力,這里涉及到了為 input chan 添加事件的處理邏輯,里面包括多種特殊情況的處理,例如緩沖滿了如何處理避免因?yàn)閱蝹€(gè) CacheWatcher 而阻塞整個(gè)流程,發(fā)數(shù)據(jù)異常如何處理;
- 最終發(fā)回給客戶端的攜帶特定 Annotation 的 BOOKMARK 事件的 RV >= bookmarkAfterRV,這里非常值得注意,并不是等于 bookmarkAfterRV,原 KEP 時(shí)序圖中此處(2c)的描述是錯(cuò)誤的。根本原因在于 bookmark timer 的周期為 1 ~ 1.25s,也就是說(shuō)每 1 ~ 1.25s 產(chǎn)生一個(gè) BOOKMARK 事件,其 RV 是 incoming chan 最大 RV,正是由于這個(gè)時(shí)間間隔,結(jié)合 3 的描述,就會(huì)導(dǎo)致 G2.1 發(fā)送出去的第一個(gè)有效的 (進(jìn)入到 CacheWatcher input chan) BOOKMARK 事件的 RV >= bookmarkAfterRV。這也從側(cè)面說(shuō)明了最終在返回 bookmarkAfterRV BOOKMARK 事件之前返回的所有的攜帶有效負(fù)載的事件集合的最大 RV 也是 >= bookmarkAfterRV 的,即雖然標(biāo)記是 bookmarkAfterRV,但 List 的結(jié)果中包含比 bookmarkAfterRV 大的數(shù)據(jù)。 個(gè)人認(rèn)為此處還是可以再繼續(xù)優(yōu)化的,可以讓 List 的耗時(shí)減少一個(gè) bookmark timer 的周期,即 1 ~ 1.25s,只需要在 2b 處理非 BOOKMARK 事件時(shí)判斷 RV == bookmarkAfterRV 且尚未發(fā)送過(guò) bookmarkAfterRV BOOKMARK 事件,此時(shí)就可以直接返回一個(gè) bookmarkAfterRV BOOKMARK 給客戶端了,對(duì)于數(shù)據(jù)量較大,返回所有數(shù)據(jù)耗時(shí)超過(guò) Watch timeout 時(shí)間 1s 左右時(shí)可以降低超時(shí)的概率,避免重復(fù)執(zhí)行 WatchList 的過(guò)程,也能在一定程度上降低內(nèi)存消耗。
總結(jié)
本篇主要分析了 WatchList 的實(shí)現(xiàn)原理和邏輯,其中不乏一些細(xì)節(jié)處理,后續(xù)也會(huì)和社區(qū)就有關(guān)細(xì)節(jié)進(jìn)一步討論。在此 KEP 中同時(shí)還介紹了另外兩個(gè)用來(lái)降低 kube-apiserver 內(nèi)存壓力的修改,篇幅有限,將會(huì)在下一篇中進(jìn)行介紹,同時(shí)也會(huì)給出所有優(yōu)化工作做完前后的效果對(duì)比。敬請(qǐng)期待~