螞蟻大規模 Sigma 叢集 Etcd 拆分實踐

語言: CN / TW / HK

文|杜克偉(花名:蘇麟 )

螞蟻集團高階開發工程師

負責螞蟻 Kubernetes 叢集的穩定性方面的工作 專注於叢集元件變更、穩定性風險保障

本文 15738 字 閱讀 20 分鐘

前 言

為了支撐螞蟻業務的迭代升級,螞蟻基礎設施今年啟動了 Gzone 全面雲化專案。要求 Gzone 需與已經雲化的 Rzone 合併部署在同一個叢集,Sigma 單叢集實際管理的節點規模將超過萬臺,單叢集承擔的業務也將更加複雜。

因此我們啟動了大規模 Sigma 叢集的效能優化方案,在請求延遲上期望能夠對齊社群標準,不因規模增長的原因下降。

etcd 作為 Sigma 叢集的資料儲存資料庫,是整個叢集的基石,能夠直接決定效能天花板。社群建議的單 etcd 叢集儲存限制是 8G, 而螞蟻 Sigma 叢集的單 etcd 叢集儲存量早已超過了這個限制,Gzone 上雲專案勢必會加重 etcd 的負擔。

首先,螞蟻業務混合了流失計算、離線計算和線上業務,混合大量的生命週期在分鐘級甚至是秒級的 Pod,單叢集每天的 Pod 建立量也提升到了數十萬, 都需要 etcd 來支撐;

其次,複雜的業務需求催生了大量的 List (list all、list by namespace、list by label)、watch、create、update、delete 請求,針對 etcd 的儲存特性,這些請求效能均會隨著 etcd 儲存規模的增大而嚴重衰減,甚至導致 etcd OOM,請求超時等異常;

最後,請求量的增長也加劇了 etcd 由於 compact、defrag 操作對請求 RT P99 的暴漲,甚至請求超時,從而導致叢集關鍵元件排程器、CNI 服務等 Operator 類元件間斷性丟失,造成叢集不可用。

根據前人的經驗,針對 etcd 叢集進行資料水平拆分是一個有效的優化手段,典型的拆分是把 Pod 等重要資料單獨 etcd 叢集來儲存,從而降低單 etcd 儲存和請求處理的壓力,降低請求處理延遲。但是 Pod 資源資料針對 Kubernetes 叢集具有特殊性,具有其他資源沒有的高要求,尤其是針對已頗具規模正在服務的 K8s 叢集進行拆分更是需要萬分謹慎小心。

本文主要記錄了螞蟻集團在進行 Pod 資源資料拆分過程中一些實踐經驗和心得。

拋磚引玉,請大家多多指教!

PART. 1 面臨的挑戰

從前人的 Pod 資料拆分經驗瞭解到,Pod 資料拆分是一個高危且複雜的流程,原因來自於 Pod 資料自身的特殊性。

Pod 是一組容器的組合,是 Sigma 叢集中可排程的最小單位,是業務 workload 的最終承載體。Sigma 叢集的最核心最終的交付資源就是 Pod 資源。

Sigma 叢集最核心的 SLO 也是 Pod 的建立刪除升級等指標。Pod 資源資料可以說是 Sigma 叢集最重要的資源資料。同時 Sigma 叢集又是由事件驅動的,面向終態體系設計,所以 Pod 資源資料拆分除了考慮基本的前後資料一致性問題外,還要考慮拆分過程中對其他元件的影響。

前人的拆分經驗流程中最核心的操作是資料完整性校驗和關鍵服務元件停機。資料完整性校驗顧名思義是為了保證資料前後的一致性,而關鍵服務元件停機是為了避免拆分過程中如果元件不停機造成的非預期後果,可能會有 Pod 非預期刪除,Pod 狀態被破壞等。但是如果照搬這套流程到螞蟻 Sigma 叢集,問題就來了。

