2024年4月9日
Kubernetes 滾動更新怎麼做到真的不停機?maxSurge、Readiness、preStop 與 PodDisruptionBudget
Pod、Deployment、Service 的基本關係可以先參考 Kubernetes 入門。這篇要回答的問題是:Deployment 明明就是滾動更新,為什麼每次部署還是有一小段錯誤?
答案是,「不停機」需要三段都顧到:
- 新 Pod:真的準備好了才接流量
- 舊 Pod:確定沒有流量了才關閉,關閉前把手上的請求處理完
- 其他原因的中斷:Node 維護、叢集升級時,不能一次把 Pod 全部趕走
滾動更新的流程
修改 Deployment 的映像檔版本後,Deployment 會建立一個新的 ReplicaSet,然後依兩個參數逐步替換:
maxSurge:更新過程中,最多可以比期望數量多幾個 PodmaxUnavailable:更新過程中,最多可以有幾個 Pod 不可用
預設都是 25%。以 4 個副本為例,代表最多可以多開 1 個、同時可以有 1 個不可用。
對線上服務,我會這樣設:
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 才會繼續下一步
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 3
Readiness 端點應該檢查「這個實例能不能正常處理請求」,例如必要的設定與快取載入完成、資料庫連得上。新版本如果一直無法就緒,滾動更新會停在那裡,舊版本的 Pod 還在,服務不會中斷,這是 Readiness 帶來的另一個保護。
舊 Pod 的關閉:最容易被忽略的 502
即使新 Pod 都正確就緒了,很多人還是會在部署時看到一小段 502。原因出在舊 Pod 關閉的方式。
一個 Pod 被刪除時,K8s 會同時做兩件事:
- 把 Pod 從 Service 移除:Endpoints 更新後,各個 Node 上的 kube-proxy、Ingress Controller 要各自更新轉發規則,這需要幾秒鐘
- 通知容器關閉:kubelet 執行
preStop(如果有),然後送出 SIGTERM
問題就在「同時」。應用程式收到 SIGTERM 就停止接受新連線,但轉發規則可能還沒更新完,還有請求被送到這個正在關閉的 Pod,結果就是連線被拒絕、502。
preStop:先等一下再關
解法是用 preStop 讓 SIGTERM 晚幾秒再送,給轉發規則足夠的時間更新:
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 會:
- 停止接受新的連線
- 等待正在處理中的請求完成
- 呼叫所有
IHostedService的StopAsync,讓背景工作收尾
等待的上限由 HostOptions.ShutdownTimeout 決定(.NET 6 之後預設 30 秒),要確保它加上 preStop 的時間,小於 terminationGracePeriodSeconds:
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) 就是用來限制這件事:
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 只檢查行程是否還活著
preStopsleep 10 秒,terminationGracePeriodSeconds: 45,ASP.NET Core 的ShutdownTimeout25 秒- PDB
minAvailable: 2,搭配topologySpreadConstraints讓 3 個 Pod 分散在不同 Node - 部署時用
kubectl rollout status等待完成,失敗就kubectl rollout undo退回
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 的變更都要向下相容。