Kubernetes(k8s)是一款開源的優秀的容器編排調度系統,其本身也是一款分布式應用程序。雖然本系列文章討論的是互聯網架構,但是k8s的一些設計理念非常值得深思和借鑒,本人並非運維專家,本文嘗試從自己看到的一些k8s的架構理念結合自己的理解來分析 k8s在穩定性、簡單、可擴展性三個方面做的一些架構設計的考量。
- 穩定性:考慮的是系統本身足夠穩定,用戶使用系統做的一些動作能夠穩定落地,系統本身容錯性足夠強可以應對網絡問題,系統本身有足夠的高可用等等。
- 簡單:考慮的是系統本身的設計足夠簡單,組件之間沒有太多耦合,組件職責單一等等。
- 可擴展性:考慮的是系統的各個模塊有層次,模塊對內對外一視同仁,外部可以輕易實現擴展模塊插入到系統(插件),模塊實現統一的接口便於替換切換具體實現等等。
下面,針對這三方面我們都會來看一些k8s設計的例子,在看k8s是怎么做的同時我們可以自己思考一下,如果我們需要研發的一款產品就是類似於k8s這樣的需要高可靠的資源狀態管理協調系統,我們會怎么來設計呢?
1、穩定:聲明式應用程序管理
我們知道,k8s定義了許多資源(比如Pod、Service、Deployment、ReplicaSet、StatefulSet、Job、CronJob等),在管理資源的時候我們使用聲明式的配置(JSON、YAML等)來對資源進行增刪改查操作。我們提供的這些配置就是描述我們希望這些資源最終達成的一個目標狀態,叫做Spec,k8s會對觀察資源得到資源的狀態,叫做Status,當Spec!=Status的時候,k8s的各種控制管理程序就會起作用,進行各種操作使得資源最終可以達到我們期望的Spec。這種聲明式的管理方式和命令式管理方式相比,雖然沒有后者這么直接,但是容錯性會很強,后面一節會進一步詳細提到這點。而且,這種管理方式非常的簡潔,只要用戶提供合適的Spec定義即可,並不需要對外暴露幾十個幾百個不同的API來實現對資源的各個方面做改變。當然,我們也可以靈活的對一些重要的動作單獨開辟管理API(比如擴容,比如修改鏡像),這些API底層做的操作就是修改Spec,底層是統一的。
在之前第一季的系列文章S1E2中,我分享過任務表的設計,其實這里的聲明式對象管理就是類似這樣的思想,我們在數據庫中保存的是我們要的結果,然后由不同的任務Job來進行處理最終實現這樣的結果(同時也會保存組件當前的狀態到數據庫),即使任務執行失敗也無妨,后續的任務會繼續重試,這種方式是可靠性最高的。
2、穩定:邊緣觸發 vs 水平觸發
K8s使用的是聲明式的管理方式,也就是水平觸發。另一種做法是叫做命令式的管理,也就是邊緣觸發。比如我們在做支付系統,用戶充值100元,提現100元然后又充值100元,對於命令式管理就是三條命令。如果提現請求丟失了,用戶賬戶的余額就出錯了,這肯定是不能接受的,命令式管理或邊緣觸發一定需要配合補償。而聲明式的管理就是告訴系統,用戶在進行了三次操作后的余額分別是100、0和100,最終就是100,即使提現請求丟失了,最終用戶的余額就是100。
來看下下圖的例子,在網絡良好的情況下,邊緣觸發沒任何問題。我們進行了開、關、開三次操作,最后的狀態是0。

在網絡出現問題的時候,丟失了關這個操作,對於邊緣觸發,最終停留在了2這個錯誤的狀態。對於水平觸發沒有這個問題,雖然當中有一段時間網絡不好,狀態錯誤停留在了1,但是網絡恢復后我們馬上可以感知到當前的狀態應該是0,狀態又能回到0,最終狀態也能回到正確的1。試想一下,如果我們對我們的Pod進行擴容縮容,如果每次告知k8s應該增加或減少多少個Pod(的這種命令式方式),最終很可能因為網絡問題,Pod的狀態不是我們期望的。更好的做法是告訴k8s我們希望的狀態,不管現在網絡是否有問題,某個管理組件是否有問題,pod是否有問題,最終我們期望k8s幫我們調整到我們期望的狀態,寧可慢也不要錯。

