Kubernetes Controller執行框架解析


毫無疑問,聲明式API以及Controller機制是Kubernetes設計理念的基礎。Controller不斷從API Server同步資源對象的期望狀態並且在資源對象的期望狀態和實際運行狀態之間進行調諧,從而實現兩者的最終一致性。Kubernetes系統中的各種組件,包括Scheduler,Kubelet以及各種資源對象的Controller都以這種統一的模式運行着。

雖然Kubernetes已經內置了Deployment,StatefulSet,Job等豐富的編排對象,但是在落地過程中,面對紛繁復雜的應用場景,尤其是針對Etcd,Prometheus等配置復雜的有狀態應用,現有的編排對象依然顯得捉襟見肘。所幸的是,Kubernetes在v1.7引入了CRD,允許用戶自定義資源對象並且可以像原生對象一樣操作它們。因此對於復雜的應用,我們完全可以自定義編排對象以及相應的Controller進行配置管理。

同時,聲明式API的設計也讓其他系統與Kubernetes的集成變得更為容易。例如,當使用Prometheus對Kubernetes系統進行監控,並開啟抓取對象的自動發現時,Prometheus本質上也是一個Controller,它會不斷地從API Server同步集群中所有的Pod,Service和Node信息並從中篩選出抓取對象進行抓取。

可見,Controller在Kubernetes生態系統中無處不在。事實上,Controller的執行框架都是類似的,Kubernetes社區也貼心地將通用的代碼進行了抽象,封裝了在client-go這個包里。手寫Controller的難度也瞬間下降到了在代碼中引入client-go包並且在適當的位置實現業務邏輯這樣的"體力活"。話雖如此,但是對Controller執行框架的深入理解,對於更好地編寫Controller乃至更好地理解Kubernetes都將是不可或缺的,而這也是本文敘述的重點。

1. Controller實現概述

不論對於同步資源對象的真正意義上的Controller,還是類似於Prometheus僅僅全量獲取資源對象進行服務發現的廣義的Controller。從API Server持續同步資源對象並對其進行處理是所有Controller都相同的執行框架。乍一看,這個問題並不復雜,我們甚至可以寫一個腳本對API Server進行輪詢,每次全量獲取資源對象並逐個處理,似乎也能滿足要求。

當然,在現實條件下,上述朴素的方法顯然是不行的。從上文的分析可知,Controller是普遍存在的,而且資源對象之間往往存在關聯關系,例如Deployment和Pod,Service和Endpoint,因此一個Controller通常需要對多種資源對象進行同步。因此,如此大量的輪詢操作,對於API Server來說是無法接受的。

對於這個問題,Kubernetes社區的解法是List & Watch。首先利用List接口從API Server中獲取資源對象的全量數據並存儲在緩存中,之后再利用Watch接口對API Server進行監聽並且以增量事件的方式持續接收資源對象的變更。一方面Controller可以對這些資源對象的增量變更事件進行即時處理,另一方面,也可對緩存進行更新,保證緩存與API Server中的源數據保持最終的一致性。

落實到具體的實現,通用的Controller架構如下圖所示:

arch

上圖中有一條虛線體貼地將整張架構圖切割為成了兩部分:圖的上半部分是client-go封裝的與API Server的底層交互邏輯;下半部分則是Controller的業務代碼,主要是針對資源對象增量事件的處理。接下來,本文將以資源對象的增量事件從API Server傳輸到Controller之后,在架構中的流轉作為順序,依次說明各個組件所起的作用。

2. Reflector & Delta FIFO

Reflector負責對特定的資源對象進行監聽並且將資源對象的所有變更推送到Delta FIFO這個隊列中。首先,Reflector會利用特定資源對象的client向API Server發起List操作,獲取該資源對象的全量信息並將它們推送到Delta FIFO中。List本質上就是一個HTTP請求,其Request/Response類似的形式如下:

 GET /api/v1/namespaces/test/pods
 ---
 200 OK
 Content-Type: application/json
 {
   "kind": "PodList", "apiVersion": "v1", "metadata": {"resourceVersion":"10245"}, "items": [...] }

List操作相當於對API Server中指定資源對象的內容作了一個快照,返回的元數據當中的ResourceVersion用於標記該快照。ResourceVersion本質上是資源對象在底層數據庫中的版本號,它會隨着資源對象的變更而不斷累加。之后我們只要持續不斷地利用Watch從API Server中獲取資源對象的所有變更事件用於更新Controller中的緩存,那么緩存中的數據就可以認為與API Server中的數據是一致的(當然,最終還是與etcd中的數據一致)。Watch本質上也是一個HTTP請求,不過它是一個長連接,會不斷地從服務器端獲取數據,具體形式如下:

