K8S Pod流量的優雅無損切換實踐

Kubernetes 的部署基本上都是默認滾動式的,並且保證零宕機,但是它是有一個前置條件的。正是這個前置條件讓零宕機部署表現爲一個惱人的問題。爲了實現 Kubernetes 真正的零宕機部署,不中斷或不丟失任何一個運行中的請求,我們需要深入應用部署的運行細節並找到根源進行深入的根源分析。本篇的實踐內容繼承之前的知識體系,將更深入的總結零宕機部署方法。

刨根問底

滾動更新

我們首先來談談滾動更新的問題。根據默認情況,Kubernetes 部署會以滾動更新策略推動 Pod 容器版本更新。該策略的思想就是在執行更新的過程中,至少要保證部分老實例在此時是啓動並運行的,這樣就可以防止應用程序出現服務停止的情況了。在這個策略的執行過程中,新版的 Pod 啓動成功並已經可以引流時纔會關閉舊 Pod。

Kubernetes 在更新過程中如何兼顧多個副本的具體運行方式提供了策略參數。根據我們配置的工作負載和可用的計算資源,滾動更新策略可以細調超額運行的 Pods(maxSurge)和多少不可用的 Pods (maxUnavailable)。例如,給定一個部署對象要求包含三個複製體,我們是應該立即創建三個新的 Pod,並等待所有的 Pod 啓動,並終止除一個 Pod 之外的所有舊 Pod,還是逐一進行更新?下面的代碼顯示了一個名爲 Demo 應用的 Deployment 對象,該應用採用默認的 RollingUpdate 升級策略,在更新過程中最多隻能有一個超額運行的 Pods(maxSurge)並且沒有不可用的 Pods。

kind: Deployment
apiVersion: apps/v1
metadata:
  name: demo
spec:
  replicas: 3
  template:
    # with image docker.example.com/demo:1
    # ...
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

此部署對象將一次創建一個帶有新版本的 Pod,等待 Pod 啓動並準備好後觸發其中一箇舊 Pod 的終止,並繼續進行下一個新 Pod,直到所有的副本都被更新。下面顯示了 kubectl get pods 的輸出和新舊 Pods 隨時間的變化。

$ kubectl get pods
NAME                             READY     STATUS             RESTARTS   AGE
demo-5444dd6d45-hbvql   1/1       Running            0          3m
demo-5444dd6d45-31f9a   1/1       Running            0          3m
demo-5444dd6d45-fa1bc   1/1       Running            0          3m
...

demo-5444dd6d45-hbvql   1/1       Running            0          3m
demo-5444dd6d45-31f9a   1/1       Running            0          3m
demo-5444dd6d45-fa1bc   1/1       Running            0          3m
demo-8dca50f432-bd431   0/1       ContainerCreating  0          12s
...

demo-5444dd6d45-hbvql   1/1       Running            0          4m
demo-5444dd6d45-31f9a   1/1       Running            0          4m
demo-5444dd6d45-fa1bc   0/1       Terminating        0          4m
demo-8dca50f432-bd431   1/1       Running            0          1m
...

demo-5444dd6d45-hbvql   1/1       Running            0          5m
demo-5444dd6d45-31f9a   1/1       Running            0          5m
demo-8dca50f432-bd431   1/1       Running            0          1m
demo-8dca50f432-ce9f1   0/1       ContainerCreating  0          10s
...

...

demo-8dca50f432-bd431   1/1       Running            0          2m
demo-8dca50f432-ce9f1   1/1       Running            0          1m
demo-8dca50f432-491fa   1/1       Running            0          30s

應用可用性的理想和現實之間的差距

通過上面的案例看執行效果可知,從舊版本到新版本的滾動更新看起來確實是平滑更新的。然而不希望發生的事情還是發生了,從舊版本到新版本的切換並不總是完美平滑的,也就是說應用程序可能會丟失一些客戶端的請求。這是不可以接受的情況。

爲了真正測試當一個實例被退出服務時,請求是否會丟失。我們不得不對我們的服務進行壓力測試並收集結果。我們感興趣的主要一點是我們的傳入的 HTTP 請求是否被正確處理,包括 HTTP 連接是否保持活着。

這裏可以使用簡單的 Fortio 負載測試工具,用一連續的請求訪問 Demo 的 HTTP 端點。例子種配置包括 50 個併發連接 /goroutine,每秒請求比率爲 500,測試超時 60 秒。

fortio load -a -c 50 -qps 500 -t 60s "<http://example.com/demo>"

我們在進行滾動更新部署時同時運行這個測試,如下圖報告所示,會有一些連接失敗的請求:

Fortio 1.1.0 running at 500 queries per second, 4->4 procs, for 20s
Starting at 500 qps with 50 thread(s) [gomax 4] for 20s : 200 calls each (total 10000)
08:49:55 W http_client.go:673> Parsed non ok code 502 (HTTP/1.1 502)
[...]
Code 200 : 9933 (99.3 %)
Code 502 : 67 (0.7 %)
Response Header Sizes : count 10000 avg 158.469 +/- 13.03 min 0 max 160 sum 1584692
Response Body/Total Sizes : count 10000 avg 169.786 +/- 12.1 min 161 max 314 sum 1697861
[...]

