假如重新設計Kubernetes
最近,我和領域內的專家Vallery Lancey有過一次閑聊,主題是關于Kubernetes的。具體說來,假設我們從零開始設計一個新的編排系統,不必拘泥于與現有Kubernetes的兼容性,我們可能會采取哪些不同的做法。我發現對話過程非常有意義,以至于我覺得有必要記錄下期間涌現的諸多想法,所以就有了這篇文章。
落筆前,我想強調幾點:
- 這不是一個完全成形的設計。其中某些想法可能根本無法落地,或者需要進行大量的重構。很多章節的內容都是想到哪寫到哪。
- 這些觀點不單純是我一個人的想法。有些是我原創的,更多的是集體思考,交流碰撞后的產物,就像Kubernetes社區中的許多設計一樣。我知道至少Vallery和Maisem Ali不止一次地啟發了我的思考,還有更多我說不出名字的。如果你覺得文中有些想法很不錯,那請把它當成是集體努力的結果。如果你不太認同其中一些看法,那把它當成是我個人的小小失誤吧。
- 文中的一些觀點是非常極端激進的。我只是嘗試把腦海的一些設計表達出來,一吐為快。
設計原則
我過往的Kubernetes實踐經驗來自兩個截然不同的地方:一個是為裸金屬機集群維護MetalLB;另一個是在GKE SRE中運維大型的集群即服務(clusters-as-a-service)。這兩個經歷都讓我覺得,Kubernetes相當復雜,要達到目前市面上宣傳的效果,往往需要做大量的前置工作,而大多數躍躍欲試的用戶對此都沒有充分的準備。
維護MetalLB的經歷告訴我,想構建與Kubernetes集成的健壯性優異的軟件十分困難。我認為MetalLB穩定性堪稱優秀,但是Kubernetes還是非常容易使它出現配置錯誤的情況,而調試起來也相當費勁。 GKE SRE的運維經歷則教會我,即使是最出色的Kubernetes專家也無法不出差錯地運維大規模Kubernetes集群(盡管GKE SRE借助一些工具能做得非常出色)。
Kubernetes可以類比成編排軟件中的C ++。功能強大,特性完備,看上去似乎挺簡單,但你會不停踩坑,直到你投入大量時間和精力,去弄清它的所有原理為止。即便如此,Kubernetes的配置和部署方式方法眾多,生態還在不停發展,以至于很難讓人覺得可以停下腳步歇口氣了。
按照這個類比,我理想的參照物是Go。如果Kubernetes是C ++,那么編排系統中Go會是什么樣的呢?極度簡潔,特點突出,緩慢而謹慎地拓展新特性,你可以在不到一周的時間里上手,然后就能用它去完成你的工作。
接下來,我們就遵循上面這些原則開始了。重新設計一個Kubernetes,可以不考慮和現有的兼容,另辟蹊徑,該考慮哪些點呢?
可修改的Pod
在Kubernetes中,大部分(但不是全部)Pod在創建后是不可變的。如果你想直接修改一個Pod,不行。得重新創建一個再刪掉舊的。這與Kubernetes中的大多數資源對象的處理方式不同,在Kubernetes中的大多數對象都是可變的,并且當預期狀態出現變化時,可以優雅地將實際狀態協調到與預期一致。
因此,我不想讓Pod成為一個例外。我打算將它設計成完全可讀寫,并讓它擁有像其它資源對象一樣的調諧邏輯。
對此我第一時間想到的方案是原地重啟。如果Pod的調度約束和需求資源前后沒有改變,猜一下如何實現? 發出SIGTERM信號終止runc,使用不同的參數重新啟動runc,就已完成重啟。這樣一來,Pod有點像從前的systemd服務,必要時還可以在不同機器之間漂移。
請注意,這不需要在運行時層面操作可變性。如果用戶更改了Pod定義,仍然可以先終止容器并使用新配置重新啟動容器。 Pod仍會保留在原節點上預留的資源,因此從概念上講,它等效于systemctl restart blah.service。你也可以嘗試在并在運行時層級上來執行Pod的更新操作,但其實沒有必要這樣做。不這么做的主要好處是將調度、Pod生命周期及運行時生命周期管理解耦。
版本管理
來繼續討論Pod的設計:既然它現在是可變的了,那么我接下來考慮的事情自然而然就是Pod回滾。為此,讓我們保留Pod舊版本的定義,如此一來“回滾到特定舊版本”就輕而易舉了。
現在,Pod更新流程如下:編寫文件更新Pod定義,并進行更新以符合預期定義。更新出錯?回滾上一個版本,流程結束。
上述流程的好處是:無需依賴所謂的GitOps,即可輕松了解到集群中應用的版本迭代。如無必要就不用引入GitOps,盡管它有不少優點。如果,你只希望解決一個很基本的“集群發生了什么變化?”的問題,僅僅使用集群中的數據就夠了。
其中還涉及到更多設計細節。尤其是我想將外部更改(用戶提交Pod的變更)與系統變更(Kubernetes內部觸發的Pod定義變更)這兩者區分開。我還沒有考慮清楚如何對這兩種變更的歷史信息進行編碼,并使得運維人員和其他系統組件都可以獲取到這些變更。也許可以設計成完全通用的,“修改人”在提交新版本時會標識自己。然后用戶可以指定特定修改人(或排除特定修改人)以查詢某類變更(類似于標簽查詢的工作原理)。同樣的,這里還需要更多的設計考量,我確定的是我想要一個具有版本管理特性的Pod對象,可以查詢它的歷史版本記錄。
最后,還需要考慮垃圾回收。具體說就是,對每個Pod的更改應該可以很好地進行增量壓縮。默認設置是保留所有變更內容,積累到一定數據量后,在此基礎上進行一次壓縮。保留所有變更內容也會對系統產生一定壓力,但可避免“因頻繁提交更改”而給系統的其它部分帶來影響。這里用戶要注意,為方便聚合,應該進行次數更少同時更有意義的變更,而不是每次改動一個字段進而產生一系列版本。
一旦有了歷史版本這個功能后,我們還可以整一些其它的小功能。例如,節點可以將最近的若干個版本的容器鏡像保留在節點上,從而使回滾更快。原來的垃圾回收超過一定期限就觸發,有了歷史版本記錄,可以更精確地保留需要的版本數。概括而言,所有編排軟件都將舊版本用作各種資源對象的GC roots,以加快回滾速度。回滾是避免服務中斷的基本方式,這是非常有價值的事情。
用PinnedDeployment替換Deployment
這是部分內容比較簡短,主要是受Vallery啟發。他的PinnedDeployment設計非常讓人驚嘆。PinnedDeployment使運維人員可以通過跟蹤兩個版本的Deployment狀態來控制應用發布。這是由SRE設計的部署對象。設計人員非常清楚SRE在部署中的關注的焦點。我個人很喜歡這個設計。
這可以和上面的可版本管理、可原地更新的Pod結合得非常好,真想不到還有什么可以添加的了。它非常清晰的解釋了多個Pod時的工作流程。要從Kubernetes各項約束中脫離來適應這一個全新的流程,可能需要做些調整,但是大體設計是非常不錯的。
顯式的編排流程
我認為Kubernetes的“API machinery”機制最大問題是編排,即一系列獨立控制循環的松散集合所構成工作流程。從表面上看,這似乎是一個好主意:你有好幾十個微小的控制循環,每個控制循環只負責一個小功能。當它們被整合到一個集群時,它們彼此互相協作以調諧資源對象狀態并收斂至符合預期的最終狀態。所以這其中有何問題?
問題就在于出現錯誤時幾乎不可能去進行調試。 Kubernetes中典型的出錯,就是用戶將變更提交給集群,然后反復刷新以等待資源對象符合預期,如果遲遲沒有符合……那么,來問題了。 Kubernetes分辨不出“對象已符合預期”和“控制循環被中斷并阻塞了其他事物”之間的區別。你或許希望有問題的控制循環會發布它所管理對象的一些事件來幫你排錯,但總的來說它們發揮不了多少作用。
此時,你唯一的可行選擇是收集可能涉及的每個控制循環的日志,尋找被中斷的循環。如果你對所有控制循環的工作機制都有深入的了解,則定位錯誤的速度可以更快一些,豐富的經驗可以讓你從資源對象的當前狀態,推斷出是哪個控制循環出錯,并正嘗試恢復運行。
這里要注意關鍵一點,我們看待復雜度的視角已經從控制循環的設計者轉換到到了集群運維人員。設計一個可以獨立執行單一任務的控制循環很容易(并非是說其不重要)。但是,要在集群中維護數十個這樣的控制循環,就需要運維人員非常熟悉這些控制循環的操作,以及它們之間的交互,并嘗試理解這樣一個組織松散的系統。這是必須認真考慮的問題,通常設計者編寫控制循環代碼驗證其功能這樣的工作是一次性的,但是運維人員可能要終日和它們打交道,并反復處理控制循環出現的問題。簡化那些你只需要做一次的事情對運維人員來說不公平。
為了解決這個問題,我會參照systemd的做法。它解決了類似的生命周期管理問題:給定當前狀態A和目標狀態B,如何從A變為B?區別是,在systemd中,操作步驟及其依賴是顯式的。你告訴systemd,你的服務單元是multi-user.target服務組的一部分,則它必須在掛載文件系統之后聯網之前啟動運行。您還可以依賴系統的其他具體組件,例如說只要sshd運行,你的服務就需要運行(聽起來像邊車,是吧?)。
這樣做的好處是systemd可以準確地告訴用戶,是系統的哪一部分發生故障,哪部分仍在運行,或是哪個前置條件沒通過。它甚至還可以打印出系統啟動的執行過程,以供分析定位問題,例如“哪個服務的啟動耗時最長”。
我想批量的照搬這些設計到我的集群編排系統中。不過也確實需要一些微調,但大致來說:控制循環必須聲明它們對其他控制循環的依賴性,必須生成結構化日志,以便用戶可以輕松搜索到“有關Pod X的所有控制循環的操作日志”,并且編排系統處理生命周期事件,可以采取像systemd那樣的做法,逐個排查定位到出問題的服務組單元。
這在實踐起來會是怎么樣的?先結合Pod的生命周期說起。可能我們將定義一個抽象的“運行”target,這是我們要達到的狀態——Pod已經啟動并且一切正常。容器運行時將添加一個任務到“運行”之前,以啟動容器。但它可能要到存儲系統完成網絡設備掛載后才能運行,因此它將在“存儲”target之后自行啟動。同樣地,對于網絡,容器希望在“網絡”target之后啟動。
現在,你的Ceph控制循環將自己安排在“存儲”target之前運行,因為它負責啟動存儲。其他存儲控制循環也是相同的執行流程(local bind mount,NFS等)。請注意,這里的執行流程可以是并發執行,因為它們都聲明要在存儲準備就緒之前執行,但是并不在意在其他存儲插件的控制循環之前還是之后執行。也有可能存在例外情況!比如你編寫了一個很棒的存儲插件,它功能出色,但是必須先進行NFS掛載,然后才能運行。好了,我們只需要在nfs-mounts步驟中添加一個依賴項,就可以完成了。這就和systemd類似,我們既規定了順序,又規定了“還需要其他組件才能正常工作”這樣的硬性要求,因此用戶可以輕松定義服務的啟動步驟。
(此處的討論我稍微簡化了一下,并假設各項操作步驟沒有太多的循環依賴。要深入的話,這可以展開出更復雜的流程。請參閱下文進一步探討,這里先不討論太過于復雜的流程。)
有了這些設計,編排系統可以回答用戶“為什么Pod沒有啟動?”用戶可以dump下Pod的啟動流程圖,并查看哪些步驟已完成,哪些步驟失敗,哪些已在運行。 NFS掛載已經進行了5分鐘?會不會有可能是NFS服務器已掛掉,但控制循環沒報超時?服務的各項配置和可能的狀態,疊加出來的結果矩陣是非常龐大的:如果有了我們設計的這樣一個輔助調試的工具,這也不算個大問題。 Systemd允許用戶以任意順序、任意約束往服務的啟動過程添加內容。但是當出現問題時,我仍然可以輕松對其進行故障排查,根據約束條件,在調試工具的輔助下,我可以第一時間定位到問題的關鍵所在。
和Systemd給系統啟動帶來的好處類似,這讓系統可以盡可能地并行執行生命周期操作,但也僅此而已。而且由于工作流程是顯式的,它還可以擴展。你的集群是否存在這種情況:在每個Pod上都有企業定制的操作,且必須在某個生命周期階段內執行的?可以定義一個新的中間target,使其依賴于于正確的前置或后置條件,然后將控制循環掛接(hook)到這里接收回調。編排系統將確保控制循環在生命周期的各階段發揮作用。
請注意,這還解決了諸如Istio之類的存在奇葩問題。在Istio中,它們必須注入一些額外的開發者提供的定義才能起作用。沒必要!提供對應的控制循環介入到生命周期管理中,并根據實際需要在進行調整。只要你可以向系統表示,在生命周期中某個特定階段需要執行操作的,就無需考慮通過向運維人員提供額外的資源對象去操作。
這部分內容很長,但想表達的意思很簡短。這和Kubernetes的原來的API machinery大相徑庭,因此需要大量新的設計工作才能實現。最突出的變化,控制循環不再只是簡單地觀察集群對象的狀態并做出修正,還必須等待編排器(Orchestrator)完成對特定對象的調用,當這些對象達到符合預期的狀態時,控制循環再進一步響應。你現在可以通過注解和約定,將其在Kubernetes實現上。但除非對工作機制的細枝末節的都了解得一清二楚,否則就可觀察性和可調試性來說,沒什么幫助。
有趣的是,Kubernetes已經有其中一些想法的原型實現:Initializers和Finalizers 。它們分別是生命周期兩個不同階段里執行預操作的鉤子。它使您可以將控制循環掛接到兩個硬編碼的“target”上。他們將控制循環分為三個部分:初始,“默認”和終結。雖然是硬編碼,這是顯式工作流程圖的雛形。我打算把這個設計推廣到更一般的情況。
顯式的字段歸屬
承接上一部分設計的適度擴展:使資源對象的每個字段都被特定的控制循環顯式擁有。該循環是唯一允許寫入該字段的循環。如果未定義所有者,則該字段可被集群運維人員寫入,但運維人員不能寫其他任何內容。這是由API machinery(而非約定)強制執行的。
這已經是大多數情況,但還是存在字段所有權模糊不清的時候。這導致兩個問題:如果字段錯誤,則很難弄清誰負責;而且一不小心就會進入到兩個控制器修改一個字段的情況,陷入循環。
后者是MetalLB存在的大麻煩,它與其他一些負載均衡器實現方式發生了沖突。不應該出現這樣的情況。Orchestrator應該拒絕MetalLB添加到集群中,因為與LB相關的字段將有兩個所有者。
可能需要留個后門,讓用戶處理一個字段有多個歸屬者的情況。但簡單起見,我在這里先不考慮,然后看看設計是不是經得起考驗。除非有充分證明支持,否則共享所有權就是一個會帶來潛在隱患的設計。
如果你還要求控制循環顯式注冊讀取的字段(并把那些沒有注冊的字段剝離出來——不準作弊),這也可以讓你做一些有意義的事情,比如證明系統收斂(沒有讀->寫->讀的循環),或是幫你照出拖慢系統響應速度的調用環節。
有且只有IPv6
我對Kubernetes網絡部分非常熟悉,它是我最想全盤推倒重來的一個部分。有很多原因造成了網絡模塊發展成今天這個局面,我并不是想說Kubernetes網絡設計得一無是處。但網絡不在我的編排體系里。這部分內容很長,請帶點耐心。
首先,讓我們先徹底拋開Kubernetes現有的網絡。覆蓋網絡,Service, CNI,kube-proxy,網絡插件,這些統統都不要了。
(順便提一句,為什么網絡插件是不應該出現K8s理想的設計中的。目前,已經有不少企業開始兜售他們的網絡插件了,你最好不要相信他們能保持客觀中立,讓我來列出反駁他們的理由。無論是自然界還是軟件界,所有生態系統的第一要務都是確保其繼續存在。你不能要求一個生態系統自我進化到滅亡,你必須從外部觸發滅亡。)
回到正題,現在一切歸零了。我們有容器,它們需要互相通信,和外部通信。那該做什么?
讓我們賦予每個Pod一個IPv6地址。是的,只有一個IPv6地址。它們從哪里來?這里要求局域網支持IPv6(假定具備這樣的條件,畢竟我們的設計要面向未來),IP地址就從這來。你幾乎都不需要做IP地址沖突檢測,2^64足夠大,隨機生成的IP地址基本上就滿足需求了。我們需要一個機制,好讓每個節點上之間能互相發現,這樣就可以找到其他 Pod 在哪個節點上。這應該不難實現,這么做的理由很簡單:對集群網絡內的其他部分而言,一個 Pod 看起來就像是在運行其中的某個節點。
或者我們干脆組一個全是唯一本地地址的網絡,然后手動在每個節點上做路由。這實現起來非常容易,而且地址分配基本上就是 "隨便選一個數字就可以了"。可能還需要設計一個子集,這樣節點到節點的路由才會更有效率,但這都是不太復雜的東西。
有個麻煩是云服務商喜歡介入到網絡的基礎部分。所以IPAM模塊要保持可插拔性(在上文所提到工作流模型之內),這樣我們就可以做一些事情,比如向AWS解釋流量是如何轉發的。不過,使用IPv6可能就無法在GCP上運行了。
不管怎么說,有許多備選的方案來做這部分的設計。就其根本而言,我只想在節點之間使用IPv6和配置一些基本的、簡單的路由就可以了。這樣就可以在接近零配置的情況下,解決Pod之間的連接問題,因為IPv6是有足夠大的地址空間,我們選一些隨機數字就能完事。
如果你有更復雜的連接需求,你就把這些作為額外的網絡接口和我設計的簡單、可預測的IPv6路由接上。需要保證節點間的通信安全?引入wireguard隧道,添加路由,通過wireguard隧道推送節點IP,完事。編排系統不需要知道這些細枝末節,除了可能會在節點生命周期管理中添加一個小小的控制循環,這樣在隧道建立好之后,才讓節點處于就緒狀態。
好了,Pod之間的互聯互通,Pod和外部網絡的連接,這兩個問題都解決了。考慮到現在只有IPv6,我們如何處理流入集群的IPv4流量呢?
首先,我們規定IPv4只適用于Pod和Internet連通的這種情況。在集群內必須強制使用IPv6。
我們可以用幾種方法來應對這個限制。簡單來說,我們可以讓每個節點封裝IPv4流量,為Pod預留一小塊符合RFC 1918規范的地址空間(所有節點上預留的地址都從屬于這個空間)。這樣就可以讓它們到達IPv4互聯網,但這都是每個節點的靜態配置,根本不需要集群可見。你甚至可以將IPv4的東西完全隱藏在控制平面中,這只是每臺機器運行時的一個實現細節。
我們也可以用NAT64和CLAT來找點樂子:讓整個網絡只用IPv6,但用CLAT來欺騙Pods,讓它們以為自己有IPv4連接。然后在Pod內進行 IPv4到IPv6的地址翻譯,并將流量發送到NAT64網關。可以是每臺機器的NAT64,也可以是集群內的部署的。如果你需要處理大量的NAT64流量,甚至可以用一個集群外部類似CGNAT這樣的東西。在這一點上,CLAT和NAT64已經有很好的應用:你的手機可能正是通過這樣的方式來讓你獲得IPv4地址接入互聯網。
我可能會從簡單的IPv4偽裝開始(第一種方案),因為所需的配置量極少,都可以由每臺機器在本地處理,不會有任何交叉影響,讓我們更容易著手實現。另外,后期改起來也很方便,因為在Pod看來都是一樣的,而且我們也不希望通過一個網絡插件來處理任何東西。
到這我們已經處理了出站方面的問題,我們有雙棧上網。接下來怎么處理入站端呢?負載均衡器。不考慮把它構建在核心編排系統中。編排系統應該專注于一件事:如果一個數據包的目的IP是Pod IP,就把這個數據包交付給Pod。
正好,這應該主要適合公有云的場景。廠商們也傾向于這樣的模型,這樣就可以把他們的負載均衡器產品賣給你了。好吧,你贏了,姑且先采取這樣的設計模型。不過我想要一個控制循環來控制負載均衡器,并將其與IPAM集成,這樣VPC就能明白如何將數據包路由到Pod IP。
這忽略了由物理機搭建集群的場景。但這也不是一件壞事,因為沒有一個放之四海而皆準的負載均衡器。如果我試圖給你一個負載均衡器,但它沒有完全按照你預想的工作。這說不定還會讓你抓狂,一氣之下裝起了Istio,這時我所討論到降低復雜性都是無用功了。
讓負載均衡器集中精力把一件事做好:如果要把數據包轉發給Pod,那就把數據包轉發給Pod。在遵循這一原則的前提下,你可以基于LVS、Nginx、無狀態、云廠商負載均衡服務、F5等來構建負載均衡器,你可以自由發揮。這里也許可以考慮提供幾個“默認”實現。對于負載均衡器這部分,我確實有很多想法,也許我設計的方案就挺合適。這里的關鍵是編排系統對負載均衡器如何實現毫不關心,只要能把數據包轉發到Pod上。
我沒有觸及IPv4 ingress的問題,主要是我認為這是負載均衡器該做的事情,讓它們各自用最合適的方式來解決問題。像Nginx這樣的代理型負載均衡器,只需要通過IPv6轉發后端就可以了,沒什么問題。無狀態的負載均衡器可以很容易地將IPv4地址轉換成IPv6,其間有個轉換標準。源地址為::fffff:1.2.3.4數據包到達Pod時,Pod可以將其轉回IPv4。或者干脆將其視為IPv6直接處理,這樣的處理方式就假定網絡中的地址都是IPv6。如果使用了無狀態翻譯方式,出站的時候需要有狀態的跟蹤機制,來映射回IPv4。但這也比原先在IPv4下采取的層層封裝方式來得好。從節點的視角來看,這完全可以通過一條額外的::fffff:0000:0000/96的路由來處理。
將極簡貫徹到底
作為上述所有網絡問題的替代方案,我們干脆都不要了。回到Borg式的端口分配,所有服務都在主機的網絡命名空間中運行,并且必須請求分配端口。不是監聽:80,而是監聽:%port%,然后編排系統會用一個未使用的端口號來代替。例如,最終會變成監聽主機上的:53928。
這樣的設計真的非常非常簡單。簡單到基本沒什么需要額外做的。在分配端口時,需要做一些煩人的檢查,以避免端口沖突,這倒是一個令人頭疼的問題。還有一個端口耗盡的問題,因為如果你的客戶端非常活躍且數量不少,65000個端口其實并不算太多。但這個真的非常非常簡潔。我個人崇尚簡潔。
我們也可以采用經典的Docker方式,將其和上面的設計結合起來:容器在自己的網路命名空間中運行,使用一些臨時的私有 IP。你可以使用任意的端口,但對其他Pod和外部可見的只有那些告知運行時要暴露的端口。而且你只能聲明要暴露的容器端口,映射到主機上的端口是由容器運行時選擇的。(這里也可以留一些后門來應對特例,你可以告訴系統你非要80端口不可,然后通過調度約束來起作用,調度到80端口沒被占用的機器——類似于當前Kubernetes在這方面的處理。)
上面論述的關鍵點是,這些設計極大地簡化了網絡層。以至于我可以在短短幾分鐘內向別人解釋清楚,確保他們能夠了解其工作機制。
缺點是這把復雜性推給了服務發現。你不能使用“純粹的DNS”作為發現機制,因為大多數DNS客戶端不解析SRV記錄,因此不會動態發現隨機端口。
搞笑的是,服務網格的逐漸普及讓這個問題不再是個問題。因為人們現在假設存在一個本地智能代理,它可以做任何服務發現能做的事情,并將其映射到一個網絡視圖上,而這個視圖只被需要它的 Pod 看到。不過,我不太愿意接受這種做法,因為服務網格增加了太多的復雜性和維護成本,所以我不想采用它們……至少在有實踐方案表明能使它們良好運行之前,我維持這樣的觀點。
所以,我們不妨做一些類似服務網格的東西,但更簡單點。在源主機上做一些自動的IP端口轉換……不過這看起來很像kube-proxy,這隨之而來的就是復雜性和調試困難(這不是一個通過在不同的地方執行tcpdump就能解決的問題,因為流量會在不同的跳數之間變化)。
所以,這個方案也表明顯式主機端口映射可能也算個解決方案,但仍存在很多隱藏的復雜性(我相信這就是為什么Kubernetes一開始就采用單Pod單IP的原因)。Borg通過強制規定解決了這種復雜性,它規定了應用都必須用自家設計的依賴庫和框架。所以這里有個顯而易見的缺點,不能隨意更換的服務發現和負載平衡的實現框架。除非我們采用真正的服務網格,否則做不到這點。
本節描述的方案還有可改善之處,但我更傾向于上一節的實現。它的設計時要考慮的東西更多一些,但可以得到是一個可組合、可調試、可理解的系統,不需要無限制地增加功能以滿足新需求。
安全同樣重要
長篇大論的探討完網絡之后,來簡單說一下安全問題。容器默認應該被最大限度的沙盒化,并需要顯式的雙重確認步驟。
我們可以直接應用Jessie Frazelle在容器安全方面出色的工作成果。打開默認的apparmor和seccomp配置文件。不允許在容器中以root身份運行。使用user命名空間進行隔離,這樣哪怕有人設法在容器中升級為root,那也不是系統root。默認阻止所有設備掛載。不允許主機綁定掛載。為了達到效果,寫一個你能想的的最嚴格的Pod安全策略,然后把他們作為默認值,并且讓它們很難背離默認值。
Pod安全策略與此相當接近,因為它們強制執行雙重確認:集群運維人員確認允許用戶做不安全的操作,而用戶必須顯式申請權限執行不安全的操作。遺憾的是,Kubernetes現有的設計并沒有這么考慮。這里我們先不關心向下的兼容性,把默認值做得盡可能安全。
(溫馨提示:從這里開始,章節內容開始有點天馬行空。這些都是我想要的設計,不過我強烈意識到,很多細節沒有考慮清楚。)
gVisor?Firecracker?
說到默認情況下的最高級別的安全,我覺得不妨采取更激進的沙盒化措施。可以考慮將gVisor或Firecracker作為默認容器沙箱,并開啟雙重確認機制,最終達到“與主機共享內核的最安全的容器環境”這目的?
這里需要再斟酌斟酌。一方面,這些工具所承諾的極度安全非常吸引我。另一方面,這也不可避免地要運行一大堆額外的代碼,也帶來潛在漏洞和復雜性。而且這些沙盒對你能做的事情進行了極度的限制。甚至,任何與存儲有關的事情都會演變成“不,你不能有任何存儲”。這對于某些場景而言來說是不錯,但把它變成默認值就限制得太過分了。
至少在存儲方面,virtio-fs的成熟會解決很多這樣的問題,能讓這些沙盒在不破壞安全模型的前提下,執行有效地綁定和掛載操作。可能我們應該在那個時候再重新審視這個決定?
去中心化集群
我猜這個時髦的術語應該是“邊緣計算”,但我真正的意思是,我想讓我所有的服務器都在一個編排系統下,把它們作為一個單元來運作。這意味著我家里服務器機架上的計算機,我在DigitalOcean上的虛擬機,以及在互聯網上的其他幾臺服務器。這些都應該是集群內基本等效的一部分,實際上也具備這樣的能力。
這就導致了幾個與Kubernetes不一樣的地方。首先,工作節點應該設計得比當前更加獨立,對控制節點的依賴更少。可以在沒有控制節點的情況下長時間(極端情況下是幾周)運行,只要沒有機器故障,導致需要Pod重新調度。
我認為主要的轉變是將更多的數據同步節點上,并存到持久化存儲中。這樣節點自身就有了恢復正常運行所需要的數據,和主節點失聯后也能從冷啟動中恢復到可響應狀態。理論上,我希望集群編排系統在每個節點上填充一組systemd單元,在節點的運行過程中扮演一個被動管理的角色。它在本地擁有它需要的一切,除非這些東西需要改變,否則節點是獨立于管理節點的。
這確實導致了如何處理節點失聯的問題。在“中心化”的集群中,這是觸發工作負載重新調度的關鍵信號,但在去中心化的情況下,我更有可能會說“別擔心,這可能是短暫的失聯,節點很快就會回來”。所以,節點生命周期以及它與Pod生命周期的關系將不得不改變。也許你必須顯式聲明你是否希望Pod是“高可用”(即當節點失聯時主動地重新調度)或 “盡力”(只有當系統確定一個節點已經掛了并無法恢復時才重新調度)。
一種說法是,在我設計的“去中心化”集群中,Pod的表現更像是“獨一無二的寵物”而不是“牧場里的羊群”。我會考慮設計類似無狀態應用水平擴展的機制,但與Kubernetes不同,在這個場景下,當應用副本數縮小到一個時,我可以干預這一個應用運行在哪個節點上。這是Kubernetes不鼓勵的做法,所以此處我們不得不背離Kubernetes的某些做法。
另一種觀點是,將集群聯邦視為一級對象。實際上可以把分散的機器看成是單獨的集群,各自有自己的控制平面,然后將它們整合作為一個超大型集群來使用。這當然可以,并且回答了一些關于如何將節點與控制平面解耦的問題(我個人的答案:不要嘗試這么做,應當將控制平面的功能盡可能地下放到數量龐大的工作節點)。在這種情況下,我希望控制平面是極其精簡的,否則在Kubernetes中這樣做的開銷會很大,我個人希望避免這種情況。
這也提高了網絡部分的難度,因為我們現在必須跨網連接。我的做法是以某種方式去和Tailscale集成,這剛好解決我們需求。也可以選擇需要一些更定制化的、組件更少的方案(不要進行多余的NAT轉換)。
納管虛擬機
注意:當我在這里說虛擬機的時候,我并不是指“用戶在Kubernetes上運行的Pod”。我指的是管理員自己創建的hypervisor的服務器虛擬機。類似Proxmox或ESXi創建出來的,但不是EC2這種托管的。
我希望我的編排系統能夠無縫地管理容器和虛擬機。否則,在實踐中,我將需要一個單獨的hypervisor,那樣一來我將有兩套的管理系統。
我不確定這究竟會成為一種怎樣的設計,只是一個粗略的想法。kubevirt提供的功能應該內置到系統中,并成為系統關鍵的一部分,就像容器一樣。這是一個相當龐大的問題,因為這可能意味著從“讓我運行一個帶有虛擬軟盤的系統”到“運行一個看起來和感覺都有點像EC2的管理程序”,這是非常不同的兩件事。我唯一確定的是,我不希望運行同時運行Proxmox和這套編排系統,但我確實需要同時擁有虛擬機和容器。
如何存儲?
在我當前的設想中,存儲是一個巨大未知數。我缺乏充足的經驗,沒有太多獨到的見解。我覺得CSI太復雜了,應當精簡,但除了上面提到的,與生命周期工作流程有關的那一小部分,我也沒有好的想法可以提出來。存儲是我目前唯一個想保留目前Kubernetes插件化設計的模塊,不過一旦我對這方面的知識了解到位,我可能會有不同想法。
最后
寫到這里,文章的內容很多,我相信我可能遺漏了一些我一開始想解決的問題或是一些古怪的想法。但是,如果我明天就要著手替換Kubernetes,上面列的幾點應該是我一定要改的地方。
我沒有過多提及這個行業內的其他玩家——Hashicorp的Nomad,Facebook的Twine,Google的Borg和Omega,Twitter的Mesos。除了Borg之外,我還沒有實踐過其它方案,無法對其有深刻見解。如果要著手開發一個全新的Kubernetes,我一定先投入更多的時間去了解清楚這些競品,這樣我就可以取其精華,去其糟粕。我也會對Nix進行深入的思考,好好想想如何把它糅合到我的設計中。
老實說,我可能也只是想想而已,什么也沒實踐。我從Borg上學到了很多關于云計算理念的精髓,而Kubernetes也促使我進行了反思。我目前依舊相信,最好的容器編排系統就是沒有容器編排系統,而這種努力將不惜一切代價避免Kubernetes各種坑。顯然,這個想法與構建容器編排系統是格格不入的。