淺析 Kubernetes 控制器的工作原理

米開朗基楊發表於2019-03-31

原文地址:淺析 Kubernetes 控制器的工作原理

Kubernetes 中執行了一系列控制器來確保叢集的當前狀態與期望狀態保持一致,它們就是 Kubernetes 的大腦。例如,ReplicaSet 控制器負責維護叢集中執行的 Pod 數量;Node 控制器負責監控節點的狀態,並在節點出現故障時及時做出響應。總而言之,在 Kubernetes 中,每個控制器只負責某種型別的特定資源。對於叢集管理員來說,瞭解每個控制器的角色分工至關重要,如有必要,你還需要深入瞭解控制器的工作原理。

本文我將會帶你深入瞭解 Kubernetes 控制器的內部結構、基本元件以及它的工作原理。本文使用的所有程式碼都是從 Kubernetes 控制器的當前實現程式碼中提取的,基於 Go 語言的 client-go 庫。

1. 控制器的模型

Kubernetes 官方文件給出了控制器最完美的解釋:

In applications of robotics and automation, a control loop is a non-terminating loop that regulates the state of the system. In Kubernetes, a controller is a control loop that watches the shared state of the cluster through the API server and makes changes attempting to move the current state towards the desired state. Examples of controllers that ship with Kubernetes today are the replication controller, endpoints controller, namespace controller, and serviceaccounts controller.

翻譯:

在機器人設計和自動化的應用中,控制迴圈是一個用來調節系統狀態的非終止迴圈。而在 Kubernetes 中,控制器就是前面提到的控制迴圈,它通過 API Server 監控整個叢集的狀態,並確保叢集處於預期的工作狀態。Kubernetes 自帶的控制器有 ReplicaSet 控制器,Endpoint 控制器,Namespace 控制器和 Service Account 控制器等。

官方文件:Kube-controller-manager

Kubernetes 控制器會監視資源的建立/更新/刪除事件,並觸發 Reconcile 函式作為響應。整個調整過程被稱作 “Reconcile Loop”(調諧迴圈)或者 “Sync Loop”(同步迴圈)。Reconcile 是一個使用 object(Resource 的例項)的名稱空間和 object 名來呼叫的函式,使 object 的實際狀態與 object 的 Spec 中定義的狀態保持一致。呼叫完成後,Reconcile 會將 object 的狀態更新為當前實際狀態。

什麼時候才會觸發 Reconcile 函式呢?以 ReplicaSet 控制器為例,當收到了一個關於 ReplicaSet 的事件或者關於 ReplicaSet 建立 Pod 的事件時,就會觸發 Reconcile 函式。

為了降低複雜性,Kubernetes 將所有的控制器都打包到 kube-controller-manager 這個守護程式中。下面是控制器最簡單的實現方式:

for {
  desired := getDesiredState()
  current := getCurrentState()
  makeChanges(desired, current)
}
複製程式碼

2. 水平觸發的 API

Kubernetes 的 API 和控制器都是基於水平觸發的,可以促進系統的自我修復和週期協調。水平觸發這個概念來自硬體的中斷,中斷可以是水平觸發,也可以是邊緣觸發。

  • 水平觸發 : 系統僅依賴於當前狀態。即使系統錯過了某個事件(可能因為故障掛掉了),當它恢復時,依然可以通過檢視訊號的當前狀態來做出正確的響應。
  • 邊緣觸發 : 系統不僅依賴於當前狀態,還依賴於過去的狀態。如果系統錯過了某個事件(“邊緣”),則必須重新檢視該事件才能恢復系統。

Kubernetes 水平觸發的 API 實現方式是:監視系統的實際狀態,並與物件的 Spec 中定義的期望狀態進行對比,然後再呼叫 Reconcile 函式來調整實際狀態,使之與期望狀態相匹配。

水平觸發的 API 也叫宣告式 API。

水平觸發的 API 有以下幾個特點:

  • Reconcile 會跳過中間過程在 Spec 中宣告的值,直接作用於當前 Spec 中宣告的值。
  • 在觸發 Reconcile 之前,控制器會併發處理多個事件,而不是序列處理每個事件。

舉兩個例子:

例 1:併發處理多個事件

使用者建立了 1000 個副本數的 ReplicaSet,然後 ReplicaSet 控制器會建立 1000 個 Pod,並維護 ReplicaSet 的 Status 欄位。在水平觸發系統中,控制器會在觸發 Reconcile 之前併發更新所有 Pod(Reconcile 函式僅接收物件的 Namespace 和 Name 作為引數),只需要更新 Status 欄位 1 次。而在邊緣觸發系統中,控制器會序列響應每個 Pod 事件,這樣就會更新 Status 欄位 1000 次。

