圖片來源: http://127.0.0.1/vhost/conf/img_echo.php?w=640&h=427&src=https://unsplash.com/photos/_ZxC9GRHk1k
軟體世界的發展比以往任何時候都快,為了保持競爭力需要儘快推出新的軟體版本,而又不影響線上得使用者。許多企業已將工作負載遷移到了 Kubernetes 叢集,Kubernetes 叢集本身就考慮到了一些生產環境的實踐,但是要讓 Kubernetes 實現真正的零停機不中斷或丟失請求,我們還需要做一些額外的操作才行。
滾動更新預設情況下,Kubernetes 的 Deployment 是具有滾動更新的策略來進行 Pod 更新的,該策略可以在任何時間點更新應用的時候保證某些例項依然可以正常執行來防止應用 down 掉,當新部署的 Pod 啟動並可以處理流量之後,才會去殺掉舊的 Pod。
在使用過程中我們還可以指定 Kubernetes 在更新期間如何處理多個副本的切換方式,比如我們有一個3副本的應用,在更新的過程中是否應該立即建立這3個新的 Pod 並等待他們全部啟動,或者殺掉一個之外的所有舊的 Pod,或者還是要一個一個的 Pod 進行替換?下面示例是使用預設的滾動更新升級策略的一個 Deployment 定義,在更新過程中最多可以有一個超過副本數的容器(maxSurge),並且在更新過程中沒有不可用的容器。
apiVersion: apps/v1kind: Deploymentmetadata: name: zero-downtime labels: app: zero-downtimespec: replicas: 3 selector: matchLabels: app: zero-downtime strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: # with image nginx # ...上面的 zero-downtime 這個應用使用 nginx 這個映象建立3個副本,該 Deployment 執行滾動更新的方式:首先建立一個新版本的 Pod,等待 Pod 啟動並準備就緒,然後刪除一箇舊的 Pod,然後繼續下一個新的 Pod,直到所有副本都已替換完成。為了讓 Kubernetes 知道我們的 Pod 何時可以準備處理流量請求了,我們還需要配置上 liveness 和 readiness 探針。下面展示的就是新舊 Pod 替換的輸出資訊:
$ kubectl get podsNAME READY STATUS RESTARTS AGEzero-downtime-d449b5cc4-k8b27 1/1 Running 0 3m9szero-downtime-d449b5cc4-n2lc4 1/1 Running 0 3m9szero-downtime-d449b5cc4-sdw8b 1/1 Running 0 3m9s...zero-downtime-d449b5cc4-k8b27 1/1 Running 0 3m9szero-downtime-d449b5cc4-n2lc4 1/1 Running 0 3m9szero-downtime-d449b5cc4-sdw8b 1/1 Running 0 3m9szero-downtime-d569474d4-q9khv 0/1 ContainerCreating 0 12s...zero-downtime-d449b5cc4-n2lc4 1/1 Running 0 3m9szero-downtime-d449b5cc4-sdw8b 1/1 Running 0 3m9szero-downtime-d449b5cc4-k8b27 1/1 Terminating 0 3m29szero-downtime-d569474d4-q9khv 1/1 Running 0 1m...zero-downtime-d449b5cc4-n2lc4 1/1 Running 0 5mzero-downtime-d449b5cc4-sdw8b 1/1 Running 0 5mzero-downtime-d569474d4-q9khv 1/1 Running 0 1mzero-downtime-d569474d4-2c7qz 0/1 ContainerCreating 0 10s......zero-downtime-d569474d4-2c7qz 1/1 Running 0 40szero-downtime-d569474d4-mxbs4 1/1 Running 0 13szero-downtime-d569474d4-q9khv 1/1 Running 0 67s可用性檢測如果我們從舊版本到新版本進行滾動更新,只是簡單的通過輸出顯示來判斷哪些 Pod 是存活並準備就緒的,那麼這個滾動更新的行為看上去肯定就是有效的,但是往往實際情況就是從舊版本到新版本的切換的過程並不總是十分順暢的,應用程式很有可能會丟棄掉某些客戶端的請求。
為了測試是否存在請求被丟棄,特別是那些針對即將要退出服務的例項的請求,我們可以使用一些負載測試工具來連線我們的應用程式進行測試。我們需要關注的重點是所有的 HTTP 請求,包括 keep-alive 的 HTTP 連線是否都被正確處理了,所以我們這裡可以使用 Apache Bench(AB Test) 或者 Fortio(Istio 測試工具) 這樣的測試工具來測試。
我們使用多個執行緒以併發的方式去連線到正在執行的應用程式,我們關心的是響應的狀態和失敗的連線,而不是延遲或吞吐量之類的資訊。我們這裡使用 Fortio 這個測試工具,比如每秒 500 個請求和 8 個併發的 keep-alive 連線的測試命令如下所示(使用域名zero.qikqiak.com代理到上面的3個 Pod):
$ fortio load -a -c 8 -qps 500 -t 60s "http://zero.qikqiak.com/"關於 fortio 的具體使用可以檢視官方文件:https://github.com/fortio/fortio
使用 -a 引數可以將測試報告儲存為網頁的形式,這樣我們可以直接在瀏覽器中檢視測試報告。如果我們在進行滾動更新應用的過程中啟動測試,則可能會看到一些請求無法連線的情況:
Starting at 1000 qps with 8 thread(s) [gomax 2] for 1m0s : 7500 calls each (total 60000)Ended after 1m0.006243654s : 5485 calls. qps=91.407Aggregated Sleep Time : count 5485 avg -17.626081 +/- 15 min -54.753398956 max 0.000709054 sum -96679.0518[...]Code 200 : 5463 (99.6 %)Code 502 : 20 (0.4 %)Response Header Sizes : count 5485 avg 213.14166 +/- 13.53 min 0 max 214 sum 1169082Response Body/Total Sizes : count 5485 avg 823.18651 +/- 44.41 min 0 max 826 sum 4515178[...]從上面的輸出可以看出有部分請求處理失敗了(502),我們可以執行幾種通過不同方式連線到應用程式的測試場景,比如通過 Kubernetes Ingress 或者直接從叢集內部通過 Service 進行連線。我們會看到在滾動更新過程中的行為可能會有所不同,具體的還是需要取決於測試的配置引數,和通過 Ingress 的連線相比,從叢集內部連線到服務的客戶端可能不會遇到那麼多的失敗連線。
原因分析現在的問題是需要弄明白當應用在滾動更新期間重新路由流量時,從舊的 Pod 例項到新的例項究竟會發生什麼,首先讓我們先看看 Kubernetes 是如何管理工作負載連線的。如果我們執行測試的客戶端直接從叢集內部連線到 zero-downtime 這個 Service,那麼首先會通過 叢集的 DNS 服務解析到 Service 的 ClusterIP,然後轉發到 Service 後面的 Pod 例項,這是每個節點上面的 kube-proxy 通過更新 iptables 規則來實現的。
kubernetes kube-proxy
Kubernetes 會根據 Pods 的狀態去更新 Endpoints 物件,這樣就可以保證 Endpoints 中包含的都是準備好處理請求的 Pod。
但是 Kubernetes Ingress 連線到例項的方式稍有不同,這就是為什麼當客戶端通過 Ingresss 連線到應用程式的時候,我們會在滾動更新過程中檢視到不同的宕機行為。
大部分 Ingress Controller,比如 nginx-ingress、traefik 都是通過直接 watch Endpoints 物件來直接獲取 Pod 的地址的,而不用通過 iptables 做一層轉發了。
kubernetes ingress
無論我們如何連線到應用程式,Kubernetes 的目標都是在滾動更新的過程中最大程度地減少服務的中斷。一旦新的 Pod 處於活動狀態並準備就緒後,Kubernetes 就將會停止就的 Pod,從而將 Pod 的狀態更新為 “Terminating”,然後從 Endpoints 物件中移除,並且傳送一個 SIGTERM 訊號給 Pod 的主程序。SIGTERM 訊號就會讓容器以正常的方式關閉,並且不接受任何新的連線。Pod 從 Endpoints 物件中被移除後,前面的負載均衡器就會將流量路由到其他(新的)Pod 中去。這個也是造成我們的應用可用性差距的主要原因,因為在負責均衡器注意到變更並更新其配置之前,終止訊號就會去停用 Pod,而這個重新配置過程又是非同步發生的,所以並不能保證正確的順序,所以就可能導致很少的請求會被路由到終止的 Pod 上去。
零宕機那麼如何增強我們的應用程式以實現真正的零宕機遷移呢?
首先,要實現這個目標的先決條件是我們的容器要正確處理終止訊號,在 SIGTERM 訊號上實現優雅關閉。下一步需要新增 readiness 可讀探針,來檢查我們的應用程式是否已經準備好來處理流量了。
可讀探針只是我們平滑滾動更新的起點,為了解決 Pod 停止的時候不會阻塞並等到負載均衡器重新配置的問題,我們需要使用 preStop 這個生命週期的鉤子,在容器終止之前呼叫該鉤子。
生命週期鉤子函式是同步的,所以必須在將最終終止訊號傳送到容器之前完成,在我們的示例中,我們使用該鉤子簡單的等待,然後 SIGTERM 訊號將停止應用程式程序。同時,Kubernetes 將從 Endpoints 物件中刪除該 Pod,所以該 Pod 將會從我們的負載均衡器中排除,基本上來說我們的生命週期鉤子函式等待的時間可以確保在應用程式停止之前重新配置負載均衡器。
這裡我們在 zero-downtime 這個 Deployment 中新增一個 preStop 鉤子:
apiVersion: apps/v1kind: Deploymentmetadata: name: zero-downtime labels: app: zero-downtimespec: replicas: 3 selector: matchLabels: app: zero-downtime template: spec: containers: - name: zero-downtime image: nginx livenessProbe: # ... readinessProbe: # ... lifecycle: preStop: exec: command: ["/bin/bash", "-c", "sleep 20"] strategy: # ...我們這裡使用 preStop 設定了一個 20s 的寬限期,Pod 在真正銷燬前會先 sleep 等待 20s,這就相當於留了時間給 Endpoints 控制器和 kube-proxy 更新去 Endpoints 物件和轉發規則,這段時間 Pod 雖然處於 Terminating 狀態,即便在轉發規則更新完全之前有請求被轉發到這個 Terminating 的 Pod,依然可以被正常處理,因為它還在 sleep,沒有被真正銷燬。
現在,當我們去檢視滾動更新期間的 Pod 行為時,我們將看到正在終止的 Pod 處於 Terminating 狀態,但是在等待時間結束之前不會關閉的,如果我們使用 Fortio 重新測試下,則會看到零失敗請求的理想行為:
Starting at 1000 qps with 8 thread(s) [gomax 2] for 1m0s : 7500 calls each (total 60000)Ended after 1m0.091439891s : 10015 calls. qps=166.66Aggregated Sleep Time : count 10015 avg -23.316213 +/- 14.52 min -50.161414028 max 0.001811225 sum -233511.876[...]Code 200 : 10015 (100.0 %)Response Header Sizes : count 10015 avg 214 +/- 0 min 214 max 214 sum 2143210Response Body/Total Sizes : count 10015 avg 826 +/- 0 min 826 max 826 sum 8272390Saved result to data/2020-02-12-162008_Fortio.json All done 10015 calls 47.405 ms avg, 166.7 qps總結Kubernetes 在考慮到生產就緒性方面已經做得很好了,但是為了在生產環境中執行我們的企業級應用,我們就必須了解 Kubernetes 是如何在後臺執行的,以及我們的應用程式在啟動和關閉期間的行為。而且上面的方式是隻適用於短連線的,對於類似於 websocket 這種長連線應用需要做滾動更新的話目前還沒有找到一個很好的解決方案,有的團隊是將長連線轉換成短連線來進行處理的,我這邊還是在應用層面來做的支援,比如客戶端增加重試機制,連線斷掉以後會自動重新連線,大家如果有更好的辦法也可以留言互相討論下方案。