← 回文章列表

2024年4月9日

Kubernetes 滾動更新怎麼做到真的不停機?maxSurge、Readiness、preStop 與 PodDisruptionBudget

Kubernetes部署高可用ASP.NET Core

Pod、Deployment、Service 的基本關係可以先參考 Kubernetes 入門。這篇要回答的問題是:Deployment 明明就是滾動更新,為什麼每次部署還是有一小段錯誤?

答案是,「不停機」需要三段都顧到:

  1. 新 Pod:真的準備好了才接流量
  2. 舊 Pod:確定沒有流量了才關閉,關閉前把手上的請求處理完
  3. 其他原因的中斷:Node 維護、叢集升級時,不能一次把 Pod 全部趕走

滾動更新的流程

Kubernetes 滾動更新流程,3 個副本、maxSurge 1、maxUnavailable 0:第一步先建立一個 v2 Pod,此時共有 4 個 Pod,v2 還沒通過 Readiness 不接流量;第二步 v2 Pod 就緒後加入 Service,再終止一個 v1 Pod;重複這個過程,第三步、第四步依序替換,最後 3 個 Pod 全部是 v2;整個過程中可接流量的 Pod 從未少於 3 個

修改 Deployment 的映像檔版本後,Deployment 會建立一個新的 ReplicaSet,然後依兩個參數逐步替換:

  • maxSurge:更新過程中,最多可以比期望數量多幾個 Pod
  • maxUnavailable:更新過程中,最多可以有幾個 Pod 不可用

預設都是 25%。以 4 個副本為例,代表最多可以多開 1 個、同時可以有 1 個不可用。

對線上服務,我會這樣設:

YAML
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # 每次多開一個新的
      maxUnavailable: 0    # 新的就緒之前,舊的一個都不關
  minReadySeconds: 10      # 就緒後再觀察 10 秒,才算可用

maxUnavailable: 0 代表可接流量的 Pod 永遠不會少於 3 個:先開一個新的,等它就緒,才關一個舊的。代價是更新期間需要多一個 Pod 的資源,叢集資源不夠時,新 Pod 會卡在 Pending,整個更新就停住。

另一種策略 Recreate 是先全部關掉再全部開新的,一定會停機,只適合「新舊版本絕對不能同時存在」的情況。

Readiness Probe:判斷「新 Pod 準備好了」

上面的流程有一個關鍵:「新 Pod 就緒」是怎麼判斷的?答案是 Readiness Probe。

  • 沒有設定 Readiness Probe:容器一啟動就被視為就緒,Deployment 馬上關掉一個舊的;但 .NET 應用程式可能還在暖機、建立資料庫連線池,前幾秒的請求會變慢或失敗
  • 有設定:要等探針通過,Pod 才會被加進 Service,Deployment 才會繼續下一步
YAML
readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  periodSeconds: 5
  failureThreshold: 3

Readiness 端點應該檢查「這個實例能不能正常處理請求」,例如必要的設定與快取載入完成、資料庫連得上。新版本如果一直無法就緒,滾動更新會停在那裡,舊版本的 Pod 還在,服務不會中斷,這是 Readiness 帶來的另一個保護。

舊 Pod 的關閉:最容易被忽略的 502

即使新 Pod 都正確就緒了,很多人還是會在部署時看到一小段 502。原因出在舊 Pod 關閉的方式。

Pod 終止的時序比較:Pod 被標記為 Terminating 後,有兩件事同時開始。一條線是 Endpoints 控制器把 Pod 從 Service 移除,再由 kube-proxy 與 Ingress Controller 更新各自的轉發規則,這需要幾秒;另一條線是 kubelet 送出 SIGTERM。沒有 preStop 時,應用程式收到 SIGTERM 立刻停止接受連線,但轉發規則還沒更新,仍有請求被送進來,造成 502。加上 preStop sleep 10 秒後,SIGTERM 延後送出,這段時間轉發規則已經更新完成,應用程式接著處理完手上的請求再結束,整體時間要在 terminationGracePeriodSeconds 之內

一個 Pod 被刪除時,K8s 會同時做兩件事:

  1. 把 Pod 從 Service 移除:Endpoints 更新後,各個 Node 上的 kube-proxy、Ingress Controller 要各自更新轉發規則,這需要幾秒鐘
  2. 通知容器關閉:kubelet 執行 preStop(如果有),然後送出 SIGTERM

問題就在「同時」。應用程式收到 SIGTERM 就停止接受新連線,但轉發規則可能還沒更新完,還有請求被送到這個正在關閉的 Pod,結果就是連線被拒絕、502。

preStop:先等一下再關

解法是用 preStop 讓 SIGTERM 晚幾秒再送,給轉發規則足夠的時間更新:

YAML
spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: house-api
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "10"]

這 10 秒內 Pod 照常處理請求,但已經不會有新的請求進來了。10 秒後才送 SIGTERM,應用程式開始正常關閉。

要注意兩件事:

  • terminationGracePeriodSeconds 包含 preStop 的時間。預設 30 秒,時間到了不管做完沒有都會送 SIGKILL 強制結束。preStop 10 秒、應用程式關閉最多 30 秒,這裡就要設 40 秒以上
  • 映像檔裡要有 sleep:精簡過的映像檔(例如 distroless、chiseled)沒有 shell,也沒有 sleep 指令,preStop 會執行失敗。這種情況可以改在應用程式裡處理(見下一節)

應用程式端的優雅關閉

收到 SIGTERM 後,ASP.NET Core 會:

  1. 停止接受新的連線
  2. 等待正在處理中的請求完成
  3. 呼叫所有 IHostedService 的 StopAsync,讓背景工作收尾

