字節跳動有狀態應用雲原生實踐

本文主要介紹了字節跳動有狀態應用雲原生化過程中在狀態管理、基礎能力增強、自動化運維等方面的挑戰,以及有狀態應用雲原生化之後的收益。

作者:趙鵬,字節跳動基礎架構團隊高級研發工程師

編輯:Sarah (K8sMeetup)

背景介紹

說起有狀態應用,要從無狀態服務講起。無狀態是指應用的實例可以平滑遷移、水平擴展,實例之間沒有顯著差別。這類服務在雲原生化過程中與 K8s(包括 Deployment)等對象配合得很好,因此成爲第一批雲原生受益者。

有狀態應用指持有特定的數據、並依賴其提供服務的應用,大規模場景中通常具備分片(Sharding)和多副本(Replica)、數據持久化等特點。有狀態應用又分爲數據有狀態和網絡有狀態。

網絡有狀態是數據有狀態之外的一種形態,本文分享的內容主要圍繞數據有狀態應用在字節的落地展開。

K8sMeetup

有狀態應用業務場景

字節內部大量應用了有狀態應用。一些常見的場景有:

在雲原生化之前,服務多是通過物理機部署的。物理機時代的架構複雜、運維不夠靈活敏捷、物理機環境不一致、資源碎片化等問題一直沒有得到很好的解決。這也正是雲原生化關注的痛點,字節對雲原生的理解體現在效率和成本兩方面。

效率

成本

當然雲原生化這條路也不是一帆風順的,在有狀態應用的狀態管理、基礎能力增強和自動化運維等方面都存在一些挑戰,在此過程中我們也解決了很多相關技術問題。

總體來說,在內部 K8s 基座上我們通過編排的優化(包括 CRD、Controller、webhook 等能力)以及在基礎能力方面的增強(包括性能優化、存儲能力的增強),已經承接了內部上千個有狀態服務,覆蓋 2w + 節點,100w+ CPU Core,5w+ Pod。

狀態管理

有狀態應用的狀態管理可以拆分成三個問題:

這裏我先舉個例子。假設我們有個自研的海量 KV 服務,由於數據量比較大,單個實例無法承擔這麼大數據量。我們首先要把數據拆分成多個 Shard,每個 Shard 根據 Key 的哈希值取模,在一個 Shard 內部對應的 Pod 負責一部分的數據對外提供服務。同時爲了保證高可用性,一個 Shard 內有多個 Pod 副本,它們之間可能會有主備關係。所以,對於這種有狀態應用,可以把其全部實例展開形成一個矩陣,矩陣的每一列就是負責對外提供同一個 Shard 服務的多個 Pod 副本。

此外,有狀態應用對外部的數據比較敏感,在實例副本不變的情況下,數據依然有可能發生更新。比如這個 KV 服務需要每小時加載最新的數據版本,對外提供這個版本的數據 serving。

對應剛纔的例子,可以把上述所有實例形成的矩陣與有狀態服務的抽象 SolarService 一一對應起來。剛纔說的矩陣的一列,就是若干個 Pod 對應一個的 Shard,是對應到上圖的自研增強版 Statefulset ( Statefulset Extention )上,我們通過 CRD 的方式在 Statefulset 基礎上增強了原地升級(鏡像版本、環境變量更新)、升級順序的自定義、小流量 / 全流量的特性。

此外在服務副本不變的情況下,數據也需要進行輪換更新。數據管理是由另外一個 CRD (Budset) 完成的。Budset 和 Statefulset 是一一對應的關係,Budset Operator 會根據 Budset 定義生成若干個 CRD Bud(Bud 和 Pod 是一一對應的關係),表明這個 Pod 預期的數據狀態。此外,我們在每個 Pod 中注入一個 DataSync sidecar 容器,監聽自己 Pod 對應的 Bud,完成數據下載等動作並更新 Bud 的狀態。

SolarService 就是以上 StatefulsetExtension 和 Budset 兩者合併在一起構成的。

下面通過兩個例子介紹 SolarService  Controller 是怎麼工作的。

滾動升級

首先根據 Shard 進行橫向切分,多個 Shard 內部併發升級,Shard 的滾動粒度是可以配置的。在一個 Shard 裏面我們根據 Statefalset Extention 配置的 MaxUnavailable ,併發升級一個 Shard 內的多個副本。

擴容

擴容分爲兩種情況:

假設一開始只有全量數據,全都存在 Budset 1。Statefulset Extention 1 裏的 Pod 全都加載了 Budset 1 的數據。做成倍擴展的時候,第一步是擴容 Statefulset。這時 Statefulset Extention 1、2 裏面數據都是全量數據。之後再來更新 Budset,原來全量數據會被切成兩個 Shard,這些過程都完成之後會再去更新服務發現,這個時候 Statefuleset Extention 2 的 Pod 纔會正式承接流量。

