雲原生灰度更新實踐

相信在座的大家應該都聽說過雲原生了,這是近三四年一直熱門的一個東西。什麼是雲原生呢?現在的雲原生是個很寬泛的定義,可以簡單理解為你的服務是為雲而生,或者說因為現在雲原生都是以 Kubernetes 容器技術作為基礎設施,那隻要你的服務運行在 Kubernetes 上,它們就可以算雲原生。

而今天我跟大家分享的主題是 Luffy3 利用雲原生技術,實現的灰度更新,主要從以下 4 個方面進行介紹:

  • 什麼是灰度更新

  • 灰度更新的現狀

  • 雲原⽣實踐

  • 總結與展望

什麼是灰度更新

為了讓大家更好的理解,我通過一個簡單的例子和大家說一下什麼是灰度更新。

假設你有⼀個關於酒店預定的項⽬,需要對外提供⼀個 Web 網站,供用戶預定房間。為了保證業務的⾼可⽤,該項⽬研發的服務端是⽀持分散式的。因此,你在⽣產環境,組了⼀個酒店預定 Web 集群,⼀共起了 3 個服務端,通過 Nginx 反向代理的方式對外提供服務。

左圖是傳統意義上的灰度更新,即先將部分流量導到新版本上進行測試,如果可以就全面推廣,如果不行就退回上一個版本。具體舉例來說的話,有三台機器分別部署了服務端,IP 地址分別為 0.2、0.3、0.4。日常更新的話,選擇先在 0.4 服務端更新並看一下是否有問題出現,在確定沒有問題後才進行 0.3 和 0.2 的更新。

右圖則是使用容器技術,它會比物理機部署的方式更加靈活。它用到的概念是 instance,也就是實例,同一台機器上可以起多個實例。訪問流量會如圖從左往右的方向,先經過網關,通過在網關上添加一些策略,讓 95% 的流量走上面的原服務,5% 的流量走下面的灰度服務。通過觀察灰度服務是否有異常,如果沒有異常,則可以把原服務的容器鏡像版本更新到最新,並刪掉下面的灰度服務。這和左圖是不一樣的,它不是滾動式一台接一台的更新,而是藉助一個彈性資源平台直接把原服務全部更新掉。

灰度更新現狀

上圖是灰度更新在 Luffy2 上面的現狀,主要問題出現在 API 處理這一塊,因為之前的狀態是靠資料庫來維護的,容易出現狀態不統一的問題。

左圖是一個簡略的處理流程。當一個 API 請求服務過來要求進行服務灰度更新時,第一步會先生成一個帶灰度名稱的 App。

第二步這裡給大家細說,首先要將生成的 App 放入資料庫,同時在 Kubernetes 創建無狀態服務,這通常需要 10 分鐘左右的時間。這期間會通過一個 Go 語言程式對 App 表進行不間斷掃描以確認服務是否完成創建。同時還需要使用 Kubernetes 創建轉發規則等,等待所有需求都創建完成後就返回原版 ok 給調用方。

這裡涉及到性能問題,因為資料庫內有很多條要處理的東西,這些要等待挨個處理,而這其中有很多都是無用數據,在掃到 App 前的這 10 分鐘里,就算去 Kubernetes 那邊調用,也是在做無用操作。

另外還有一個調用鏈很長的問題,在 Kubernetes 里創建的很多東西都會包含在同一次 API 請求里,這就導致隨時可能出現在一步完成後,下一步崩潰的情況。這種時候可能要考慮是否回滾的問題,而如果回滾就要刪掉相關服務和資料庫。這種情況在調用外部組件越多時,越容易出現。比較直觀的解決方法是簡化 API 流程,針對這個方法 Kubernetes 提供了 CRD。

雲原生實踐

CRD