GET /api/v1/namespaces/test/pods?watch=1&resourceVersion=10245 --- 200 OK Transfer-Encoding: chunked Content-Type: application/json { "type": "ADDED", "object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "10596", ...}, ...} } { "type": "MODIFIED", "object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "11020", ...}, ...} } ...

Watch會持續地返回資源對象的變更事件,"type"字段指定了事件的類型,主要類型如下:

  • Added:新的資源對象實例,例如:新增一個Pod
  • Modified:資源對象實例更新,例如:更新一個Pod
  • Deleted:資源對象實例刪除,例如:刪除一個Pod

"object"字段則包含了對應資源對象實例的全部信息。Reflector維護了一個lastSyncResourceVersion字段,最初由List返回結果中的ResourcVersion初始化,之后每次從Watch接收到一個事件,都會利用該事件中包含對象的ResourceVersion更新lastSyncResourceVersion。實際上,現實世界里網絡是不可能永遠穩定的,Watch底層的連接也隨時可能會斷開。一旦,Watch斷開,Reflector就會以lastSyncResourceVersion為起點重新開始對API Server進行監聽,從而既不會遺漏也不會重復接受資源對象的變更事件,保證了緩存數據與API Sever中原始數據的一致性。

Delta FIFO是一個先進先出隊列,其中緩存了資源對象的變更事件,每一個事件即一個如下所示的Delta結構:

type Delta struct { Type DeltaType // Added, Updated, Deleted, Sync Object interface{} }

其中Type是事件的類型,Object則是變更發生后,資源對象的狀態。

Delta FIFO也是一個典型的生產者-消費者模型,Reflector就是生產者,它通過List&Watch從API Server獲取相應的事件並根據事件的類型轉換為Delta結構並放入隊列中。而如下所示的Delta FIFO的Pop方法就是該隊列的消費者:

type PopProcessFunc func(interface{}) error

func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error)

Pop方法會持續地隊列進行監聽,一旦隊列中存在"消費品"時,Pop方法就會將其出隊並交由類型為PopProcessFunc的處理函數進行處理。這里需要注意的是,Pop方法每次從隊列中彈出的並不是某個資源的單個事件,而是該資源在隊列期間按發生的先后排好序的所有變更事件的集合。從而對於每個資源對象實例,你只需要處理它一次並且每次處理你都能看到從上次處理它以來,發生在它身上所有的變更。

3. Informer

從上文的架構圖可以看到,Informer這個結構處於整個架構的中心,簡單地說,它有以下三個作用:

  • 接收Delta FIFO的Pop方法彈出的資源對象的一系列變更事件並根據事件的類型進行處理
  • 根據資源對象的變更事件更新本地緩存,即Indexer
  • 將資源對象的變更事件分發給已經在Informer中注冊的各個事件變更類型的Handler進行處理

如果你仔細研究過client-go的源碼,那你一定感受過被Index支配的恐懼:IndexIndexersIndices以及IndexFunc這幾種名字看起來非常相似但是作用卻完全不同的自定義類型一定讓你感到暈頭轉向。事實上,Indexer僅僅是只是在最簡單的緩存之上封裝了一個索引的功能。

client-go實現緩存的方法很簡單,就是golang原生的Map加上一個讀寫鎖,也就是上面架構圖中的Thread Safe Store。其中Map的Value是具體的資源對象實例,例如PodA,而Map的Key的形式一般為Namespace/Name,例如對於NamespaceA下的PodA,它的Key即為NamespaceA/PodA,對於類似於節點這種不屬於任何Namespace的資源對象,則Key直接為Name

當監聽的資源對象是Pod時,獲取某個Namespace之下的所有Pod,或者獲取和某個Pod處於同一個Namaspace下的所有Pod等等,都是常見的需求。但是如果沒有索引的話,我們每次都要遍歷Map,從中篩選出符合要求的資源對象實例,這種方式顯然是不能滿足要求的。

因此,緩存Thread Safe Store在真正實現時,對應到具體的數據結構如下:

type threadSafeMap struct { lock sync.RWMutex items map[string]interface{} indexers Indexers indices Indices } type IndexFunc func(obj interface{}) ([]string, error) type Indexers map[string]IndexFunc type Index map[string]sets.String type Indices map[string]Index

可以看到,在實現加鎖Map的結構以外還增加了一個類型為IndexersIndices的字段,那么這兩個字段的作用又是什么呢?對於一組資源對象,我們可以從很多的維度構建索引,而Indexers其實是一組索引生成函數,每個鍵值對都代表不同的索引維度,最常見的自然是基於Namespace進行索引,此時Indexers的Key就是字符串namespace,而Value則為函數MetaNamespaceIndexFunc,它的參數是任意的資源對象,返回的則是該資源對象所處的Namespace。而Indices則是一個存儲索引的結構。類似地,它的Key也是構建索引的維度,例如此處的namespace,而Index則是真正存儲索引的結構,Key為某個索引值,Value則是與該索引值匹配的資源對象實例的Key。下面我們用一個例子對上述機制進行更為直觀的說明。