螞蟻 Sigma 作為螞蟻集團核心的基礎設施,經過 2 年多的發展已經成為擁有 80+ 叢集、單叢集節點數可達到 1.2w+ 規模的雲底座。在如此規模的叢集上,執行著螞蟻內部百萬級別的 Pod,其中短執行時長 Pod 每天的建立量在 20w+次。為了滿足各種業務發展需求,Sigma 團隊與螞蟻儲存、網路、PaaS 等多個雲原生團隊合作,截止目前 Sigma 共建的第三方元件量已經達到上百個。如果 Pod 拆分要重啟元件,需要大量的與業務方的溝通工作,需要多人共同操作。如果操作不慎,梳理不完全漏掉幾個元件就有可能造成非預期的後果。

在這裡插入圖片描述

從螞蟻 Sigma 叢集現狀總結一下已有的 Pod 資料拆分經驗流程的問題:

  1. 人工操作大量元件重啟時間長、易出錯

潛在需要重啟的元件高達數十個,需要與各個元件 owner 進行溝通確認,梳理出需要重啟的元件,需要耗費大量的溝通時間。萬一遺漏就可能造成非預期的後果,比如資源殘留、髒資料等。

  1. 完全停機持續時間長打破 SLO

資料拆分期間元件完全停機,叢集功能完全不可用,且拆分操作極為耗時,根據前人經驗,持續時間可能長達 1~2 小時,完全打破了 Sigma 叢集對外的 SLO 承諾。

  1. 資料完整性校驗手段薄弱

拆分過程中使用 etcd 開源工具 make-mirror 工具來遷移資料,該工具實現比較簡單,就是讀取一個 etcd 的 key 資料然後重新寫到另一個 etcd,不支援斷點續傳,同時因重新寫入 etcd 造成原有 key 的重要欄位 revision 被破壞,影響 Pod 資料的 resourceVersion, 可能會造成非預期後果。關於 revision 後文會詳細說明。最後的校驗手段是檢驗 keys 的數量是否前後一致,如果中間 key 的資料被破壞,也無法發現。

PART. 2 問題解析

美好的期望

作為一個懶人,不想和那麼多的元件 owner 溝通重啟問題,大量元件重啟也易造成操作遺漏,造成非預期問題。同時是否有更好的資料完整性校驗的手段呢?

如果元件不重啟,那麼整個過程後演變為下面的流程,預期將簡化流程,同時保障安全性。

為了達成美好的期望,我們來追本溯源重新 review 整個流程。

在這裡插入圖片描述

資料拆分是在做什麼?

眾所周知,etcd 儲存了 Kubernetes 叢集中的各種資源資料,如 Pod、Services、Configmaps、Deployment 等等。

Kube-apiserver 預設是所有的資源資料都儲存在一套 etcd 叢集中,隨著儲存規模的增長,etcd 叢集會面臨效能瓶頸。以資源維度進行 etcd 的資料拆分來提升 Kube-apiserver 訪問 etcd 的效能是業內所共識的經驗優化思路,本質是降低單 etcd 叢集的資料規模,減少單 etcd 叢集的訪問 QPS。

針對螞蟻 Sigma 叢集自身的規模和需求,需拆分為 4 個獨立的 etcd 叢集,分別儲存 Pods、Leases、event 和其他資源資料,下面分別簡要說明這前三類(Pods、Lease、event)需要拆分出去的資源資料。

在這裡插入圖片描述

Event 資源

K8s event 資源資料並不是 watch 中的 event,一般是表示關聯物件發生的事件,比如 Pod 拉取映象,容器啟動等。在業務上一般是 CI/CD 需要流水式展示狀態時間軸,需要頻繁拉取 event 資源資料。

event 資源資料本身就是有效期的(預設是 2 小時),除了通過 event 觀測資源物件生命週期變化外,一般沒有重要的業務依賴,所以說 event 資料一般認為是可以丟棄,不需要保障資料前後一致性的。

因為上述的資料特點,event 的拆分是最為簡單的,只需要修改 APIServer 的啟動配置,重啟 APIServer 即可,不需要做資料遷移,也不需要做老舊資料的清理。整個拆分過程除了 Kube-apiserver 外,不需要任何元件的重啟或者修改配置。

Lease資源

Lease 資源一般用於 Kubelet 心跳上報,另外也是社群推薦的 controller 類元件選主的資源型別。