等待的上限由 HostOptions.ShutdownTimeout 決定(.NET 6 之後預設 30 秒),要確保它加上 preStop 的時間,小於 terminationGracePeriodSeconds:

C#
builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromSeconds(25);
});

// 映像檔沒有 sleep 可用時,在應用程式裡延後關閉,效果等同 preStop
var app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() => Thread.Sleep(TimeSpan.FromSeconds(10)));

用 ApplicationStopping 延後時,這 10 秒是在 SIGTERM 之後才開始算,所以會佔用 ShutdownTimeout 的時間,設定時要一起算進去。

背景工作(例如消費佇列的 BackgroundService)要檢查 stoppingToken,收到取消就停止拿新的工作、把手上這筆做完或放回佇列,而不是做到一半被砍掉。

長連線:WebSocket 與 SignalR

HTTP 請求幾秒就結束了,但 WebSocket 連線可能維持好幾個小時,舊 Pod 關閉時,連在上面的連線一定會斷。這沒辦法在伺服器端完全避免,要從架構上處理:

  • 用戶端一定要自動重連:SignalR 用戶端加上 withAutomaticReconnect(),斷線後會重新連到新的 Pod
  • 狀態不能只存在連線上:例如對話的進度、訂閱的頻道,重連後要能恢復。多實例下的做法可以參考 SignalR 串流與多實例部署
  • 滾動更新的速度放慢:每次只換一個 Pod,避免所有用戶同時重連,把新 Pod 打爆

其他中斷:PodDisruptionBudget

除了部署,Pod 也會因為計畫中的維護被趕走:Node 要升級、叢集要縮減 Node 時,管理員會 kubectl drain,把那台 Node 上的 Pod 全部驅逐。

如果 3 個 Pod 剛好都在同一台 Node 上,drain 時3 個會一起被驅逐,服務就中斷了。PodDisruptionBudget(PDB) 就是用來限制這件事:

YAML
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: house-api
spec:
  minAvailable: 2          # 任何時候至少要有 2 個可用
  selector:
    matchLabels:
      app: house-api

有了 PDB,drain 一次只能驅逐到「剩下 2 個可用」為止,要等被驅逐的 Pod 在其他 Node 重建就緒後,才能繼續驅逐下一個。

幾個要注意的地方:

  • PDB 只管「自願中斷」:drain、叢集自動縮減這類主動驅逐。Node 突然當機、Pod 被 OOMKilled 不在它的控制範圍內
  • PDB 不影響 Deployment 的滾動更新,那是由 maxUnavailable 控制的
  • 不要設得太嚴格:3 個副本設 minAvailable: 3,等於任何 Pod 都不能被驅逐,drain 會一直卡住,維運人員只能強制處理
  • 搭配分散排程:用 topologySpreadConstraints 或 Pod Anti-Affinity,讓副本分散在不同的 Node,一台 Node 出事不會帶走全部

新舊版本會同時存在

滾動更新還有一個應用程式層面的影響:更新期間,v1 和 v2 會同時在處理請求。

  • 資料庫結構變更要向下相容:v2 要刪除一個欄位,如果 migration 先跑了,還在運作的 v1 就會出錯。正確做法是分階段:先部署「不再使用這個欄位」的版本,下一次部署才真正刪除欄位(Expand/Contract)
  • API 的回應格式要相容:前端可能先拿到 v2 的頁面、再呼叫到 v1 的 API,或反過來
  • 訊息格式要相容:v2 發出的新格式訊息,可能被還沒更新的 v1 Consumer 收到

使用情境:物件查詢 API 的部署設定

把上面的設定組合起來:

  • 3 個副本、maxSurge: 1、maxUnavailable: 0、minReadySeconds: 10
  • Readiness 檢查資料庫連線與設定載入;Liveness 只檢查行程是否還活著
  • preStop sleep 10 秒,terminationGracePeriodSeconds: 45,ASP.NET Core 的 ShutdownTimeout 25 秒
  • PDB minAvailable: 2,搭配 topologySpreadConstraints 讓 3 個 Pod 分散在不同 Node
  • 部署時用 kubectl rollout status 等待完成,失敗就 kubectl rollout undo 退回
Shell
kubectl rollout status deployment/house-api -n realty --timeout=5m
kubectl rollout undo deployment/house-api -n realty

部署期間持續對服務打請求驗證,整個滾動更新過程不會出現 5xx。

注意事項

  • Liveness 不要檢查外部依賴:資料庫一慢,所有 Pod 的 Liveness 一起失敗、一起被重啟,小問題直接變成全面中斷
  • 啟動很慢的應用程式要設 Startup Probe:否則 Liveness 可能在應用程式還沒啟動完時就判定失敗,Pod 一直重啟
  • progressDeadlineSeconds:新 Pod 一直無法就緒時,Deployment 在這個時間(預設 600 秒)後會標記為失敗。CI/CD 要檢查這個狀態,而不是 kubectl apply 成功就以為部署完成
  • 資源要預留:maxSurge 需要額外的資源,叢集剛好用滿時,新 Pod 會卡在 Pending,部署就卡住了
  • 不要只在測試環境驗證:測試環境流量小,轉發規則的時間差不容易出現問題。正式環境部署時要搭配監控,觀察 5xx 是否在部署期間上升

一句話總結

滾動更新要真的不停機,新 Pod 端靠 maxUnavailable: 0 加 Readiness Probe 確保就緒才接流量,舊 Pod 端靠 preStop 等轉發規則更新完、再讓應用程式在寬限時間內處理完手上的請求,長連線要靠用戶端自動重連,Node 維護時靠 PodDisruptionBudget 和分散排程避免一次被趕光;最後別忘了,更新期間新舊版本會同時存在,資料庫與 API 的變更都要向下相容。