例 2:跳過中間狀態

使用者修改了某個 Deployment 的映象,然後進行回滾。在回滾過程中發現容器陷入 crash 迴圈,需要增加記憶體限制。然後使用者更新了 Deployment 的內容,調整記憶體限制,重新開始回滾。在水平觸發系統中,控制器會立即停止上一次回滾動作,開始根據最新值進行回滾。而在邊緣觸發系統中,控制器必須等上一次回滾操作完成才能進行下一次回滾。

3. 控制器的內部結構

每個控制器內部都有兩個核心元件:Informer/SharedInformerWorkqueue。其中 Informer/SharedInformer 負責 watch Kubernetes 資源物件的狀態變化,然後將相關事件(evenets)傳送到 Workqueue 中,最後再由控制器的 workerWorkqueue 中取出事件交給控制器處理程式進行處理。

事件 = 動作(create, update 或 delete) + 資源的 key(以 namespace/name 的形式表示)

Informer

控制器的主要作用是 watch 資源物件的當前狀態和期望狀態,然後傳送指令來調整當前狀態,使之更接近期望狀態。為了獲得資源物件當前狀態的詳細資訊,控制器需要向 API Server 傳送請求。

但頻繁地呼叫 API Server 非常消耗叢集資源,因此為了能夠多次 getlist 物件,Kubernetes 開發人員最終決定使用 client-go 庫提供的快取機制。控制器並不需要頻繁呼叫 API Server,只有當資源物件被建立,修改或刪除時,才需要獲取相關事件。client-go 庫提供了 Listwatcher 介面用來獲得某種資源的全部 Object,快取在記憶體中;然後,呼叫 Watch API 去 watch 這種資源,去維護這份快取;最後就不再呼叫 Kubernetes 的任何 API:

lw := cache.NewListWatchFromClient(
      client,
      &v1.Pod{},
      api.NamespaceAll,
      fieldSelector)
複製程式碼

上面的這些所有工作都是在 Informer 中完成的,Informer 的資料結構如下所示:

store, controller := cache.NewInformer {
	&cache.ListWatch{},
	&v1.Pod{},
	resyncPeriod,
	cache.ResourceEventHandlerFuncs{},
複製程式碼

儘管 Informer 還沒有在 Kubernetes 的程式碼中被廣泛使用(目前主要使用 SharedInformer,下文我會詳述),但如果你想編寫一個自定義的控制器,它仍然是一個必不可少的概念。

你可以把 Informer 理解為 API Server 與控制器之間的事件代理,把 Workqueue 理解為儲存事件的資料結構。

下面是用於構造 Informer 的三種模式:

ListWatcher

ListWatcher 是對某個特定名稱空間中某個特定資源的 listwatch 函式的集合。這樣做有助於控制器只專注於某種特定資源。fieldSelector 是一種過濾器,它用來縮小資源搜尋的範圍,讓控制器只檢索匹配特定欄位的資源。Listwatcher 的資料結構如下所示:

cache.ListWatch {
	listFunc := func(options metav1.ListOptions) (runtime.Object, error) {
		return client.Get().
			Namespace(namespace).
			Resource(resource).
			VersionedParams(&options, metav1.ParameterCodec).
			FieldsSelectorParam(fieldSelector).
			Do().
			Get()
	}
	watchFunc := func(options metav1.ListOptions) (watch.Interface, error) {
		options.Watch = true
		return client.Get().
			Namespace(namespace).
			Resource(resource).
			VersionedParams(&options, metav1.ParameterCodec).
			FieldsSelectorParam(fieldSelector).
			Watch()
	}
}
複製程式碼

Resource Event Handler

Resource Event Handler 用來處理相關資源發生變化的事件:

type ResourceEventHandlerFuncs struct {
	AddFunc    func(obj interface{})
	UpdateFunc func(oldObj, newObj interface{})
	DeleteFunc func(obj interface{})
}
複製程式碼
  • AddFunc : 當資源建立時被呼叫
  • UpdateFunc : 當已經存在的資源被修改時就會呼叫 UpdateFuncoldObj 表示資源的最近一次已知狀態。如果 Informer 向 API Server 重新同步,則不管資源有沒有發生更改,都會呼叫 UpdateFunc
  • DeleteFunc : 當已經存在的資源被刪除時就會呼叫 DeleteFunc。該函式會獲取資源的最近一次已知狀態,如果無法獲取,就會得到一個型別為 DeletedFinalStateUnknown 的物件。

ResyncPeriod

ResyncPeriod 用來設定控制器遍歷快取中的資源以及執行 UpdateFunc 的頻率。這樣做可以週期性地驗證資源的當前狀態是否與期望狀態匹配。

如果控制器錯過了 update 操作或者上一次操作失敗了,ResyncPeriod 將會起到很大的彌補作用。如果你想編寫自定義控制器,不要把週期設定太短,否則系統負載會非常高。

SharedInformer

通過上文我們已經瞭解到,Informer 會將資源快取在本地以供自己後續使用。但 Kubernetes 中執行了很多控制器,有很多資源需要管理,難免會出現以下這種重疊的情況:一個資源受到多個控制器管理。

為了應對這種場景,可以通過 SharedInformer 來建立一份供多個控制器共享的快取。這樣就不需要再重複快取資源,也減少了系統的記憶體開銷。使用了 SharedInformer 之後,不管有多少個控制器同時讀取事件,SharedInformer 只會呼叫一個 Watch API 來 watch 上游的 API Server,大大降低了 API Server 的負載。實際上 kube-controller-manager 就是這麼工作的。

SharedInformer 提供 hooks 來接收新增、更新或刪除某個資源的事件通知。還提供了相關函式用於訪問共享快取並確定何時啟用快取,這樣可以減少與 API Server 的連線次數,降低 API Server 的重複序列化成本和控制器的重複反序列化成本。

lw := cache.NewListWatchFromClient(…)
sharedInformer := cache.NewSharedInformer(lw, &api.Pod{}, resyncPeriod)
複製程式碼

Workqueue

由於 SharedInformer 提供的快取是共享的,所以它無法跟蹤每個控制器,這就需要控制器自己實現排隊和重試機制。因此,大多數 Resource Event Handler 所做的工作只是將事件放入消費者工作佇列中。

每當資源被修改時,Resource Event Handler 就會放入一個 key 到 Workqueue 中。key 的表示形式為 <resource_namespace>/<resource_name>,如果提供了 <resource_namespace>,key 的表示形式就是 <resource_name>。每個事件都以 key 作為標識,因此每個消費者(控制器)都可以使用 workers 從 Workqueue 中讀取 key。所有的讀取動作都是序列的,這就保證了不會出現兩個 worker 同時讀取同一個 key 的情況。

Workqueueclient-go 庫中的位置為 client-go/util/workqueue,支援的佇列型別包括延遲佇列,定時佇列和速率限制佇列。下面是速率限制佇列的一個示例:

queue :=
workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter())
複製程式碼

Workqueue 提供了很多函式來處理 key,每個 key 在 Workqueue 中的生命週期如下圖所示:

淺析 Kubernetes 控制器的工作原理

如果處理事件失敗,控制器就會呼叫 AddRateLimited() 函式將事件的 key 放回 Workqueue 以供後續重試(如果重試次數沒有達到上限)。如果處理成功,控制器就會呼叫 Forget() 函式將事件的 key 從 Workqueue 中移除。注意:該函式僅僅只是讓 Workqueue 停止跟蹤事件歷史,如果想從 Workqueue 中完全移除事件,需要呼叫 Done() 函式。

現在我們知道,Workqueue 可以處理來自快取的事件通知,但還有一個問題:控制器應該何時啟用 workers 來處理 Workqueue 中的事件呢?

控制器需要等到快取完全同步到最新狀態才能開始處理 Workqueue 中的事件,主要有兩個原因:

  1. 在快取完全同步之前,獲取的資源資訊是不準確的。
  2. 對單個資源的多次快速更新將由快取合併到最新版本中,因此控制器必須等到快取變為空閒狀態才能開始處理事件,不然只會把時間浪費在等待上。

這種做法的虛擬碼如下:

controller.informer = cache.NewSharedInformer(...)
controller.queue = workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter())

controller.informer.Run(stopCh)

if !cache.WaitForCacheSync(stopCh, controller.HasSynched)
{
	log.Errorf("Timed out waiting for caches to sync"))
}

// Now start processing
controller.runWorker()
複製程式碼

所有處理流程如下所示:

淺析 Kubernetes 控制器的工作原理

控制器處理事件的流程

4. 參考資料

相關文章