每個 Kubelet 都使用一個 Lease 物件進行心跳上報,預設是每 10s 上報一次。節點越多,etcd 承擔的 update 請求越多,節點 Lease 的每分鐘更新次數是節點總量的 6 倍,1 萬個節點就是每分鐘 6 萬次,還是非常可觀的。Lease 資源的更新對於判斷 Node 是否 Ready 非常重要,所以單獨拆分出來。

controller 類元件的選主邏輯基本上都是使用的開源的選主程式碼包,即使用 Lease 選主的元件都是統一的選主邏輯。Kubelet 的上報心跳的程式碼邏輯更是在我們掌控之中。從程式碼中分析可知 Lease 資源並不需要嚴格的資料一致性,只需要在一定時間內保障 Lease 資料被更新過,就不影響使用 Lease 的元件正常功能。

Kubelet 判斷 Ready 的邏輯是否在 controller-manager 中的時間預設設定是 40s,即只要對應 Lease 資源在 40s 內被更新過,就不會被判斷為 NotReady。而且 40s 這個時間可以調長,只要在這個時間更新就不影響正常功能。使用選主的 controller 類元件的選主 Lease duration 一般為 5s~65s 可以自行設定。

因此 Lease 資源拆分雖和 event 相比要複雜一些,但也是比較簡單的。多出來的步驟就是在拆分的過程中,需要把老 etcd 中的 Lease 資源資料同步到新的 etcd 叢集中,一般我們使用 etcdctl make-mirror 工具同步資料。此時若有元件更新 Lease 物件,請求可能會落在老 etcd,也可能落在新的 etcd 中。落在老 etcd 中的更新會通過 make-mirror 工具同步到新的 etcd 中,因為 Lease 物件較少,整個過程持續時間很短,也不會存在問題。另外還需要遷移拆分完成後,刪除老 etcd 中的 Lease 資源資料,以便釋放鎖佔用的空間,雖然空間很小,但也不要浪費。類似 event 資源拆分,整個拆分過程除了 kube-apiserver 外,同樣不需要任何元件的重啟或者修改配置。

Pod 資源

Pod 資源可能是我們最熟悉的資源資料了,所有的 workload 最終都是由 Pod 來真實承載。K8s 叢集的管理核心就在於 Pod 資源的排程和管理。Pod 資源資料要求嚴格的資料一致性,Pod 的任何更新產生的 watch event 事件,都不能錯過,否則就有可能影響 Pod 資源交付。Pod 資源的特點也正是導致傳統 Pod 資源資料拆分過程中需要大規模重啟相關元件的原因,後文會解析其中的原因。

社群 kube-apiserver 元件本身早已有按照資源型別設定獨立 etcd 儲存的配置--etcd-servers-overrides。

--etcd-servers-overrides strings 
Per-resource etcd servers overrides, comma separated. The individual override format: group/resource#servers, where servers are URLs, semicolon separated. Note that this applies only to resources compiled into this server binary.

我們常見的資源拆分的簡要配置示例如下:

events 拆分配置

--etcd-servers-overrides=/events#https://etcd1.events.xxx:2xxx;https://etcd2.events.xxx:2xxx;https://etcd3.events.xxx:2xxx

leases 拆分配置