這時其實有一個問題:在 Budset 變更的時候,兩個 Statefulset Extention 的 Pod 裏的數據依然是全量的。這個時候我們跟業務框架有一些配合工作,有一些業務可能自己定義了數據退場 TTL 邏輯,這時只要等待數據冷卻就可以了。此外,還有些業務自定義觸發數據的 Compaction,把多餘的數據驅逐掉。

服務發現與路由

服務發現與路由包括兩個要點。前面的例子提到過,有狀態服務實例形成的矩陣中,每一列 Pod 對外提供不同 Data Shard 的數據服務。因此當一個請求來的時候,需要知道它是路由到哪個 Shard 的實例中。

圖中有一個 Proxy 業務層的組件,會統一分發請求,將請求分配給對應 Statefulset Extension 的 Pod。同時,同一個 Shard 裏面存在多個 Pod 副本,由於宿主機微小的性能差異或其他原因,它們的錯誤率也不是完全相等的。這裏就可以來做第二層路由邏輯,根據一個 Statefulset Extension 內 Pod 的錯誤率,進一步增強服務路由 / 熔斷邏輯。基於這種複雜的定製的邏輯,我們並沒有依賴 K8s Service 來對請求進行路由,而是通過自研的服務發現基礎設施註冊宿主機上的 IP 和端口,通過 KV 的方式寫到 Service Discovery 這個組件裏面。

針對有狀態服務,我們在 Service Discovery 組件裏面額外注入了 ShardID、ReplicaID 和 Shard 總數等信息,方便上層框架從 KV 裏讀取,制定自己的熔斷、路由的策略。

上圖展示的一個 Proxy 組件,是一種比較常見的服務形態:即把有狀態服務上面做一層封裝,完成路由轉發。此外,請求轉發其實也可以和 service mesh 進行進一步結合,通過胖客戶端的方式,上游服務自己路由每一個請求到對應的 Pod 裏面,以減少一層 Proxy 的開銷。

基礎能力增強

我們在基礎能力方面的增強主要包括調度存儲兩個方面。

調度

調度能力方面,爲了追求極致的性能優化,我們基於現代服務器的 NUMA 架構對 K8s 的 Scheduler  和 Kubelet 做了一些增強。

NUMA 指非均勻內存訪問架構,在一個多核處理器的標準架構中,CPU 訪問不同內存的延遲是不一樣的,一個處理器訪問本地的內存和相對遠的內存有延遲的差別。此外,不光是內存有這樣的特性,GPU 設備或網卡也有這樣的微拓撲親和性,通過將服務的 Pod 綁定在與 CPU 鄰近的內存 NUMA node 上,可以從系統層面極致優化服務器性能。

具體做法如下:

存儲

在存儲能力方面,我們通過 Dynamic Provison 的方式支持了多種存儲介質,同時也支持了遠程塊存儲和本地盤存儲的在線擴容。

遠程塊存儲

遠程塊存儲方案是基於 NBD 完成的標準 CSI 接口(在內部實現中去除了 attach 和 dettach 的過程),這種基於 NBD 的網絡設備目前支持兩種模式:單寫單讀多讀(共享只讀)。圖中的 External Provisioner 和另外一個在單機層面的 CSI plugin 這兩個組件是自研的,其他都是原生組件。

當 Statefulset Extention 創建出對應的 PVC 和 Pod 之後,External Provisoner 會監聽到 PVC create 這個事件,隨之 Provision 一個 PV 與 PVC 進行綁定。之後 Pod 到了單機層面,CSI driver 裏就會依次執行對應的 CSI 標準協議裏面 nodeserver 的函數,包括 node stage/publish volume 等。

本地盤存儲

首先補充一點關於社區的 Volume Scheduling 的背景。Volume Scheduling 是指調度器在選擇存儲卷的時候會對 Pod 存儲資源和計算資源(CPU、Memory 等)進行統一的管理和分配,Volume Scheduling 包括三個階段:

這也是我們本地卷存儲能夠做 Dynamic Provision 的關鍵。社區的 LPV 方案其實只有 Static Provisioning 這種形式,而內部的本地盤 LPV Dynamic Provision 的實現原理,就是在監聽調度器 Assume Volume 後,動態創建 PV 的。

具體來說:

  1. 不同於 External Provisioner,LPV Provisioner 和 Driver 是打包在一個 Binary 以 Daemon 的形式部署的,每個 Pod 會通過 CRD 的方式彙報當前節點存儲資源量。在 Pod 進入調度流程之後,通過自研 predicate 過濾每個節點剩餘可用的存儲資源,選擇可行的節點。

  2. LPV Provisioner 監聽調度器預分配到當前節點的 PVC,如果調度器進行一次 Assume Volume(更新 PVC annotattion),就嘗試創建一個 PV 和 PVC 進行綁定。如果創建 PV 失敗,就會把這個 PVC 調度器打的 annotation 清理掉,這個時候會觸發調度器重新進行調度。

內部本地存儲支持若干種存儲介質:

其中 AEP 是 Intel 新推出的非易失性存儲設備,性能遠遠勝於 SSD。AEP 有兩種使用方式:當做內存使用或當做磁盤使用。在我們的場景裏可以把 AEP 當做磁盤,在上面創建文件系統,通過 fsdax 方式掛載(因爲 AEP 設備本身的延遲已經和內存相當,就沒有必要通過操作系統 page cache 層產生額外的開銷),可直接寫到文件系統上去。

AEP 設備可以切分成若干個 namespace,可以理解爲若干個盤。從卷的角度來,AEP 看作爲一塊磁盤,其分配邏輯與 LPV 分配本地磁盤的過程是差不多的。但從設備角度來講,AEP 設備也有 NUMA 親和性分配的需求,也就是說在分配 CPU 內存的時候,要綜合考慮到設備的統一管理。K8s v1.16 推出了 Topology Manager 的特性,統一考慮了設備和 CPU 的近鄰性。我們通過擴展 Topology Manager Policy 完成了 CPU、內存、設備等多個角度的統籌分配,可以極致提高設備的性能。

監控與自動化運維

監控體系

我們自研了一個基於 eBPF 的容器級別系統監控組件 SysProbe,可採集宿主機包括容器在內的 100+ Metrics。此外,自研高可用 Metrics Aggregation Server(MAS)會不斷獲取 SysProbe 的 Metrics,對接多個下游 sink,比如 MQ、TSDB 等,爲用戶提供豐富的監控面板。

自動化運維

關於自動化運維,着重提一下我們在 PDB 方面做的事情。

相比於無狀態應用,有狀態應用對自動化運維提出了更高的要求:

爲了解決這些問題,我們通過 Pod Eviction(驅逐) 完成主機的運維。在宿主機下線之前,通過 K8s API 驅逐掉宿主機上的 Pod。之所以沒有使用 delete pod 接口的主要原因是,驅逐(Eviction)會檢查 K8s 的 PDB 資源,而我們就可以通過擴展 PDB (通過 webhook 的方式攔截 Evictions 請求) 自定義驅逐策略。

上圖介紹的是多機房驅逐的例子。一個 Region 裏的多個 AZ 可能有各自的 K8s 集羣,裏邊部署了等價的 Solarservice,隸屬於同一個服務。在進行驅逐的時候就要同時考慮圖中兩個 AZ 之間的實例比例關係,這樣不會導致一個 AZ 裏的 Pod 都被驅逐乾淨了,此 AZ 裏錯誤率飆升,但總數卻又符合要求的情況發生。具體做法是通過跨 AZ 的 Meta K8s 中以 CRD 形式保存我們的自定義策略 PDB Extension,來檢查驅逐是否合法。

CSI Race Condition

此外雲原生實踐過程中也遇到了很多 CSI 的問題。在刪除 Pod 時,原生 CSI 接口中有兩個相應的函數:

  1. NodeUnpublishVolume:調用 CSI 對應的 driver,以清除 Pod 對應的掛載點。

  2. NodeUnstageVolume:從節點上把卷卸載。

但是 Kubelet 刪除 Pod 時,只會判斷第一件事情是否完成。因此在短暫的時間窗口裏,如果有運維或其他情況發生,就可能會造成 race condition。

Global Mount 掛載點殘留

在這種情況下,執行完第一個函數清除了掛載點,但是卷還殘留在宿主機上。這時如果對 Kubelet 執行重啓,重啓之後的 Kubelet 發現 Pod 已經被刪除了,就只會看當前節點上還存活的 Pod 所使用的卷。那些未完成 unstage 的卷就不會被刪除。我們的解決方案是針對 fs 類型的卷,在 Kubelet Volume Manager 增加殘留掛載點掃描操作,清理殘留掛載點。

重複打開正在卸載的卷

這種情況也是發生在 Kubelet 刪除 Pod 後,NodeUnstageVolume 之前。如果一個 Pod 被刪除,沒有進行 unstageVolume,新的 Pod 已經創建出來,並且調度上其他節點上了,而且新的 Pod 需要掛載同一個卷,那麼從存儲側發現 Kubelet 正在嘗試重複掛載。例如,在前面提到的基於 NBD 的塊設備,一個單讀單寫的模式中,新的節點開始嘗試建立 NBD 連接了,舊的卷連接還保留着,那麼在存儲側服務端就會發現異常並報警。

Case Study

最後介紹幾個在對接過程中遇到的問題。前面介紹了 NBD 多塊盤共享宿主機的內核,一旦宿主機由於 NBD 不穩定出現故障,會影響整臺宿主機上所有的 Pod。因此我們也在積極嘗試基於 **Kata **的輕量級虛擬化方案,降低爆炸半徑,把故障範圍從宿主機粒度降低爲 Pod 粒度。

總結

在字節跳動雲原生化過程中,從無狀態應用逐漸進入到有狀態化應用的雲原生對接,有狀態應用一般有如下特點:

雲原生化的過程中,給有狀態應用帶來了效率和成本兩方面收益,解決了物理機時代運維有狀態應用的一些痛點:

關注公衆號:Knative,瞭解更多 Serverless 、Knative,雲原生相關資訊

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CI47T8l7vwooQAyi7s5CxA