輸出結果表明,並非所有的請求都被成功處理。

瞭解問題根源

現在需要搞清楚的問題是,Kubernetes 在滾動更新時將流量重新路由,從一箇舊的 Pod 實例版本到新的 Pod 實例版本,到底發生了什麼。讓我們來看看 Kubernetes 是如何管理工作負載連接的。

假設我們的客戶端是直接從集羣內部連接到 Demo 服務,通常會使用通過 Cluster DNS 解析的服務虛擬 IP,最後到 Pod 實例。這是通過 kube-proxy 來實現的,kube-proxy 運行在每個 Kubernetes 節點上並動態更新 iptables,讓請求路由到 Pod 的 IP 地址。Kubernetes 會更新 Pods 狀態中的 endpoints 對象,因此 demo 服務只包含準備處理流量的 Pods。

還有一個情況,客戶端流量是從 ingress 方式連接到 Pods 實例,它的連接方式不一樣。滾動更新時應用請求會有不同的請求宕機行爲。如 Nginx Ingress 是直接把 Pod IP 地址的 endpoints 對象觀察起來,有變化時將重載 Nginx 實例,導致流量中斷。

當然我們應該需要知道的是,Kubernetes 的目標時在滾動更新過程中儘量減少服務中斷。一旦一個新的 Pod 還活着並且準備提供服務時,Kubernetes 就會將一箇舊的 Pod 從 Service 中移除,具體操作是將 Pod 的狀態更新爲 Terminating,將其從 endpoints 對象中移除,併發送一個 SIGTERM 。SIGTERM 會導致容器以一種優雅的方式(需要應用程序能正確處理)關閉,並且不接受任何新的連接。在 Pod 被驅逐出 endpoints 對象後,負載均衡器將把流量路由到剩餘的(新的)對象上。注意此時,Pod 在負載均衡器注意到變化並更新其配置的時候,移出 endpoints 對象記錄和重新刷新負載均衡配置是異步發生的,因此不能保證正確的執行順序還可能會導致一些請求被路由到終止的 Pod,這就是在部署過程中造成應用可用性差的真實原因。

實現零故障部署

現在我們的目標就是如何增強我們的應用程序能力,讓它以真正的零宕機更新版本。

首先,實現這個目標的前提條件是我們的容器要能正確處理終止信號,即進程會在 SIGTERM 上優雅地關閉。如何實現可以網上查閱應用優雅關閉的最佳實踐,這裏不在贅述。

下一步是加入就緒探針,檢查我們的應用是否已經準備好處理流量。理想情況下,探針已經檢查了需要預熱的功能的狀態,比如緩存或數據庫初始化。

爲了解決 Pod terminations 目前沒有阻塞和等待直到負載均衡器被重新配置的問題,包含一個 preStop 生命週期鉤子。這個鉤子會在容器終止之前被調用。生命週期鉤子是同步的,因此必須在向容器發送最終終止信號之前完成。

在下面的例子中,在 SIGTERM 信號終止應用進程之前使用 preStop 鉤子來等待 120 秒,並且同時 Kubernetes 將從 endpoints 對象中移除 Pod。這樣可以確保在生命週期鉤子等待期間,負載均衡器可以正確的刷新配置。

爲了實現這個行爲,在 demo 應用部署中定義一個 preStop 鉤子如下:

kind: Deployment
apiVersion: apps/v1beta1
metadata:
  name: demo
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: zero-downtime
        image: docker.example.com/demo:1
        livenessProbe:
          # ...
        readinessProbe:
          # ...
        lifecycle:
          preStop:
            exec:
              command: ["/bin/bash""-c""sleep 120"]
  strategy:
    # ...

使用負載測試工具重新測試,發現失敗的請求數爲零,終於實現無損流量的更新。

Fortio 1.1.0 running at 500 queries per second, 4->4 procs, for 20s
Starting at 500 qps with 50 thread(s) [gomax 4] for 20s : 200 calls each (total 10000)
[...]
Code 200 : 10000 (100.0 %)
Response Header Sizes : count 10000 avg 159.530 +/- 0.706 min 154 max 160 sum 1595305
Response Body/Total Sizes : count 10000 avg 168.852 +/- 2.52 min 161 max 171 sum 1688525
[...]

實踐總結

應用的滾動更新是流量平滑切換的原子操作基礎。只有讓 Kubernetes 能正確處理滾動更新,纔有可能實現應用流量的無損更新。在此基礎之上,通過部署多套 Ingress 資源來引入流量是可以解決平滑流量的切換的。另外,因爲 Helm 支持部署一套應用的多個版本,通過版本的選擇也是可以快速切換流量的。這樣的技巧都是基於最底層的 Pod 能保證不中斷請求才行。

參考資料

https://kubernetes.io/docs/tutorials/kubernetes-basics/update/update-intro/

本文分享自微信公衆號 - 雲原生技術愛好者社區(programmer_java)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章