上圖是從 Kubernetes 官網上摘抄下來的關於 CRD 的說明。這個大家應該都比較熟悉了。Kubernetes 里最重要的概念就是資源,它裡面所有的東西都是一個資源或者對象。右圖是相關的無狀態服務的例子,裡面包含了服務的版本、類型、標籤以及鏡像版本和容器對外提供的埠。在 Kubernetes 里創建無狀態服務,你只需要完成定義即可,而 CRD 則可以幫助我們自定義 spec 內的內容。

需要注意的是,訂製資源本身只能⽤來存取結構化的數據。只有與訂製控制器(Custom Controller)相結合時,才能提供真正的聲明式 API (Declarative API)。通過使用聲明式 API, 你可以聲明或者設定資源的期望狀態,並讓 Kubernetes 對象的當前狀態同步到其期望狀態。也就是控制器負責將結構化的數據解釋為⽤戶所期望狀態的記錄,並持續地維護該狀態。

上圖是關於聲明式 API 的相關實踐,採用水平觸發的方式。簡單舉例,電視使用的遙控器是邊緣觸發,只要你按了更改頻道就會立即觸發更改。而鬧鐘則是水平觸發,無論在鬧鐘響動之前更改了多少次,它只會在你最後定好的時間點觸發。總結來說就是邊緣觸發更注重時效性,在更改時會立即回饋。而水平觸發則只關注最終的一致性,無論前面如何,只保證最後狀態和我們設置的一樣就好。

Luffy3.0 CRD

上圖是又拍雲使用 luffy3.0 做的整體結構,它是架在 Kubernetes 上的,其中和 Kubernetes 的服務相關交互都由 apiserver 完成。

圖中右下角的是關係式資料庫,關係相關比如用戶關係、從屬關係,都在這裡面。它上面帶一層 redis 快取,來提高熱點數據查詢效率。左圖是我們實現的幾個自己的 CRD。第二個 projects 就是相關項目。當年在創立項目時,就是背靠 CRD 的。首先在資料庫里寫了,然後在 Kubernetes 創建了 projects 這個 CRD 對象。

Kubernetes client-go informer 機制

接下來和,大家談一下 informer 的實現邏輯,informer 是 Kubernetes 官方提供的,方便大家和 Apiserver 做交互的一套 SDK,它比較依賴水平觸發的機制。

上圖左邊是我們的 apiserver,所有的數據都存在 Key-value 的資料庫 ETCD 里。在存儲時它使用以下結構:

/registry/{kind}/{namespace}/{name}

這之中前綴 registry 是可以修改的,用於防止衝突,kind 是類型,namespace 為命名空間或者說項目名,對應 Luffy3。再後面的 name 是服務名稱。在通過 apiserver 對這個對象進行了創建、更新、刪除等操作時,ETCD 都會將這個事件回饋給 apiserver。然後 apiserver 會將更改對象開放給 informer。而 informer 是基於單個類型 {kind} 的,這也就說如果你有多個類型,那麼你必須對應每一個類型起一個對應的 informer,當然這個可以通過程式碼來生成。

回到 informer 實現邏輯,當 informer 運行起來後,它會先去 Kubernetes 中獲取全量數據,比如當前 informer 對應的類型是無狀態服務,那它會獲取全部的無狀態服務。然後持續 watch apiserver,一旦 apiserver 有新的無狀態服務,它都會收到對應事件。收到新事件後,informer 會將時間放入先進先出的隊列,讓 controller 進行消費。而 controller 會將事件交遞給模組 Processer 進行特殊處理。在模組 Processer 上有很多監聽器,這些監聽器是對特定類型設置的回調函數。

然後來看一下為什麼 controller 中的 lister 和 indexer 關聯。因為 namespace 和目錄很像,在這個目錄下會有很多的無狀態服務,如果想根據某一規則進行處理,在原生服務上處理肯定是最差的選擇,而這就是 lister 所要做的。它會將這部分進行快取,並做一個索引,也就是 inderxer,這個索引和資料庫很像,是由一些 key 組成的。

而對於 CRD 來說,要實現的就是 contorller,以及 informer 和 controller 交互的部分。其他的部分由程式碼自己生成。

CRD 控制器程式碼模組分解