(圖來自這里)
3、穩定:高可用設計
我們知道etcd是基於Raft協議的分布式鍵值數據庫/協調系統,本身推薦使用3、5、7這樣奇數節點構成集群實現高可用。對於Master節點,我們可以在每一個節點都部署一個etcd,這樣節點上的API Server可以和本地的etcd直接通訊,而API Server因為是輕(無)狀態的,所以可以在之前使用負載均衡器做代理,不管是Node節點也好還是客戶端也好都可以由負載均衡分發請求到合適的API Server上。對於類似於Job的Controller Manager以及Scheduler,顯然不適合多個節點同時運行,所以它們都會采用搶占方式選舉Leader,只有Leader能承擔工作任務,Follower都處於待機狀態。整體結構如下圖所示:

我們可以想一下其它一些分布式系統的高可用方案,以及我們自己設計的系統的高可用方案,無非就是這三種大模式:
- 無狀態多節點 + 負載均衡
- 有狀態的主節點 + 從(或備份)節點
- 對稱同步的有狀態多節點
4、簡單:基於list-watch的發布訂閱
通過前面的介紹我們大概知道了k8s的一個設計原則是etcd會處於API Server之后,集群內的各種組件是無法直接和數據庫對話的,不僅僅因為把數據庫直接暴露給各組件會特別混亂,更重要的是誰都可以直接讀寫etcd會非常不安全,需要統一經過API Server做身份認證和鑒權等安全控制(后面我們會提到API Server的插件鏈)。
對於k8s集群內的各種資源,k8s的控制管理器和調度器需要感知到各種資源的狀態變化(比如創建),然后根據變化事件履行自己的管理職責。考慮到解耦,顯然這里有MQ的需求,各種管理組件可以監聽各種資源的狀態變化事件,不需要相互感知到對方的存在,自己做自己的事情即可。如果k8s還依賴一些消息中間件實現這個功能,那么整體的復雜度會上升,而且還需要對消息中間件進行一些安全方面的定制。
K8s給出的實現方式是仍然使用API Server來充當簡單的消息總線的角色,所有的組件通過watch機制建立HTTP長鏈接來隨時獲悉自己感興趣的資源的變化事件,完成自己的功能后還是調用API Server來寫入我們組件新的Spec,這份Spec會被其它管理程序感知到並且進行處理。Watch的機制是推的機制,可以實時對變化進行處理,但是我們知道考慮到網絡等各種因素,事件可能丟失,組件可能重啟,這個時候我們需要推拉結合進行補償,因此API Server還提供了List接口,用於在watch出現錯誤的時候或是組件重啟的時候同步一次最新狀態。通過推拉結合的list-watch機制滿足了時效性需求和可靠性需求。

我們來看一下這個圖,這個圖展示了客戶端創建一個Deployment后k8s大概的工作過程:
組件初始化階段:
- Deployment Controller訂閱Deployment創建事件
- ReplicaSet Controller訂閱ReplicaSet創建事件
- Scheduler訂閱未綁定Node的Pod創建事件
- 所有Kubelet訂閱自己節點的Node和Pod綁定事件
集群資源變更操作:
- 客戶端調用API Server創建Deployment Spec
- Deployment Controller收到消息需要處理新的Deployment
- Deployment Controller調用API Server創建ReplicaSet
- ReplicaSet Controller收到消息需要處理新的ReplicaSet
- ReplicaSet Controller調用API Server創建Pod
- Scheduler收到消息,需要處理的新的Pod
- Scheduler經過處理后決定把這個Pod綁定到Node1,調用API Server寫入綁定
- Node1上的Kubelet收到事消息需要處理Pod的部署
- Node1上的Kubelet根據Pod的Spec進行Pod部署
可以看到基於list-watch的API Server實現了簡單可靠的消息總線的功能,基於資源消息的事件鏈,解耦了各組件之間的耦合,配合之前提到的基於聲明式的對象管理又確保了管理穩定性。從層次上來說,master的組件都是控制面的組件,用來控制管理集群的狀態,node的組件是執行面的組件,kubelet是一個無腦執行者的角色,它們的交流橋梁是API Server的各種事件,kubelet是無法感知到控制器的存在的。
5、簡單:API Sever收斂資源管理入口
如下圖所示,API Server實現了基於插件+過濾器鏈的方式(比如我們熟知的Spring MVC的攔截器鏈)來實現資源管理操作的前置校驗(身份認證、授權、准入等等)。

