九張圖帶你理解 Kubernetes Controller 工作機制
一、Introduction
起因:工作上要重構一個現有的組件管理工具,要求實現全生命周期管理,還有上下游解耦,我心里一想這不就是 k8s controller 嘛!所以決定在動手前先學習一下 k8s 的先進理念。
此文就是通過對代碼的簡單分析,以及一些經驗總結,來描述 k8s controller 管理資源的主要流程。
二、Concepts
resource: 資源,k8s 中定義的每一個實例都是一個資源,比如一個 rs、一個 deployment。資源有不同的 kind,比如 rs、deployment。資源間存在上下游關系。
注意:下文中提到的所有“資源”,都是指 k8s 中抽象的資源聲明,而不是指 CPU、存儲等真實的物理資源。
高度抽象 k8s 的話其實就三大件:
- apiserver: 負責存儲資源,并且提供查詢、修改資源的接口
- controller: 負責管理本級和下級資源。比如 deploymentController 就負責管理 deployment 資源 和下一級的 rs 資源。
- kubelet: 安裝在 node 節點上,負責部署資源
controller 和 kubelet 都只和 apiserver 通訊。controller 不斷監聽本級資源,然后修改下級資源的聲明。kubelet 查詢當前 node 所應部署的資源,然后執行清理和部署。
1、術語
- ?
?metadata?
?: 每一個資源都有的元數據,包括 label、owner、uid 等 - ?
?UID?
?: 每一個被創建(提交給 apiserver)的資源都有一個全局唯一的 UUID。 - ?
?label?
?: 每個資源都要定義的標簽 - ?
?selector?
?: 父資源通過 labelSelector 查詢歸其管理的子資源。不允許指定空 selector(全匹配)。 - owner: 子資源維護一個 owner UID 的列表?
?OwnerReferences?
?, 指向其父級資源。列表中第一個有效的指針會被視為生效的父資源。selector 實際上只是一個 adoption 的機制, 真實起作用的父子級關系是靠 owner 來維持的, 而且 owner 優先級高于 selector。 - replicas: 副本數,pod 數
- 父/子資源的相關:
- orphan: 沒有 owner 的資源(需要被 adopt 或 GC)
- adopt: 將 orphan 納入某個資源的管理(成為其 owner)
- match: 父子資源的 label/selector 匹配
- release: 子資源的 label 不再匹配父資源的 selector,將其釋放
- RS 相關:
- saturated: 飽和,意指某個資源的 replicas 已符合要求
- surge: rs 的 replicas 不能超過 spec.replicas + surge
- proportion: 每輪 rolling 時,rs 的變化量(小于 maxSurge)
- fraction: scale 時 rs 期望的變化量(可能大于 maxSurge)
三、Controller
- sample-controller@a40ea2c/controller.go
- kubernetes@59c0523b/pkg/controller/deployment/deployment_controller.go
- kubernetes@59c0523b/pkg/controller/controller_ref_manager.go
控制器,負責管理自己所對應的資源(resource),并創建下一級資源,拿 deployment 來說:
- 用戶創建 deployment 資源
- deploymentController 監聽到 deployment 資源,然后創建 rs 資源
- rsController 監聽到 rs 資源,然后創建 pod 資源
- 調度器(scheduler)監聽到 pod 資源,將其與 node 資源建立關聯
(node 資源是 kubelet 安裝后上報注冊的)
理想中,每一層管理器只管理本級和子兩層資源。但因為每一個資源都是被上層創建的, 所以實際上每一層資源都對下層資源的定義有完全的了解,即有一個由下至上的強耦合關系。
比如 ??A -> B -> C -> D?
? 這樣的生成鏈,A 當然是知道 D 資源的全部定義的, 所以從理論上說,A 是可以去獲取 D 的。但是需要注意的是,如果出現了跨級的操作,A 也只能只讀的獲取 D,而不要對 D 有任何改動, 因為跨級修改數據的話會干擾下游的控制器。
k8s 中所有的控制器都在同一個進程(controller-manager)中啟動, 然后以 goroutine 的形式啟動各個不同的 controller。所有的 contorller 共享同一個 informer,不過可以注冊不同的 filter 和 handler,監聽自己負責的資源的事件。
(informer 是對 apiserver 的封裝,是 controller 查詢、訂閱資源消息的組件,后文有介紹)注:如果是用戶自定義 controller(CRD)的話,需要以單獨進程的形式啟動,需要自己另行實例化一套 informer, 不過相關代碼在 client-go 這一項目中都做了封裝,編寫起來并不會很復雜。
控制器的核心代碼可以概括為:
for {
for {
// 從 informer 中取出訂閱的資源消息
key, empty := queue.Get()
if empty {
break
}
defer queue.Done(key)
// 處理這一消息:更新子資源的聲明,使其匹配父資源的要求。
// 所有的 controller 中,這一函數都被命名為 `syncHandler`。
syncHandler(key)
}
// 消息隊列被消費殆盡,等待下一輪運行
time.sleep(time.Second)
}
- 通過 informer(indexer)監聽資源事件,事件的格式是字符串?
?<namespace>/<name>?
? - 控制器通過 namespace 和 name 去查詢自己負責的資源和下級資源
- 比對當前資源聲明的狀態和下級資源可用的狀態是否匹配,并通過增刪改讓下級資源匹配上級聲明。比如 deployments 控制器就查詢 deployment 資源和 rs 資源,并檢驗其中的 replicas 副本數是否匹配。
controller 內包含幾個核心屬性/方法:
- informer: sharedIndexer,用于獲取資源的消息,支持注冊 Add/Update/Delete 事件觸發,或者調用?
?lister?
? 遍歷。 - clientset: apiserver 的客戶端,用來對資源進行增刪改。
- syncHandler: 執行核心邏輯代碼(更新子資源的聲明,使其匹配父資源的要求)。
1、syncHandler
syncHandler 像是一個約定,所有的 controller 內執行核心邏輯的函數都叫這個名字。該函數負責處理收到的資源消息,比如更新子資源的聲明,使其匹配父資源的要求。
以 deploymentController 為例,當收到一個事件消息,syncHandler 被調用后:
注:
- ?
?de?
?: 觸發事件的某個 deployment 資源 - ?
?dc?
?: deploymentController 控制器自己 - ?
?rs?
?: replicaset,deployment 對應的 replicaset 子資源
注:事件是一個字符串,形如 ??namespace/name?
?,代表觸發事件的資源的名稱以及所在的 namespace。因為事件只是個名字,所以 syncHandler 需要自己去把當前觸發的資源及其子資源查詢出來。這里面涉及很多查詢和遍歷,不過這些查詢都不會真實的調用 apiserver,而是在 informer 的內存存儲里完成的。
graph TD
A1[將 key 解析為 namespace 和 name] --> A2[查詢 de]
A2 --> A3[查詢關聯子資源 rs]
A3 --> A31{de 是否 paused}
A31 --> |yes| A32[調用 dc.sync 部署 rs]
A31 --> |no| A4{是否設置了 rollback}
A4 --> |yes| A41[按照 annotation 設置執行 rollback]
A4 --> |no| A5[rs 是否匹配 de 聲明]
A5 --> |no| A32
A5 --> |yes| A6{de.spec.strategy.type}
A6 --> |recreate| A61[dc.rolloutRecreate]
A6 --> |rolling| A62[dc.rolloutRolling]
查詢關聯子資源
- kubernetes@59c0523b/pkg/controller/deployment/deployment_controller.go:getReplicaSetsForDeployment
k8s 中,資源間可以有上下級(父子)關系。
理論上 每一個 controller 都負責創建當前資源和子資源,父資源通過 labelSelector 查詢應該匹配的子資源。
一個 deployment 的定義:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
上文中講到 syncHandler 的時候,提到需要“查詢關聯子資源”。其實這一步驟很復雜,不僅僅是查詢,還包含對現有子資源進行同步(修改)的操作。簡而言之,這一步驟實際上做的是通過對 owner、label 的比對,確認并更新當前真實的父子資源關系。
對用戶呈現的資源關聯是依靠 label/selector。但實際上 k8s 內部使用的是 owner 指針。(owner 指針是資源 metadata 內用來標記其父資源的 OwnerReferences)。
查詢匹配子資源的方法是:
- 遍歷 namespace 內所有對應類型的子資源 (比如 deployment controller 就會遍歷所有的 rs)
- 匹配校驗 owner 和 label
(父是當前操作的資源,子是查詢出的子資源)
還是用 deployment 舉例,比如此時收到了一個 deployment 事件,需要查詢出該 de 匹配的所有 rs:
graph LR
A(遍歷 namespace 內所有 rs) --> A1{子.owner == nil}
A1 --> |false| A2{子.owner == 父.uid}
A2 --> |false| A21[skip]
A2 --> |true| A3{labels matched}
A3 --> |true| A5
A3 --> |false| A31[release]
A1 --> |true| A4{labels matched}
A4 --> |false| A21
A4 --> |true| A41[adopt]
A41 --> A5[標記為父子]
如上圖所示,其實只有兩個 case 下,rs 會被視為是 de 的子資源:
- rs owner 指向 de,且 labels 匹配
- rs owner 為空,且 labels 匹配
注意:如果 rs owner 指向了其他父資源,即使 label 匹配,也不會被視為當前 de 的子資源。
dc.sync
- kubernetes@59c0523b/pkg/controller/deployment/sync.go:sync
這是 deployment controller 中執行“檢查和子資源,令其匹配父資源聲明”的一步。準確的說:
- dc.sync: 檢查子資源是否符合父資源聲明
- dc.scale: 操作更新子資源,使其符合父資源聲明
graph TD
A1[查詢 de 下所有舊的 rs] --> A2{當前 rs 是否符合 de}
A2 --> |no| A21[newRS = nil]
A2 --> |yes| A22[NewRS = 當前 rs]
A22 --> A23[將 de 的 metadata 拷貝給 newRS]
A23 --> A231[newRS.revision=maxOldRevision+1]
A231 --> A3[調用 dc.scale]
A21 --> A33
A3 --> A31{是否有 active/latest rs}
A31 --> |yes| A311[dc.scaleReplicaSet 擴縮容]
A31 --> |no| A32{newRS 是否已飽和}
A32 --> |yes|A321[把所有 oldRS 清零]
A32 --> |no|A33{de 是否允許 rolling}
A33 --> |no|A331[return]
A33 --> |yes|A34[執行滾動更新]
滾動更新的流程為:
(??if deploymentutil.IsRollingUpdate(deployment) {...}?
? 內的大量代碼,實際做的事情就是按照 deployment 的要求更新 rs 的 replicas 數。不過每次變更都涉及到對 rs 和 deployment 的 maxSurge 的檢查,所以代碼較為復雜。)
- 計算所有 RS replicas 總和?
?allRSsReplicas?
?。 - 計算滾動升級過程中最多允許出現的副本數?
?allowedSize?
??。??allowedSize = de.Spec.Replicas + maxSurge?
? - ?
?deploymentReplicasToAdd = allowedSize - allRSsReplicas?
? - 遍歷所有當前 rs,計算每一個 rs 的 replicas 變化量(proportion), 計算的過程中需要做多次檢查,不能溢出 rs 和 deployment 的 maxSurge。
- 更新所有 rs 的 replicas,然后調用?
?dc.scaleReplicaSet?
? 提交更改。
四、Object
- apimachinery@v0.0.0-20210708014216-0dafcb48b31e/pkg/apis/meta/v1/meta.go
- apimachinery@v0.0.0-20210708014216-0dafcb48b31e/pkg/apis/meta/v1/types.go
ObjectMeta 定義了 k8s 中資源對象的標準方法。
雖然 resource 定義里是通過 labelSelector 建立從上到下的關聯, 但其實內部實現的引用鏈是從下到上的。每一個資源都會保存一個 Owner UID 的 slice。
每個資源的 metadata 中都有一個 ??ownerReferences?
? 列表,保存了其父資源(遍歷時遇到的第一個有效的資源會被認為是其父資源)。
type ObjectMeta struct {
OwnerReferences []OwnerReference `json:"ownerReferences,omitempty" patchStrategy:"merge" patchMergeKey:"uid" protobuf:"bytes,13,rep,name=ownerReferences"`
}
判斷 owner 靠的是比對資源的 UID
func IsControlledBy(obj Object, owner Object) bool {
ref := GetControllerOfNoCopy(obj)
if ref == nil {
return false
}
// 猜測:UID 是任何資源在 apiserver 注冊的時候,由 k8s 生成的 uuid
return ref.UID == owner.GetUID()
}
五、Informer
- A deep dive into Kubernetes controllers[1]
- client-go@v0.0.0-20210708094636-69e00b04ba4c/informers/factory.go
Informer 也經歷了兩代演進,從最早各管各的 Informer,到后來統一監聽,各自 filter 的 sharedInformer。
所有的 controller 都在一個 controller-manager 進程內,所以完全可以共享同一個 informer, 不同的 controller 注冊不同的 filter(kind、labelSelector),來訂閱自己需要的消息。
簡而言之,現在的 sharedIndexer,就是一個統一的消息訂閱器,而且內部還維護了一個資源存儲,對外提供可過濾的消息分發和資源查詢功能。
sharedIndexer 和 sharedInformer 的區別就是多了個 threadsafe 的 map 存儲,用來存 shared resource object。
現在的 informer 中由幾個主要的組件構成:
- reflecter:查詢器,負責從 apiserver 定期輪詢資源,更新 informer 的 store。
- store: informer 內部對資源的存儲,用來提供 lister 遍歷等查詢操作。
- queue:支持 controller 的事件訂閱。
各個 controller 的訂閱和查詢絕大部分都在 sharedIndexer 的內存內完成,提高資源利用率和效率。
一般 controller 的消息來源就通過兩種方式:
- lister: controller 注冊監聽特定類型的資源事件,事件格式是字符串,?
?<namespace>/<name>?
? - handler: controller 通過 informer 的?
?AddEventHandler?
?? 方法注冊??Add/Update/Delete?
? 事件的處理函數。
這里有個值得注意的地方是,資源事件的格式是字符串,形如 ??<namespace>/<name>?
?,這其中沒有包含版本信息。
那么某個版本的 controller 拿到這個信息后,并不知道查詢出來的資源是否匹配自己的版本,也許會查出一個很舊版本的資源。
所以 controller 對于資源必須是向后兼容的,新版本的 controller 必須要能夠處理舊版資源。這樣的話,只需要保證運行的是最新版的 controller 就行了。
1、Queue
controller 內有大量的隊列,最重要的就是注冊到 informer 的三個 add/update/delete 隊列。
RateLimitingQueue
- client-go@v0.0.0-20210708094636-69e00b04ba4c/util/workqueue/rate_limiting_queue.go
實際使用的是隊列類型是 RateLimitingQueue,繼承于 Queue。
Queue
- client-go@v0.0.0-20210708094636-69e00b04ba4c/util/workqueue/queue.go
type Interface interface {
// Add 增加任務,可能是增加新任務,可能是處理失敗了重新放入
//
// 調用 Add 時,t 直接插入 dirty。然后會判斷一下 processing,
// 是否存在于 processing ? 返回 : 放入 queue
Add(item interface{})
Len() int
Get() (item interface{}, shutdown bool)
Done(item interface{})
ShutDown()
ShuttingDown() bool
}
type Type struct {
// queue 所有未被處理的任務
queue []t
// dirty 所有待處理的任務
//
// 從定義上看和 queue 有點相似,可以理解為 queue 的緩沖區。
// 比如調用 Add 時,如果 t 存在于 processing,就只會插入 dirty,不會插入 queue,
// 這種情況表明外部程序處理失敗了,所以再次插入了 t。
dirty set
// processing 正在被處理的任務
//
// 一個正在被處理的 t 應該從 queue 移除,然后添加到 processing。
//
// 如果 t 處理失敗需要重新處理,那么這個 t 會被再次放入 dirty。
// 所以調用 Done 從 processing 移除 t 的時候需要同步檢查一下 dirty,
// 如果 t 存在于 dirty,則將其再次放入 queue。
processing set
cond *sync.Cond
shuttingDown bool
metrics queueMetrics
unfinishedWorkUpdatePeriod time.Duration
clock clock.Clock
}
隊列傳遞的資源事件是以字符串來表示的,格式形如 ??namespace/name?
?。
正因為資源是字符串來表示,這導致了很多問題。其中對于隊列的一個問題就是:沒法為事件設置狀態,標記其是否已完成。為了實現這個狀態,queue 中通過 queue、dirty、processing 三個集合來表示。具體實現可以參考上面的注釋和代碼。
另一個問題就是資源中沒有包含版本信息。
那么某個版本的 controller 拿到這個信息后,并不知道查詢出來的資源是否匹配自己的版本,也許會查出一個很舊版本的資源。
所以 controller 對于資源必須是向后兼容的,新版本的 controller 必須要能夠處理舊版資源。這樣的話,只需要保證運行的是最新版的 controller 就行了。
六、GC
- Garbage Collection[2]
- Using Finalizers to Control Deletion[3]
- kubernetes@59c0523b/pkg/controller/garbagecollector/garbagecollector.go
1、Concepts
我看到
GC 的第一印象是一個像語言 runtime 里的回收資源的自動垃圾收集器。但其實 k8s 里的 GC 的工作相對比較簡單,更像是只是一個被動的函數調用,當用戶試圖刪除一個資源的時候, 就會把這個資源提交給 GC,然后 GC 執行一系列既定的刪除流程,一般來說包括:
- 刪除子資源
- 執行刪除前清理工作(finalizer)
- 刪除資源
k8s 的資源間存在上下游依賴,當你刪除一個上游資源時,其下游資源也需要被刪除,這被稱為??級聯刪除 cascading deletion?
?。
刪除一個資源有三種策略(??propagationPolicy/DeletionPropagation?
?):
- ?
?Foreground?
?(default): Children are deleted before the parent (post-order) - ?
?Background?
?: Parent is deleted before the children (pre-order) - ?
?Orphan?
?: 忽略 owner references
可以在運行 ??kubectl delete --cascade=????
?? 的時候指定刪除的策略,默認為 ??foreground?
?。
2、Deletion
k8s 中,資源的 metadata 中有幾個對刪除比較重要的屬性:
- ?
?ownerRerences?
?: 指向父資源的 UID - ?
?deletionTimestamp?
?: 如果不為空,表明該資源正在被刪除中 - ?
?finalizers?
?: 一個字符串數組,列舉刪除前必須執行的操作 - ?
?blockOwnerDeletion?
?: 布爾,當前資源是否會阻塞父資源的刪除流程
每一個資源都有 ??metadata.finalizers?
??,這是一個 ??[]string?
?, 內含一些預定義的字符串,表明了在刪除資源前必須要做的操作。每執行完一個操作,就從 finalizers 中移除這個字符串。
無論是什么刪除策略,都需要先把所有的 finalizer 逐一執行完,每完成一個,就從 finalizers 中移除一個。在 finalizers 為空后,才能正式的刪除資源。
foreground、orphan 刪除就是通過 finalizer 來實現的。
const (
FinalizerOrphanDependents = "orphan"
FinalizerDeleteDependents = "foregroundDeletion"
)
注:有一種讓資源永不刪除的黑魔法,就是為資源注入一個不存在的 finalizer。因為 GC 無法找到該 finalizer 匹配的函數來執行,就導致這個 finalizer 始終無法被移除, 而 finalizers 為空清空的資源是不允許被刪除的。
3、Foreground cascading deletion
- 設置資源的?
?deletionTimestamp?
??,表明該資源的狀態為正在刪除中(??"deletion in progress"?
?)。 - 設置資源的?
?metadata.finalizers?
?? 為??"foregroundDeletion"?
?。 - 刪除所有?
?ownerReference.blockOwnerDeletion=true?
? 的子資源 - 刪除當前資源
每一個子資源的 owner 列表的元素里,都有一個屬性 ??ownerReference.blockOwnerDeletion?
??,這是一個 ??bool?
?, 表明當前資源是否會阻塞父資源的刪除流程。刪除父資源前,應該把所有標記為阻塞的子資源都刪光。
在當前資源被刪除以前,該資源都通過 apiserver 持續可見。
4、Orphan deletion
觸發 ??FinalizerOrphanDependents?
?,將所有子資源的 owner 清空,也就是令其成為 orphan。然后再刪除當前資源。
5、Background cascading deletion
立刻刪除當前資源,然后在后臺任務中刪除子資源。
graph LR
A1{是否有 finalizers} --> |Yes: pop, execute| A1
A1 --> |No| A2[刪除自己]
A2 --> A3{父資源是否在等待刪除}
A3 --> |No| A4[刪除所有子資源]
A3 --> |Yes| A31[在刪除隊列里提交父資源]
A31 --> A4
foreground 和 orphan 刪除策略是通過 finalizer 實現的 因為這兩個策略有一些刪除前必須要做的事情:
- foreground finalizer: 將所有的子資源放入刪除事件隊列
- orphan finalizer: 將所有的子資源的 owner 設為空
而 background 則就是走標準刪除流程:刪自己 -> 刪依賴。
這個流程里有一些很有趣(繞)的設計。比如 foreground 刪除,finalizer 里把所有的子資源都放入了刪除隊列, 然后下一步在刪除當前資源的時候,會發現子資源依然存在,導致當前資源無法刪除。實際上真正刪除當前資源(父資源),j是在刪除最后一個子資源的時候,每次都會去檢查下父資源的狀態是否是刪除中, 如果是,就把父資源放入刪除隊列,此時,父資源才會被真正刪除。
6、Owner References
每個資源的 metadata 中都有一個 ??ownerReferences?
? 列表,保存了其父資源(遍歷時遇到的第一個有效的資源會被認為是其父資源)。
owner 決定了資源會如何被刪除。刪除子資源不會影響到父資源。刪除父資源會導致子資源被聯動刪除。(默認 ??kubectl delete --cascade=foreground?
?)
七、參考資料
關于本主題的內容,我制作了一個 slides,可用于內部分享:https://s3.laisky.com/public/slides/k8s-controller.slides.html#/
1、如何閱讀源碼
核心代碼:https://github.com/kubernetes/kubernetes,所有的 controller 代碼都在 ??pkg/controller/?
? 中。
所有的 clientset、informer 都被抽象出來在 https://github.com/kubernetes/client-go 庫中,供各個組件復用。
學習用示例項目:https://github.com/kubernetes/sample-controller
2、參考文章
- Garbage Collection[4]
- Using Finalizers to Control Deletion[5]
- A deep dive into Kubernetes controllers[6]
- kube-controller-manager[7]
引用鏈接
[1]
A deep dive into Kubernetes controllers: https://app.yinxiang.com/shard/s17/nl/2006464/674c3d83-f011-49b8-9135-413588c22c0f/
[2]
Garbage Collection: https://kubernetes.io/docs/concepts/workloads/controllers/garbage-collection/
[3]
Using Finalizers to Control Deletion: https://kubernetes.io/blog/2021/05/14/using-finalizers-to-control-deletion/
[4]
Garbage Collection: https://kubernetes.io/docs/concepts/workloads/controllers/garbage-collection/
[5]
Using Finalizers to Control Deletion: https://kubernetes.io/blog/2021/05/14/using-finalizers-to-control-deletion/
[6]
A deep dive into Kubernetes controllers: https://engineering.bitnami.com/articles/a-deep-dive-into-kubernetes-controllers.html
[7]
kube-controller-manager: https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/