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