如果程式碼沒有生成,那就會用到上圖了。前三條是寫程式碼相關,其中 API type 需要我們填寫 CRD 的定義、灰度更新定義等,完成定義後要將定義註冊到 Kubernetes 上,不然就不會起效。接著,程式碼會生成下方的 4 項,包括 deepcopy 深度拷貝函數,使用 CRD 的 client,informer 和 lister。

第三塊是自定義控制器相關的 controller,包括和 Apiserver 打交道的 Kubernetes rest client,時間控制器或時間函數 eventhandler 和 handlerfuncs 等。這其中需要寫的是調和函數 reconciliation,因為其他官方都已經為我們封裝好了,只需要定義好調和函數就可以。

全部封裝完成後需要把這些東西串起來,當前主流的選擇有兩個,OperatorSDK 和 Kubebuilder。

OperatorSDK vs Kubebuilder

接下來來看一下程式碼是如何生成的

以 OperatorSDK 為例看一下是如何生成程式碼的。當然大家也可以選擇使用 Kubebuilder,這兩者的生成方式差別不大。在上圖的「初始化項目」里可以看到倉庫的名稱,它定義了一個版本的版本號,以及類型 canaryDeployment,即灰度的無服務狀態。之後生成對應的資源和控制器。完成後寫剛剛講到的調和函數和 API 定義。全部完成後就可以執行了,非常簡單。

灰度更新的設計

在聊了上面的這些知識後,來看一下灰度更新。上圖是灰度更新的簡易示例圖,流程是從左開始到右邊結束。

第一步是創建灰度服務,創建後可以更新灰度。比如剛才的 Nginx 的例子,我們創建的版本號是 1.19。但是在灰度過程中發現當前版本有 bug,而在對這個 bug 進行修復後,確認無誤就可以將原服務更新到版本號 1.20,然後刪除灰度服務。如果發現 1.20 版本依然有 bug,也可以選擇刪除灰度服務,讓你原服務接管所有流量。這就是 CRD 對開發步驟的簡化。

灰度更新一共有以下 4 個階段:

  • 創建

  • 更新

  • 替換

  • 刪除

創建

因為 Kubernetes 是水平觸發的,所有它創建和更新的處理邏輯是相同的,只看最終狀態即可。

這張圖比較重要,大家可以仔細看一下。圖中右上部分是原服務,原服務包含 Kubernetes 無狀態服務、Service 內部域名、ApisixRoute、Apisix 路由規則、ApisixUpstrean,以及 Apisix 上游的一些配置。原服務下方是灰度服務,左邊的 controller 是之前提到的 CRD 控制器。

原服務創建好後,創建無狀態服務,配置對應的 http 轉發規則後轉到 ApisixRoute 服務站中進行對應路由的配置,之後只有轉到容器網關就會自動定位到指定服務。然後大家可以看到,我們自定義的 CRD 類型名是 CanaryDeployment,是灰度的無狀態服務。創建這個無狀態服務的流程和原服務是相同的。

CRD 的定義是如何設計的?下圖是一個簡單示例:

apiVersion 我們先不講,具體看一下下面的部分:

  • kind:類型,上圖類型為 CanaryDeployment(無狀態服務)

  • name:名稱

  • namespace:位置,在 mohb-test 這個測試空間下

  • version:版本

  • replicas:灰度實例個數,這個個數是可配的

  • weight:權重,影響了灰度服務接管多少流量

  • apisix:服務對應的 hb 轉化規則

  • apisixRouteMatches:相關功能

  • parentDeployment:原無狀態服務名稱

  • template:這裡定義了剛剛講的鏡像、其他命令、開放埠等配置

在定義 CRD 的時候可能會遇到幾個問題。第一個問題是如果刪除了原服務,那灰度服務不會自動刪除,會被遺留。出現這個問題是因為沒有做 Kubernetes 的回收技術,而解決這個問題需要 Kubernetes 的 ownerReferences。它可以幫助你把灰度服務的 CRD 指到原服務的無狀態服務中,也就是灰度服務的 owner 由原服務負責。