例如,當NamespaceA下新增了一個PodA時,需要根據該事件更新緩存。threadSafeMap首先計算出這個Pod在items中存儲的鍵值,即NamespaceA/PodA,接下來還需要為該Pod建立各個維度的索引,此時我們遍歷Indexers調用各個維度的索引構造函數,例如對於namespace這個維度,索引構造函數得到PodA對應的索引值是NamespaceA,然后我們再用這一結果更新Indices,即threadSafeMap.indices["namespace"]["NamespaceA"] = sets.String{..., "NamespaceA/PodA"}。之后如果我們再想通過緩存獲取NamespaceA下的所有Pod信息,那么最終利用的是threadSafeMap.indices["namespace"]["NamespaceA"]得到一系列的鍵值,再利用這些鍵值從threadSafeMap.items得到相關的Pod信息。索引建立之后,篩選特定的資源對象就不再需要使用遍歷緩存這種原始而低效的方法了。

Controller基於的是一種面向事件的編程模型,因此Controller中業務邏輯的實現本質上是對與資源對象變更事件的處理。因此,通常需要向Informer注冊針對變更事件的處理函數,分別對資源對象的增加(Add),修改(Update)以及刪除(Delete)進行處理。當Informer從Delta FIFO彈出Delta時,它會根據Delta的類型,將Delta中的對象交由對應變更類型的事件處理函數進行處理。

上述即是client-go對於Controller通用邏輯的封裝,概括地說,就是Reflector利用List & Watch從API Server得到關於特定資源對象的變更事件,Informer再利用這些事件更新緩存並且用注冊的事件處理函數對各個事件進行處理。

client-go對Controller中的通用邏輯進行了封裝,本質上,它為我們提供了兩組接口:一組是特定資源對象的緩存,即上文中的Indexer,通過它可以知道對應資源對象所有實例的狀態;另一組就是我們可以向Informer注冊的事件處理函數,一旦某個資源對象實例發生變更,相應的事件處理函數就會被調用。

4. Controller中業務邏輯的實現

通過上文的分析可知,對於Controller的實現者來說,他所要做的僅僅是將業務邏輯封裝在資源對象變更事件的處理函數里並向Controller注冊。從編碼的角度來看,Controller開發者只要實現如下的接口即可:

type ResourceEventHandler interface { OnAdd(obj interface{}) OnUpdate(oldObj, newObj interface{}) OnDelete(obj interface{}) } // 實際向Informer注冊的其實是`ResourceEventHandlerFuncs`,`ResourceEventHandlerFuncs`可以允許指定三種事件處理函數中的某幾種,但依然確保能夠實現接口`ResourceEventHandler`

每當相應的事件類型發生,對應的處理函數就會被調用。不過通常來說,用戶並不會真的在事件處理函數里面實現自己的業務邏輯。例如,Kubelet本質也是一個Controller,它會對Pod進行監聽並且注冊相應的處理函數進行處理。當系統中新增一個Pod時,Kubelet顯然不會直接在OnAdd()函數中直接就開始將這個新增的Pod實例化,因為這樣的操作往往非常耗時,從而阻塞Informer對於事件的分發。

因此,事件處理函數中的邏輯往往非常簡單。它會直接獲得一個Key用於表示該對象,就像我們在上文的緩存中做的那樣,例如NamespaceA下的PodA,它的Key就是NamespaceA/PodA,並將這個Key放入一個隊列。同時,我們可以創建多個Goroutine從隊列中獲取Key,對發生變更的資源對象實例進行處理。那么如何從Key轉換成具體的對象實例呢?顯然緩存可以幫我們做到這一點。獲取到實例之后,我們就可以從容地展開業務邏輯了。例如,對於Kubelet來說就可以用新的Pod配置去創建或者更新Pod實例了(如果在緩存中找不到Key對應的實例,則說明它已經被刪除了)。可以發現,這部分敘述的內容和上文架構圖虛線以下的部分是完全對應的。

5. 總結

Controller其根本的理念是非常簡單的:實現實際狀態和期望狀態的最終一致性。如果API Server有着無限的並發能力,Controller的實現完全可以簡單到直接通過輪詢全量資源對象的目標狀態,然后據此對實際狀態進行調整。但實際情況是,API Server的並發能力是有限的,因此我們需要利用List & Watch進行緩存,以增量式地,面向事件的模式對資源對象的更新進行處理。所幸的是,社區對Controller實現框架中的通用部分進行了抽象封裝,從而極大地減輕了Controller開發者的心智負擔,可以更多地專注於業務邏輯的處理。

參考文獻

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM