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