整個流程會有哪些環節呢:
- 身份認證,根據各種插件確定來者是誰
- 授權,根據各種插件確定用戶是否有資格可以操作請求的資源
- 默認值和轉換,資源默認值設置,客戶端到etcd版本號轉換
- 管理控制,根據各種插件執行資源的驗證或修改操作,先修改后驗證
- 驗證,根據各種驗證規則驗證每一個字段有效性
- 冪等和並發控制,使用樂觀並發方式(版本號方式)驗證資源尚未被並發修改
- 審計,記錄所有資源變更日志
如果是刪除資源,還會有額外的一些環節:
- 優雅關閉
- 終接器鈎子,可以配置一些終接器,在這個時候回調
- 垃圾回收,級聯刪除沒有引用根的資源
對於復雜的流程式的操作,采用職責鏈+處理鏈+插件的方式來實現是很常見的做法。你可能會說這個API Server的設計總體上就不簡單,怎么有這么多環節,其實這才是最簡單的做法,每一個環節都有獨立的插件來運作(插件可以獨立更新升級,也可以根據需求動態插拔配置),每一個插件只是做自己應該做的事情,如果沒有這樣的設計,恐怕會出現1萬行代碼的一個大方法。
6、簡單:Scheduler的設計

如圖所示,類似於API Server的鏈式設計,Scheduler在做Pod調度算法的時候也采用了鏈式設計:
- 待調度的Pod本身有一個優先級的概念,優先級高的先調度
- 先找出所有的可用節點
- 使用predicate(過濾器)篩選節點
- 使用priority(排序器)對節點進行排序
- 選擇最大優先級的節點調度給Pod
常見的predicate算法有:
- 端口沖突監測
- 資源是否滿足
- 親和性考量
- ……
常見的priority算法有:
- 網絡拓撲臨近
- 平衡資源使用
- 資源較多節點優先
- 已使用的節點優先
- 已緩存鏡像節點優先
- ……
比如我們在做類似路由系統這種業務系統的時候可以借鑒這種設計模式。簡單一詞在於每一個小組件簡單,它們可以組合起來構成復雜的規則系統,這種設計比把所有邏輯堆在一起簡單的多。
7、擴展:分層架構
K8s的設計理念是類似Linux的分層架構:
- 核心層:Kubernetes 最核心的功能,對外提供 API 構建高層的應用,對內提供插件式應用執行環境
- 應用層:部署(無狀態應用、有狀態應用、批處理任務、集群應用等)和路由(服務發現、DNS 解析等)
- 管理層:系統度量(如基礎設施、容器和網絡的度量),自動化(如自動擴展、動態 Provision 等)以及策略管理(RBAC、Quota、PSP、NetworkPolicy 等)
- 接口層:kubectl 命令行工具、客戶端 SDK 以及集群聯邦

之前介紹的一些組件大多數位於核心層和應用層。在更上層的管理層和接口層,我們往往會做更多的一些二次開發。在之前的文章中我也介紹過,對於復雜的微服務互聯網系統,我們也應該把微服務進行分層,從下到上分為基礎服務、業務服務、聚合業務服務等,每一層的服務聚合下層實現一些業務邏輯,不但可以做到服務重用,而且上層多變的業務服務的變動可以不影響下層基礎設施的搭建。
8、擴展:接口化和插件
除了k8s大量內部組件的實現使用了插件的架構,k8s在整體設計上就把核心和外部的一些資源和服務抽象為了統一的接口,可以插件方式插入具體的實現,如下圖所示:

- 容器方面,容器運行時插件(Container Runtime Interface,簡稱 CRI)是 k8s v1.5 引入的容器運行時接口,它將 Kubelet 與容器運行時解耦,將原來完全面向 Pod 級別的內部接口拆分成面向 Sandbox 和 Container 的 gRPC 接口,並將鏡像管理和容器管理分離到不同的服務。
- 網絡方面,k8s支持兩種插件:
- kubenet:這是一個基於 CNI bridge 的網絡插件(在 bridge 插件的基礎上擴展了 port mapping 和 traffic shaping ),是目前推薦的默認插件
- CNI:CNI 網絡插件,Container Network Interface (CNI) 最早是由CoreOS發起的容器網絡規范,是Kubernetes網絡插件的基礎。
- 存儲方面,Container Storage Interface (CSI) 是從 k8s v1.9 引入的容器存儲接口,用於擴展 Kubernetes 的存儲生態。實際上,CSI 是整個容器生態的標准存儲接口,同樣適用於 Mesos、Cloud Foundry 等其他的容器集群調度系統
我們看下下面這個圖,k8s使用CRI插件來管理容器,為容器配置網絡的時候又走了CNI插件:

CNI、CSI、CRI我們比較熟悉了,其它更多的抽象接口這里就不描述了,k8s就像一個大主板,主板上有各種內存、CPU、IO、網絡方面的接口,具體的實現k8s本身並不關心,用戶和社區甚至可以根據的需要實現自己的插件。
我覺得這點是最了不起的最困難的,很多時候我們在設計一個系統的時候一開始是無法定義出抽象接口的,因為我們不知道將來會面對什么樣的實現,只有到實現越來越多后我們才能抽象出接口才能制定標准。
9、擴展:PV & PVC & StorageClass
K8s在存儲方面的解耦設計特別值得一提。如下圖所示,我們來看一下k8s在存儲這塊的解耦設計:

(圖引自Kubernetes in Action一書)
我們要做的事情很明確,Pod需要綁定存儲資源:
- 首先,我們肯定需要有卷這種抽象,來抽象出存儲方式。但是,如果每次都讓k8s的使用者(不管是運維還是開發)在部署Pod的時候設置需要的卷顯然耦合太強了(比如NFS卷,每次都要設置地址,用於無需也無法關注到底層的這些細節)。卷V描述的是底層存儲能力。
- 於是,k8s抽象出持久卷PV和和持久卷聲明PVC的概念,管理員可以先設置配置PV映射到卷,用戶只需要創建PVC來關聯PV,然后在創建Pod的時候引用PVC即可,PVC並不關注卷的一些具體細節,只關注容量需求和操作權限。PV這層抽象描述的是運維能提供出來的全局卷的資源,PVC這層描述的是用戶希望為Pod申請的存儲資源請求。
- 但是總是需要運維先創建PV還是不方便,k8s還提供了StorageClass這層抽象,通過把PVC關聯到指定的(或默認的)StorageClass來動態創建PV。
K8s中除了存儲抽象的V、PV、PVC、SC,還有其它的一些組件也有類似層次的抽象以及動態綁定的理念。
我們在使用OO語言進行編程的時候,很自然知道我們需要先定義類,然后再實例化類來創建對象,如果類特別復雜(有不同的實現)的話,我們可能會使用工廠模式(或反射,外層傳入目標類型名稱)來創建對象。可以和k8s存儲抽象比較一下,是不是這個意思,這其實就是一種解耦的方式,在架構設計中,甚至表結構設計中,我們完全可以引入類和實例的概念。比如工作流系統的工作流可以認為是一個類模板,每一次發起的工作流就是這個工作流的實例。
總結
好了,本文大概窺探了一下k8s的架構,不知道你是否感受到了k8s的精良設計,對內考慮了高可用以及高可靠,對外考慮到了高可擴展性。幾乎任何操作都允許失敗,最終實現一致的狀態,幾乎任何組件都允許擴展和替換,讓用戶實現自己的定制需求。
如果你的業務系統也是一套復雜的資源協調系統(k8s抽象的是運維相關的資源,我們的業務系統可以抽象的是其它資源),那么k8s的設計理念有相當多的點可以借鑒。舉一個例子,我們在做一套很復雜的流程引擎,我們就可以考慮:
- 流程的執行者抽象出接口,插件方式插入系統
- 流程涉及到的資源我們可以先梳理清楚列出來
- 流程的管理可以把期望結果聲明式方式存儲到數據庫
- 流程的管控組件可以都對着統一的API服務讀寫&訂閱變化
- 流程的管控組件本身可以采用插件鏈、職責鏈方式執行
- 流程的入口可以由統一的網關收口做認證和鑒權等
- ……
