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外掛:
此外,由於在kubernetes中一切皆資源,k8s 1.7之後,提供了CRD(CustomResourceDefinitions)的自定義資源二次開發能力來擴充套件k8s API,通過此擴充套件,可以向k8s API中增加新型別,會比修改k8s的原始碼或者是建立自定義的API server來的更加的簡潔和容易,並且不會隨著k8s核心版本的升級,而出現需要程式碼重新合併的需要,以及相容性方面的問題。這一功能特性的提供大大提升了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服務讀寫&訂閱變化
- 流程的管控元件本身可以採用外掛鏈、職責鏈方式執行
- 流程的入口可以由統一的閘道器收口做認證和鑑權等
- ……