這樣當刪除原服務的時候,owner 會負責刪除灰度服務。而刪除 CanaryDeployment的時候,只會刪除它右邊的 Deployment。

ownerReferences 的具體設置如下圖:

我們在定義 CRD 時加入紅框部分的欄位,這個欄位會指定它是誰的 owner,以及它的指向。到這裡創建階段基本就完成了。

替換

接下來看第二階段——替換。

我通過加入欄位 replace 進行控制,默認情況下它是 false,如果值是 true 那控制器就會知道要用 deployment 的進行替換。這裡有個問題是什麼時候進行替換?也就是什麼時候把流量切過去。雖然直接切也可以,但是等原服務完全運行起來後再切無疑是更好的。

那具體要怎麼做呢?

這就涉及到 informer 的部分邏輯了。這需要控制器能夠感知到灰度服務的 parentDeployment 是否發生變更。這部分 operator-sdk 和 Kubebuilder 就很好,它可以把不是 CRD 事件的變動也導入到調和函數內,讓控制器可以監聽無狀態服務。

具體可以看一下程式碼。首先註冊一些 watch 來監聽無狀態服務,然後寫一個函數讓無狀態服務對應到 CanaryDeployment,比如在 text back 內對無狀態服務進行了標記,這樣當感知到事件後可以看一下是哪個無狀態服務進行了替換,並推算出對應的 CanaryDeployment,然後通過調用調和函數對比和預期是否有差距。

取消

接下來看最後一個階段——取消階段。

如果直接把 CanaryDeployment 對應的對象刪掉,就會發現它的右邊多了一個 deletionTimestamp 的欄位,這是 Kubernetes 打的刪除時間標記。而對於控制器來講,就是知道這個已經是刪除狀態了,需要調整對應內容。

這有個問題,刪除是瞬間的操作,可能等不到控制器運行起來,刪除就已經完成了。因此 Kubernetes 提供了 Finalizer,Finalizer 決定了最終由誰來做釋放。

Finalizer 是自定義的,對應我們自己寫的 controller。當 Kubernetes 看到 Finalizer 不為空時,就不會立即刪除,而是出於刪除中的狀態,這就讓 controller 有時間去做一些對應處理。

壓力測試 wrk

一套東西做完後,驗證它是否正確的方法就是進行壓力測試。

我用了一個更加通用的工具來做壓力測試,可以設置更多的東西。比如可以做一些邏輯上的處理。如上圖例子一樣,假設有一個服務,請求原服務會返回「 helloword」,而請求灰度版本則會返回「 hello Hongbo」。然後定義回來的包,讓每一個請求結束後都會調用函數判斷是否等於 200,如果不是,那可能是切的過程中出現了異常,如果等於 200,則可以看一下裡面是否有 「Hongbo」。如果有,那證明請求的是灰度版本。這樣房門定一個檔(summary),對請求到原服務、灰度服務、失敗請求的次數進行統計了。

另外還可以進行一下頭部設置:

  • -c:多少個鏈接,比如 20

  • -d:放低多長時間,比如 3 分鐘

  • -s:腳本對應的地址

上圖是壓測的結果,大家可以簡單看一下。

總結和規劃

接下來和大家談一下引入 CRD 後的總結。在引⼊ CRD 後,基於 Kubernetes 事件驅動以及⽔平觸發的理念,簡化了實現的複雜性。而且因為採用了 OperatorSDK 的成熟框架,不再需要關心底層的實現,可以更加聚焦於業務的邏輯實現。減少了開發成本,提高了開發效率。

然後關於未來,有以下的規劃:

  • apisix 採用 subnet 的方式,減少創建的資源,提高成功率

  • 支援按 HTTP 頭部、特定 IP 灰度

  • 灰度服務流量比較

以上就是今天關於灰度更新實踐的分享了,感謝大家的支援。

推薦閱讀

如何讓你的大文件上傳變得又穩又快?

網路安全(一):常見的網路威脅及防範