--etcd-servers-overrides=[coordination.k8s.io/leases#https://etcd1.leases.xxx:2xxx;https://etcd2.leases.xxx:2xxx;https://etcd3.leases.xxx:2xxx](http://coordination.k8s.io/leases#https://etcd1.leases.xxx:2xxx;https://etcd2.leases.xxx:2xxx;https://etcd3.leases.xxx:2xxx)

pods 拆分配置

--etcd-servers-overrides=/pods#https://etcd1.pods.xxx.net:2xxx;https://etcd2.pods.xxx:2xxx;https://etcd3.pods.xxx:2xxx

重啟元件是必須的嗎?

為了瞭解重啟元件是否必須,如果不重啟元件有什麼影響。我們在測試環境進行了驗證,結果我們發現在拆分完成後,新建 Pod 無法被排程,已有 Pod 的無法被刪除,finalizier 無法摘除。經過分析後,發現相關元件無法感知到 Pod 建立和刪除事件。

那麼為什麼會出現這種問題呢?要回答這個問題,就需要從 K8s 整個設計核心理念到實現具體細節全部理清楚講透徹,我們細細道來。

如果 K8s 是一個普通的業務系統,Pod 資源資料拆分只是影響了 kube-apiserver 訪問 Pod 資源的儲存位置,也就是影響面只到 kube-apiserver 層面的話,就不會存在本篇文章了。

對於普通的業務系統來講,都會有統一的儲存訪問層,資料遷移拆分運維操作只會影響到儲存訪問層的配置而已,更上層的業務系統根本不會感知到。

但, K8s 就是不一樣的煙火!

在這裡插入圖片描述

K8s 叢集是一個複雜的系統,是由很多擴充套件元件相互配合來提供多種多樣的能力。

擴充套件元件是面向終態設計的。面向終態中主要有兩個狀態概念:期望狀態(Desired State) 和當前狀態(Current State), 叢集中的所有的物件(object) 都有一個期望狀態和當前狀態。

  • 期望狀態簡單來說就是我們向叢集提交的 object 的 Yaml 資料所描述的終態;

  • 當前狀態就是 object 在叢集中真實存在的狀態。

我們使用的 create、update、patch、delete 等資料請求都是我們針對終態做的修改動作,表達了我們對終態的期望,執行這些動作後,當前叢集狀態和我們的期望狀態是有差異的,叢集中的各個 Operators(Controllers)擴充套件元件通過兩者的差異進行不斷的調諧(Reconclie) , 驅動 object 從當前狀態達到最終狀態。

在這裡插入圖片描述

目前的 Operators 類元件基本上都是使用開源框架進行開發的,所以可以認為其執行元件的程式碼邏輯是一致統一的。在 Operator 元件內部,最終終態是通過向 kube-apiserver 傳送 List 請求獲取最終終態的 object yaml 資料,但為了降低 kube-apiserver 的負載壓力,在元件啟動時 List 請求只執行一次(如果不出現非預期錯誤),若終態資料 object yaml 在之後有任何變化則是通過 kube-apiserver 主動向 Operator 推送 event(WatchEvent)訊息。

從這點講也可以說 K8s 叢集是由 event 驅動的面向終態的設計。

在這裡插入圖片描述

而 Operator 和 kube-apiserver 之間的 WatchEvent 訊息流需要保障任何 event 都不能丟失, 最初的 List 請求返回的 yaml 資料,再加上 WatchEvent 的變更事件組合而成才是 Operator 應該看到的最終狀態,也是使用者的期望狀態。而保障事件不丟失的重要概念則是 resourceVersion。

叢集中的每個 object 都有該欄位,即使是使用者通過 CRD(CustomResourceDefinition) 定義的資源也是有的。

重點來了,上面提到的 resourceVersion 是與 etcd 儲存本身獨特特性(revision)息息相關的,尤其是針對 Operator 大量使用的 List 請求更是如此。資料的拆分遷移到新的 etcd 儲存叢集會直接影響到資源物件的 resourceVersion。

那麼問題又來了,etcd revision 是什麼?與 K8s 資源物件的 resourceVersion 又有什麼關聯呢?

Etcd 的 3 種 Revision

Etcd 中有三種 Revision,分別是 Revision、CreateRevision 和 ModRevision 下面將這三種 Revision 的關聯關係以及特點總結如下:

key-value 寫入或者更新時都會有 Revision 欄位,並且保證嚴格遞增, 實際上是 etcd 中 MVCC 的邏輯時鐘。

在這裡插入圖片描述

K8s ResourceVersion 與 Etcd Revision

每個從 kube-apiserver 輸出的 object 都必然有 resourceVersion 欄位,可用於檢測 object 是否變化及併發控制。

可從程式碼註釋中看到更多資訊:

// ObjectMeta is metadata that all persisted resources must have, which includes all objects
// users must create.
type ObjectMeta struct {  
    ...// omit code here
    // An opaque value that represents the internal version of this object that can
  // be used by clients to determine when objects have changed. May be used for optimistic
  // concurrency, change detection, and the watch operation on a resource or set of resources.
  // Clients must treat these values as opaque and passed unmodified back to the server.
  // They may only be valid for a particular resource or set of resources.
  //
  // Populated by the system.
  // Read-only.
  // Value must be treated as opaque by clients and .
  // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
  // +optional
  ResourceVersion string `json:"resourceVersion,omitempty" protobuf:"bytes,6,opt,name=resourceVersion"`
    ...// omit code here
}

kube-apiserver 的請求 verbs 中 create、update、 patch、delete 寫操作都會更新 etcd 中的 Revision,更嚴格的說,會引發 revision 的增長。

現將 K8s 中的 resource object 中的 resourceVersion 欄位與 etcd 中的各種 Revision 對應關係總結如下:

在這裡插入圖片描述

在所有的 kube-apiserver 請求響應中,需要特別注意 List 的響應。List 請求的 resourceVersion 是 etcd 的 Header.Revision, 該值正是 etcd 的 MVCC 邏輯時鐘,對 etcd 任何 key 的寫操作都是觸發 Revision 的單調遞增,接影響到 List 請求響應中的 resourceVersion的值。

舉例來說,即使是沒有任何針對 test-namespace 下面的 Pod 資源的修改動作,如果 List test-namespace 下面的 Pod,響應中的 resourceVersion 也很可能每次都會增長(因為 etcd 中其他 key 有寫操作)。

在我們的不停元件 Pod 資料拆分中,我們只禁止了 Pod 的寫操作,其他資料並未禁止,在 kube-apiserver 配置更新滾動生效過程中,勢必會造成 old etcd 的 Revision 要遠大於儲存 Pod 資料的 new etcd。這就造成了 List resourceVersion 拆分前後的嚴重不一致。

resourceVersion 的值在 Operator 中是保障 event 不丟的關鍵。所以說 etcd 的資料拆分不僅影響到了 kube-apiserver,同時也影響到了眾多的 Operator 類元件, 一旦出現變更事件丟失,會造成 Pod 無法交付、出現髒亂資料等問題故障。

在這裡插入圖片描述

在這裡插入圖片描述

到現在為止,雖然我們瞭解到 Operator 拿到的 list resourceVersion 拆分前後不一致,從 old etcd 中返回的 list resourceVersion 要比從 new etcd 要大, 那麼和 Operator 丟掉 Pod 更新事件有什麼關係呢?

要回答這個問題,就需要從 K8s 的元件協作設計中的 ListAndWatch 說起,勢必需要從客戶端 Client-go 和服務端 kube-apiserver 來講。

Client-go 中 ListAndWatch

我們都知道 Operator 元件是通過開源 Client-go 程式碼包進行事件感知的。

在這裡插入圖片描述

Operator 中的 Client-go 感知資料物件事件示意圖

其中核心關鍵就是 ListAndWatch 方法,保障 client 不丟失 event 事件的 resourceVersion 就是在該方法中通過 List 請求獲取的。

ListAndWatch 第一次會列出所有的物件,並獲取資源物件的版本號,然後 watch 資源物件的版本號來檢視是否有被變更。首先會將資源版本號設定為 0,list()可能會導致本地的快取相對於 etcd 裡面的內容存在延遲。Reflector 會通過 watch 的方法將延遲的部分補充上,使得本地的快取資料與 etcd 的資料保持一致。

關鍵程式碼如下:

// Run repeatedly uses the reflector's ListAndWatch to fetch all the
// objects and subsequent deltas.
// Run will exit when stopCh is closed.
func (r *Reflector) Run(stopCh <-chan struct{}) {
  klog.V(2).Infof("Starting reflector %s (%s) from %s", r.expectedTypeName, r.resyncPeriod, r.name)
  wait.BackoffUntil(func() {
    if err := r.ListAndWatch(stopCh); err != nil {
      utilruntime.HandleError(err)
    }
  }, r.backoffManager, true, stopCh)
  klog.V(2).Infof("Stopping reflector %s (%s) from %s", r.expectedTypeName, r.resyncPeriod, r.name)
}
// ListAndWatch first lists all items and get the resource version at the moment of call,
// and then use the resource version to watch.
// It returns error if ListAndWatch didn't even try to initialize watch.
func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error {
  var resourceVersion string
  // Explicitly set "0" as resource version - it's fine for the List()
  // to be served from cache and potentially be delayed relative to
  // etcd contents. Reflector framework will catch up via Watch() eventually.
  options := metav1.ListOptions{ResourceVersion: "0"}

  if err := func() error {
    var list runtime.Object
      ... // omit code here
    listMetaInterface, err := meta.ListAccessor(list)
      ... // omit code here
    resourceVersion = listMetaInterface.GetResourceVersion()
        ... // omit code here
    r.setLastSyncResourceVersion(resourceVersion)
    ... // omit code here
    return nil
  }(); err != nil {
    return err
  }
    ... // omit code here
  for {
        ... // omit code here
    options = metav1.ListOptions{
      ResourceVersion: resourceVersion,
      ... // omit code here
    }
    w, err := r.listerWatcher.Watch(options)
        ... // omit code here
    if err := r.watchHandler(w, &resourceVersion, resyncerrc, stopCh); err != nil {
        ... // omit code here
      return nil
    }
  }
}

整理為流程圖更為清楚:

在這裡插入圖片描述

kube-apiserver 中的 Watch 處理

看完客戶端的處理邏輯,再來看服務端的處理,關鍵在 kube-apiserver 對 watch 請求的處理, 對每一個 watch 請求,kube-apiserver 都會新建一個 watcher,啟動一個 goroutine watchServer 專門針對該 watch請求進行服務,在這個新建的 watchServer 中向 client 推送資源 event 訊息。

在這裡插入圖片描述

但是重點來了,client 的 watch 請求中引數 watchRV是從 Client-go 中的 List 響應而來,kube-apiserver 只向 client 推送大於 watchRV 的 event 訊息,在拆分過程中 client 的 watchRV 有可能遠大於 kube-apiserver 本地的 event 的 resourceVersion, 這就是導致 client 丟失 Pod 更新 event 訊息的根本原因。

從這一點來說,重啟 Operator 元件是必須的,重啟元件可以觸發 Client-go 的 relist,拿到最新的 Pod list resourceVersion,從而不丟失 Pod 的更新 event 訊息。

在這裡插入圖片描述

PART. 3 問題破局

破解重啟問題

到了這裡,我們似乎也難逃需要重啟元件的命運,但是經過問題解析之後,我們理清了問題原因,其實也就找到了解決問題的方法。

重啟元件問題主要涉及到兩個主體:客戶端 Client-go 和服務端 kube-apiserver,所以解決問題可以從這兩個主體出發,尋求問題的突破點。

首先針對客戶端 Client-go,關鍵就在於讓 ListAndWatch 重新發起 List 請求拿到 kube-apiserver 的最新的 resourceVersion,從而不丟失後續的 event 訊息。如果過能夠讓 Client-go 在某個特定的時機重新通過 List 請求重新整理本地的 resourceVersion,也就解決了問題,但是如果通過更改 Client-go 程式碼,還是需要元件釋出重啟才能生效,那麼問題就是如何不用修改 Client-go 的程式碼,就可以重新發起 List 請求。

我們重新 review ListAndWatch 的邏輯流程,可以發現判斷是否需要發起 List 請求,關鍵在於 Watch 方法的返回錯誤的判斷。而 watch 方法返回的錯誤是根據 kube-apiserver 對 watch 請求的響應決定的,讓我們把目光放到服務端 kube-apiserver。

在這裡插入圖片描述

不一樣的 watch 請求處理

kube-apiserver 的 watch 請求處理前文已經介紹過,我們可以通過修改 kube-apiserver 的 watch 請求處理流程,實現與 Client-go 的相互配合,來達到我們的目的。

由上文我們知道 Client-go 的 watchRV 要遠大於 kube-apiserver 本地 watch cache 中的 resourceVersion, 可以根據這個特點來實現 kube-apiserver 傳送指定錯誤(TooLargeResourceVersionError),從而觸發 Client-go 的 relist 動作。kube-apiserver 元件無可避免的需要重啟,更新配置後可以執行我們改造的邏輯。

改造邏輯示意如下:

在這裡插入圖片描述

技術保障資料一致

前人的經驗是通過 etcd make-mirror 工具來實現資料遷移的,優點是簡單方便,開源工具開箱即用。缺點是該工作實現簡單,就是從一個 etcd 中讀取 key,然後重新寫入另一個 etcd 中,不支援斷點續傳,對大資料量耗時長的遷移不友好。另外 etcd key 中的 createRevision 資訊也被破壞掉。因此在遷移完成後,需要進行嚴格的資料完整性檢測。

針對上面的問題我們可以換一個思路,我們本質是要做資料遷移的,etcd 本身的儲存結構(KeyValue)具有特殊性,我們希望保留資料前後的完整性。所以想到了 etcd 的 snapshot 工具, snapshot 工具本來是用於 etcd 的容災恢復的,即可以使用一個 etcd 的 snapshot 資料重新創建出新的 etcd 例項。而且通過 snapshot 的資料在新的 etcd 中是能夠保持原有的 keyValue 的完整性的,而這正是我們所要的。

// etcd KeyValue 資料結構
type KeyValue struct {
  // key is the key in bytes. An empty key is not allowed.
  Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
  // create_revision is the revision of last creation on this key.
  CreateRevision int64 `protobuf:"varint,2,opt,name=create_revision,json=createRevision,proto3" json:"create_revision,omitempty"`
  // mod_revision is the revision of last modification on this key.
  ModRevision int64 `protobuf:"varint,3,opt,name=mod_revision,json=modRevision,proto3" json:"mod_revision,omitempty"`
  // version is the version of the key. A deletion resets
  // the version to zero and any modification of the key
  // increases its version.
  Version int64 `protobuf:"varint,4,opt,name=version,proto3" json:"version,omitempty"`
  // value is the value held by the key, in bytes.
  Value []byte `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"`
  // lease is the ID of the lease that attached to key.
  // When the attached lease expires, the key will be deleted.
  // If lease is 0, then no lease is attached to the key.
  Lease int64 `protobuf:"varint,6,opt,name=lease,proto3" json:"lease,omitempty"`
}

遷移資料裁剪

etcd snapshot 資料雖然有我們想要的保持 KeyValue 的完整性,但是重建的 etcd 中儲存的資料是老 etcd的全部資料,這個並不是我們想要的。我們當然可以在新建 etcd 後,再來發起冗餘資料的清楚工作,但這並不是最好的方法。

我們可以通過改造 etcd snapshot 工具在 snapshot 的過程中實現我們的資料裁剪。etcd 的儲存模型中,是有一個 buckets 的列表的, buckets 是 etcd 一個儲存概念,對應到關係資料庫中可以認為是一個 table,其中的每個 key 就對應的 table 中的一行。其中最重要的 bucket 是名稱為 key 的 bucket, 該 bucket 儲存了 K8s 中所有資源物件。而 K8s 的所有資源物件的 key 都是有固定格式的,按照 resource 類別和 namespace 區別,每種 resource 都是有固定的字首。比如 Pod 資料的字首就是/registry/Pods/。我們在 snapshot 過程中可以根據這個字首區分出 Pod 資料,把非 Pod 資料裁減掉。

另外根據 etcd 的特性,etcd 做 snapshot 資料的儲存大小是 etcd 的硬碟檔案大小,其中有兩個值 db total size 和 db inuse size, db total size 大小是 etcd 在硬碟中的所佔用的儲存檔案的大小,其中包含了很多已經成為垃圾 key ,但未清理的資料。db inuse size 大小是所有可用的資料的總大小。在不經常使用 etcd defrag 方法整理儲存空間時, total 的值一般來講要遠大於 inuse 的值。

在資料裁剪中即使我們裁剪掉非 Pod 資料,整個 snapshot 的資料也不會有任何改變,這時候我們需要通過 defrag 方法來釋放掉冗餘儲存空間。

在下面的示意圖中,可以看到 db total 的變化過程,最終我們得到的 snapshot 資料大小就是 Pod 資料的大小,這對我們節約資料傳輸時間來講是非常重要的。

在這裡插入圖片描述

在這裡插入圖片描述

Pod 禁寫的小坑

在前面的拆分流程中,我們提到 K8s 禁止寫一類資源的時候,可以通過 MutatingWebhook 來實現,就是直接返回 deny 結果即可,比較簡單。這裡記錄一下我們當時遇到的一個小坑點。

我們最初的 MutatingWebhookConfiguration 配置如下, 但是我們發現 apply 這個配置後,還是能夠收到 Pod 的更新 event 訊息。

// 第一個版本配置,有問題
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: deny-pods-write
webhooks:
- admissionReviewVersions:
  - v1beta1
  clientConfig:
    url: https://extensions.xxx/always-deny
  failurePolicy: Fail
  name: always-deny.extensions.k8s
  namespaceSelector: {}
  rules:
  - apiGroups:
    - ""
    apiVersions:
    - v1
    operations:
    - "*"
    resources:
    - pods
    scope: '*'  
  sideEffects: NoneOnDryRun

經過排查後發現是 Pod 的 status 欄位被更新,通過閱讀 apiserver 的程式碼,我們發現與 Pod 儲存有關的 resource 不僅僅只有 Pod 一個,還有下面的型別,Pod status 與 Pod 對於 apiserver 的儲存來講是不同的資源。

"pods":             podStorage.Pod,
"pods/attach":      podStorage.Attach,
"pods/status":      podStorage.Status,
"pods/log":         podStorage.Log,
"pods/exec":        podStorage.Exec,
"pods/portforward": podStorage.PortForward,
"pods/proxy":       podStorage.Proxy,
"pods/binding":     podStorage.Binding,

經過調整後,下面的配置是能夠禁止 Pod 資料完全更新的配置,注意其中的 resource 配置欄位。

這是一個小坑點,記錄在此。

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: deny-pods-write
webhooks:
- admissionReviewVersions:
  - v1beta1
  clientConfig:
    url: https://extensions.xxx/always-deny
  failurePolicy: Fail
  name: always-deny.extensions.k8s
  namespaceSelector: {}
  rules:
  - apiGroups:
    - ""
    apiVersions:
    - v1
    operations:
    - "*"
    resources:
    - pods
    - pods/status
    - pods/binding
    scope: '*'  
  sideEffects: NoneOnDryRun

最後的拆分流程

在解決了前面的問題後,我們最後的拆分流程也就出來了。

示意如下:

在這裡插入圖片描述

在資料拆分期間,僅有 Pod 資料不可以有寫操作,讀是可以的,其他資源可以正常讀寫。整個流程可以通過程式自動化的來實現。

Pod 的禁寫操作的時間根據 Pod 資料的大小而所有變化,主要消耗在 Pod 資料 copy 過程上,基本整個過程在幾分鐘內即可完成。

除了 kube-apiserver 無法避免需要更新儲存配置重啟外,不需要任何元件重啟。同時也節省了大量的與元件 owner 溝通時間,也避免了眾多操作過程中的眾多不確定性。

整個拆分過程一個人完全可以勝任。

PART. 4 最後的總結

本文從資料拆分的目標出發,借鑑了前人經驗,但根據自身的實際情況和要求,突破了之前的經驗窠臼,通過技術創新解決了元件重啟和資料一致性保障問題,在提升效率的同時也在技術上保障了安全性。

現過程抽絲剝繭介紹了整個思考過程和實現關鍵點。

在這裡插入圖片描述

整個思考過程和實現關鍵點

我們並沒有發明創造了什麼,只是在現有邏輯和工具基礎上,稍加改良從而來完成我們的目標。然而改造和改良過程的背後是需要我們瞭解底層的細枝末節,這並不是畫幾個框框就能瞭解到的。

知其然知其所以然在大部分工作中都是必須的,雖然這會佔用我們很多時間,但這些時間是值得的。

最後借用一句古話來結束:

運用之妙,存乎一心 與諸君共勉。

「參考資料」

(1)【etcd storage limit】:

https://etcd.io/docs/v3.3/dev-guide/limit/

(2)【etcd snapshot】:

https://etcd.io/docs/v3.3/op-guide/recovery/

(3)【攀登規模化的高峰 - 螞蟻集團大規模 Sigma 叢集 ApiServer 優化實踐】:

https://www.sofastack.tech/blog/climbing-to-the-top-of-scale-ant-groups-large-scale-sigma-cluster-apiserver-optimization-in-practice/

在這裡插入圖片描述