做者:吳葉磊git
雲原生時代以降,無狀態應用以其天生的可替換性率先成爲各種編排系統的寵兒。以 Kubernetes 爲表明的編排系統可以充分利用雲上的可編程基礎設施,實現無狀態應用的彈性伸縮與自動故障轉移。這種基礎能力的下沉無疑是對應用開發者生產力的又一次解放。 然而,在輕鬆地交付無狀態應用時,咱們應當注意到,狀態自己並無消失,而是按照各種最佳實踐下推到了底層的數據庫、對象存儲等有狀態應用上。那麼,「負重前行」的有狀態應用是否能充分利雲與 Kubernetes 的潛力,複製無狀態應用的成功呢?github
或許你已經知道,Operator 模式已經成爲社區在 Kubernetes 上編排有狀態應用的最佳實踐,腳手架項目 KubeBuilder 和 operator-sdk 也已經愈發成熟,而對磁盤 IO 有嚴苛要求的數據庫等應用所必須的 Local PV(本地持久卷)也已經在 1.14 中 GA。這些積木彷佛已經足夠搭建出有狀態應用在平穩運行在 Kubernetes 之上這一和諧景象。然而,書面上的最佳實踐與生產環境之間還有無數工程細節造就的鴻溝,要在 Kubernetes 上可靠地運行有狀態應用仍須要至關多的努力。下面我將以 TiDB 與 Kubernetes 的「愛恨情仇」爲例,總結有狀態應用走向雲原生的工程最佳實踐。web
首先讓咱們先熟悉熟悉研究對象。TiDB 是一個分佈式的關係型數據庫,它採用了存儲和計算分離的架構,而且分層十分清晰:數據庫
其中 TiDB 是 SQL 計算層,TiDB 進程接收 SQL 請求,計算查詢計劃,再根據查詢計劃去查詢存儲層完成查詢。編程
存儲層就是圖中的 TiKV,TiKV 會將數據拆分爲一個個小的數據塊,好比一張 1000000 行的表,在 TiKV 中就有可能被拆分爲 200 個 5000 行的數據塊。這些數據塊在 TiKV 中叫作 Region,而爲了確保可用性, 每一個 Region 都對應一個 Raft Group,經過 Raft Log 複製實現每一個 Region 至少有三副本。api
而 PD 則是集羣的大腦,它接收 TiKV 進程上報的存儲信息,並計算出整個集羣中的 Region 分佈。藉由此,TiDB 便能經過 PD 獲知該如何訪問某塊數據。更重要的是,PD 還會基於集羣 Region 分佈與負載狀況進行數據調度。好比,將過大的 Region 拆分爲兩個小 Region,避免 Region 大小因爲寫入而無限擴張;將部分 Leader 或數據副本從負載較高的 TiKV 實例遷移到負載較低的 TiKV 實例上,以最大化集羣性能。這引出了一個頗有趣的事實,也就是 TiKV 雖然是存儲層,但它能夠很是簡單地進行水平伸縮。這有點意思對吧?在傳統的存儲中,假如咱們經過分片打散數據,那麼加減節點數每每須要從新分片或手工遷移大量的數據。而在 TiKV 中,以 Region 爲抽象的數據塊遷移可以在 PD 的調度下徹底自動化地進行,而對於運維而言,只管加機器就好了。網絡
瞭解有狀態應用自己的架構與特性是進行編排的前提,好比經過前面的介紹咱們就能夠概括出,TiDB 是無狀態的,PD 和 TiKV 是有狀態的,它們三者均能獨立進行水平伸縮。咱們也能看到,TiDB 自己的設計就是雲原生的——它的容錯能力和水平伸縮能力可以充分發揮雲基礎設施提供的彈性,既然如此,雲原生「操做系統」 Kubernetes 不正是雲原生數據庫 TiDB 的最佳載體嗎?TiDB Operator 應運而生。架構
Operator 你們都很熟悉了,目前幾乎每一個開源的存儲項目都有本身的 Operator,好比鼻祖 etcd-operator 以及後來的 prometheus-operator、postgres-operator。Operator 的靈感很簡單,Kubernetes 自身就用 Deployment、DaemonSet 等 API 對象來記錄用戶的意圖,並經過 control loop 控制集羣狀態向目標狀態收斂,那麼咱們固然也能夠定義本身的 API 對象,記錄自身領域中的特定意圖,並經過自定義的 control loop 完成狀態收斂。app
在 Kubernetes 中,添加自定義 API 對象的最簡單方式就是 CustomResourceDefinition(CRD),而添加自定義 control loop 的最簡單方式則是部署一個自定義控制器。自定義控制器 + CRD 就是 Operator。具體到 TiDB 上,用戶能夠向 Kubernetes 提交一個 TidbCluster 對象來描述 TiDB 集羣定義,假設咱們這裏描述說「集羣有 3 個 PD 節點、3 個 TiDB 節點和 3 個 TiKV 節點」,這是咱們的意圖。 而 TiDB Operator 中的自定義控制器則會進行一系列的 Kubernetes 集羣操做,好比分別建立 3 個 TiKV、TiDB、PD Pod,來讓真實的集羣符合咱們的意圖。運維
TiDB Operator 的意義在於讓 TiDB 可以無縫運行在 Kubernetes 上,而 Kubernetes 又爲咱們抽象了基礎設施。所以,TiDB Operator 也是 TiDB 多種產品形態的內核。對於但願直接使用 TiDB Operator 的用戶, TiDB Operator 能作到在既有 Kubernetes 集羣或公有云上開箱即用;而對於不但願有太大運維負載,又需求一套完整的分佈式數據庫解決方案的用戶,咱們則提供了打包 Kubernetes 的 on-premise 部署解決方案,用戶能夠直接經過方案中打包的 GUI 操做 TiDB 集羣,也能經過 OpenAPI 將集羣管理能力接入到本身現有的 PaaS 平臺中;另外,對於徹底不想運維數據庫,只但願購買 SQL 計算與存儲能力的用戶,咱們則基於 TiDB Operator 提供託管的 TiDB 服務,也即 DBaaS(Database as a Service)。
多樣的產品形態對做爲內核的 TiDB Operator 提出了更高的要求與挑戰——事實上,因爲數據資產的寶貴性和引入狀態後帶來的複雜性,有狀態應用的可靠性要求與運維複雜度每每遠高於無狀態應用,這從 TiDB Operator 所面臨的挑戰中就可見一斑。
描繪架構老是讓人以爲美好,而生產中的實際挑戰則將咱們拖回現實。
TiDB Operator 的最大挑戰就是數據庫的場景極其嚴苛,大量用戶的期盼都是個人數據庫可以「永不停機」,對於數據不一致或丟失更是零容忍。不少時候你們對於數據庫等有狀態應用的可用性要求甚至是高於承載線上服務的 Kubernetes 集羣的,至少線上集羣宕機還能補救,而數據一旦出問題,每每意味着巨大的損失和補救成本,甚至有可能「回天乏術」。這自己也會在很大程度上影響你們把有狀態應用推上 Kubernetes 的信心。
第二個挑戰是編排分佈式系統這件事情自己的複雜性。Kubernetes 主導的 level driven 狀態收斂模式雖然很好地解決了命令式編排在一致性、事務性上的種種問題,但它自己的心智模型是更爲抽象的,咱們須要考慮每一種可能的狀態並針對性地設計收斂策略,而最後的實際狀態收斂路徑是隨着環境而變化的,咱們很難對整個過程進行準確的預測和驗證。假如咱們不能有效地控制編排層面的複雜度,最後的結果就是沒有人能拍胸脯保證 TiDB Operator 可以知足上面提到的嚴苛挑戰,那麼走向生產也就無從談起了。
第三個挑戰是存儲。數據庫對於磁盤和網絡的 IO 性能至關敏感,而在 Kubernetes 上,最主流的各種網絡存儲很難知足 TiDB 對磁盤 IO 性能的要求。假如咱們使用本地存儲,則不得不面對本地存儲的易失性問題——磁盤故障或節點故障都會致使某塊存儲不可用,而這兩種故障在分佈式系統中是屢見不鮮。
最後的問題是,儘管 Kubernetes 成功抽象了基礎設施的計算能力與存儲能力,但在實際場景的成本優化上考慮得不多。對於公有云、私有云、裸金屬等不一樣的基礎設施環境,TiDB Operator 須要更高級、特化的調度策略來作成本優化。你們也知道,成本優化是沒有盡頭的,而且每每伴隨着一些犧牲,怎麼找到優化過程當中邊際收益最大化的點,一樣也是很是有意思的問題之一。
其中,場景嚴苛能夠做爲一個前提條件,而針對性的成本優化則不夠有普適性。咱們接下來就從編排和存儲兩塊入手,從實際例子來看 TiDB 與 TiDB Operator 如何解決這些問題,並推廣到通常的有狀態應用上。
TiDB Operator 須要驅動集羣向指望狀態收斂,而最簡單的驅動方式就是建立一組 Pod 來組成 TiDB 集羣。經過直接操做 Pod,咱們能夠自由地控制全部編排細節。舉例來講,咱們能夠:
經過替換 Pod 中容器的 image 字段完成原地升級。
自由決定一組 Pod 的升級順序。
自由下線任意 Pod。
事實上咱們也確實採用過徹底操做 Pod 的方案,可是當真正推動該方案時咱們才發現,這種徹底「本身造輪子」的方案不只開發複雜,並且驗證成本很是高。試想,爲何你們對 Kubernetes 的接受度愈來愈高, 即便是傳統上較爲保守的公司如今也勇於擁抱 Kuberentes?除了 Kubernetes 自己項目素質過硬以外,更重要的是有整個社區爲它背書。咱們知道 Kubernetes 已經在各類場景下經受過大量的生產環境考驗,這種信心是各種測試手段都無法給到咱們的。回到 TiDB Operator 上,選擇直接操做 Pod 就意味着咱們拋棄了社區在 StatefulSet、Deployment 等對象中沉澱的編排經驗,隨之帶來的巨大驗證成本大大影響了整個項目的開發效率。
所以,在目前的 TiDB Operator 項目中,你們能夠看到控制器的主要操做對象是 StatefulSet。StatefulSet 可以知足有狀態應用的大部分通用編排需求。固然,StatefulSet 爲了作到通用化,作了不少沒必要要的假設,好比高序號的 Pod 是隱式依賴低序號 Pod 的,這會給咱們帶來一些額外的限制,好比:
StatefulSet 和 Pod 的抉擇,最終是靈活性和可靠性的權衡,而在 TiDB 面臨的嚴苛場景下,咱們只有先作到可靠,才能作開發、敢作開發。最後的選擇天然就呼之欲出——StatefulSet。固然,這裏並非說,使用基於高級對象進行編排的方案要比基於 Pod 進行編排的方案更好,只是說咱們在當時認爲選擇 StatefulSet 是一個更好的權衡。固然這個故事尚未結束,當咱們基於 StatefulSet 把初版 TiDB Operator 作穩定後,咱們正在接下來的版本中開發一個新的對象來水平替換 StatefulSet,這個對象可使用社區積累的 StatefulSet 測試用例進行驗證,同時又能夠解除上面提到的額外限制,給咱們提供更好的靈活性。 假如你也在考慮從零開始搭建一個 Operator,或許也能夠參考「先基於成熟的原生對象快速迭代,在驗證了價值後再加強或替換原生對象來解決高級需求」這條落地路徑。
接下來的問題是控制器如何協調基礎設施層的狀態與應用層的狀態。舉個例子,在滾動升級 TiKV 時,每次重啓 TiKV 實例前,都要先驅逐該實例上的全部 Region Leader;而在縮容 TiKV 時,則要先在 PD 中將待縮容的 TiKV 下線,等待待縮容的 TiKV 實例上的 Region 所有遷移走,PD 認爲 TiKV 下線完成時,再真正執行縮容操做調整 Pod 個數。這些都是在編排中協調應用層狀態的例子,咱們能夠怎麼作自動化呢?
你們也注意到了,上面的例子都和 Pod 下線掛鉤,所以一個簡單的方案就經過 container lifecycle hook,在 preStop 時執行一個腳本進行協調。這個方案碰到的第一個問題是缺少全局信息,腳本中沒法區分當前是在滾動升級仍是縮容。固然,這能夠經過在腳本中查詢 apiserver 來繞過。更大的問題是 preStop hook 存在 grace period,kubelet 最多等待 .spec.terminationGracePeriodSeconds 這麼長的時間,就會強制刪除 Pod。對於 TiDB 的場景而言,咱們更但願在自動的下線邏輯失敗時進行等待並報警,通知運維人員介入,以便於最小化影響,所以基於 container hook 來作是不可接受的。
第二種方案是在控制循環中來協調應用層的狀態。好比,咱們能夠經過 partition 字段來控制 StatefulSet 升級進度,並在升級前確保 leader 遷移完畢,以下圖所示:
在僞代碼中,每次咱們由於要將全部 Pod 收斂到新版本而進入這段控制邏輯時,都會先檢查下一個要待升級的 TiKV 實例上 leader 是否遷移完畢,直到遷移完畢纔會繼續往下走,調整 partition 參數,開始升級對應的 TiKV 實例。縮容也是相似的邏輯。但你可能已經意識到,縮容和滾動更新兩個操做是有可能同時出如今狀態收斂的過程當中的,也就是同時修改 replicas 和 image 字段。這時候因爲控制器須要區分縮容與滾動更新,諸如此類的邊界條件會讓控制器愈來愈複雜。
第三種方案是使用 Kubernetes 的 Admission Webhook 將一部分協調邏輯從控制器中拆出來,放到更純粹的切面當中。針對這個例子,咱們能夠攔截 Pod 的 Delete 請求和針對上層對象的 Update 請求,檢查縮容或滾動升級的前置條件,假如不知足,則拒絕請求並觸發指令進行協調,好比驅逐 leader,假如知足,那麼就放行請求。控制循環會不斷下發指令直到狀態收斂,所以 webhook 就相應地會不斷進行檢查直到條件知足,以下圖所示:
這種方案的好處是咱們把邏輯拆分到了一個與控制器垂直的單元中,從而能夠更容易地編寫業務代碼和單元測試。固然,這個方案也有缺點,一是引入了新的錯誤模式,處理 webhook 的 server 假如宕機,會形成集羣功能降級;二是該方案適用面並不廣,只能用於狀態協調與特定的 Kubernetes API 操做強相關的場景。在實際的代碼實踐中,咱們會按照具體場景選擇方案二或方案三,你們也能夠到項目中一探究竟。
上面的兩個例子都是關於如何控制編排邏輯複雜度的,關於 Operator 的各種科普文中都會用一句「在自定義控制器中編寫領域特定的運維知識」將這一部分輕描淡寫地一筆帶過,而咱們的實踐告訴咱們,真正編寫生產級 的自定義控制器充滿挑戰與抉擇。
接下來是存儲的問題。咱們不妨看看 Kubernetes 爲咱們提供了哪些存儲方案:
其中,本地臨時存儲中的數據會隨着 Pod 被刪除而清空,所以不適用於持久存儲。
遠程存儲則面臨兩個問題:
一般來講,遠程存儲的性能較差,這尤爲體如今 IOPS 不夠穩定上,所以對於磁盤性能有嚴格要求的有狀態應用,大多數遠程存儲是不適用的。
一般來講,遠程存儲自己會作三副本,所以單位成本較高,這對於在存儲層已經實現三副本的 TiDB 來講是沒必要要的成本開銷。
所以,最適用於 TiDB 的是本地持久存儲。這其中,hostPath 的生命週期又不被 Kubernetes 管理,須要付出額外的維護成本,最終的選項就只剩下了 Local PV。
Local PV 並不是免費的午飯,全部的文檔都會告訴咱們 Local PV 有如下限制:
數據易失(相比於遠程存儲的三副本)。
節點故障會影響數據訪問。
難以垂直擴展容量(至關一部分遠程存儲能夠直接調整 volume 大小)。
這些問題一樣也是在傳統的虛擬機運維場景下的痛點,所以 TiDB 自己設計就充分考慮了這些問題:
存儲層的這些關鍵特性是 TiDB 高效使用 Local PV 的前提條件,也是 TiDB 水平伸縮的關鍵所在。固然,在發生節點故障或磁盤故障時,因爲舊 Pod 沒法正常運行,咱們須要自定義控制器幫助咱們進行恢復,及時補齊實例數,確保有足夠的健康實例來提供整個集羣所需的存儲空間、計算能力與 IO 能力。這也就是自動故障轉移。
咱們先看一看爲何 TiDB 的存儲層不能像無狀態應用或者使用遠程存儲的 Pod 那樣自動進行故障轉移。假設下圖中的節點發生了故障,因爲 TiKV-1 綁定了節點上的 PV,只能運行在該節點上,所以 在節點恢復前,TiKV-1 將一直處於 Pending 狀態:
此時,假如咱們可以確認 Node 已經宕機而且短時間沒法恢復,那麼就能夠刪除 Node 對象(好比 NodeController 在公有上會查詢公有云的 API 來刪除已經釋放的 Node)。此時,控制器經過 Node 對象不存在這一事實理解了 Node 已經沒法恢復,就能夠直接刪除 pvc-1 來解綁 PV,並強制刪除 TiKV-1,最終讓 TiKV-1 調度到其它節點上。固然,咱們同時也要作應用層狀態的協調,也就是先在 PD 中下線 TiKV-1,再將新的 TiKV-1 做爲一個新成員加入集羣,此時,PD 就會通知 TiKV-1 建立 Region 副原本補齊集羣中的 Region 副本數。
固然,更多的狀況下,咱們是沒法在自定義控制器中肯定節點狀態的,此時就很難針對性地進行原地恢復,所以咱們經過向集羣中添加新 Pod 來進行故障轉移:
上面講的是 TiDB 特有的故障轉移策略,但其實能夠類推到大部分的有狀態應用上。好比對於 MySQL 的 slave,咱們一樣能夠經過新增 slave 來作 failover,而在 failover 時,咱們一樣也要作應用層的一些事情, 好比說去 S3 上拉一個全量備份,再經過 binlog 把增量數據補上,當 lag 達到可接受的程度以後開始對外提供讀服務。所以你們就能夠發現,對於有狀態應用的 failover 策略是共通的,也都須要應用自己支持某種 failover 形式。好比對於 MySQL 的 master,咱們只能經過 M-M 模式作必定程度上的 failover,並且還會損失數據一致性。這固然不是 Kubernetes 或雲原生自己有什麼問題,而是說 Kubernetes 只是改變了應用的運維模式,但並不能影響應用自己的架構特性。假如應用自己的設計就不是雲原生的,那隻能從應用自己去解決。
經過 TiDB Operator 的實踐,咱們有如下幾條總結:
Operator 自己的複雜度不可忽視。
Local PV 能知足高 IO 性能需求,代價則是編排上額外的複雜度。
應用自己必須邁向雲原生(meets kubernetes part way)。
最後,言語的描述老是不如代碼自己來得簡潔有力,TiDB Operator 是一個徹底開源的項目,眼見爲實,你們能夠盡情到 項目倉庫 中拍磚,也歡迎你們加入社區一塊兒玩起來,期待你的 issue 和 PR!
假如你對於文章有任何問題或建議,或是想直接加入 PingCAP 鼓搗相關項目,歡迎經過個人郵箱 wuyelei@pingcap.com 聯繫我。
本文爲吳葉磊在 2019 QCon 全球軟件開發大會(上海)上的專題演講實錄,Slides 下載地址。