從 Deployment 內建的滾動更新,到 Argo Rollouts 的金絲雀——以及出事時到底該怎麼退回去。
Wafer BI 的使用者正在看報表,你突然把服務關掉換新版,他的畫面直接報錯——這種「維護中」的體驗在 2026 年是不被原諒的。要做到零停機更新 (Zero Downtime),需要聰明的部署策略。
Deployment 預設就是滾動更新:起一個新 Pod → 等 Readiness Probe 通過(Day 14 的伏筆回收)→ 導流量 → 關一個舊 Pod → 重複到換完。搭配好好寫的探針,基本的零停機就有了。
控制節奏的是兩個旋鈕,寫在 helm/wafer-bi/templates/api-gateway.yaml:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 換版期間最多可以「多」出幾個 Pod
maxUnavailable: 0 # 換版期間最多可以「少」幾個可用 Pod
以 4 個 replica 為例,三種組合的差別很有感:
| 設定 | 換版期間的 Pod 數 | 特性 |
|---|---|---|
maxSurge: 1 / maxUnavailable: 0(我們用的) |
4~5 個,可用數永遠 ≥ 4 | 最安全,但必須有多一個 Pod 的資源餘量 |
maxSurge: 0 / maxUnavailable: 1 |
3~4 個,可用數可能掉到 3 | 不吃額外資源,代價是換版期間容量少 25% |
25% / 25%(K8S 預設值) |
4~5 個,可用數可能掉到 3 | 最快,但尖峰時段換版可能被打爆 |
兩個容易吃虧的細節:
maxUnavailable: 0 的隱藏前提是叢集排得出那顆新 Pod。節點資源不夠時,新 Pod 卡在 Pending、舊 Pod 又不准關,整個更新就永遠停在半路,而且服務看起來一切正常(因為舊版還在跑),你不主動看沒人會告訴你。progressDeadlineSeconds(預設 600 秒):超時後 Deployment 會被標記 ProgressDeadlineExceeded。但請注意——它只是把狀態標紅,不會自動回滾,這是很多人的誤解。真的要退,得靠下一節。滾動更新還有個天生限制:換就是全換。新版本有 bug 的話,滾動到最後所有使用者都會中獎,你只能眼睜睜看著錯誤率一路爬升。
rollout 的三個指令滾動更新的完整工具組其實只有三個動詞,平常沒事不會用到,出事的時候手要夠快:
# 盯著這次更新有沒有走完(CI 裡當閘門很好用,卡住會非 0 退出)
kubectl rollout status deployment/api-gateway -n k8sdemo --timeout=120s
# 看歷史版本(每個 revision 對應一個 ReplicaSet)
kubectl rollout history deployment/api-gateway -n k8sdemo
# 退回上一版 / 退回指定版本
kubectl rollout undo deployment/api-gateway -n k8sdemo
kubectl rollout undo deployment/api-gateway -n k8sdemo --to-revision=3
能退幾步由 revisionHistoryLimit 決定(預設 10)——K8S 是靠「保留舊的 ReplicaSet(replica 數為 0)」來記住歷史的,你在 kubectl get rs 看到一堆 DESIRED 為 0 的舊 RS 不是垃圾,那就是你的後悔藥。設成 0 等於自廢武功。
實測:開一個 4 replica 的 demo Deployment(跑的是我們自己的 api-gateway image,maxSurge: 1 / maxUnavailable: 0、progressDeadlineSeconds 縮短成 60 秒方便觀察),然後故意把 image 換成一個拉不到的 tag:

▲ 壞版本卡住的完整現場:rollout status 非 0 退出、舊版 4 個 Pod 一個都沒關、ProgressDeadlineExceeded 但不自動回滾、undo 之後舊 ReplicaSet 原地復活
這張圖有以下四個地方可以看:
rollout status 用 exit code 1 收場——這就是它在 CI 裡的價值,kubectl apply 永遠是成功的(宣告成功不等於部署成功),只有 rollout status 會告訴你「這版沒上去」ErrImagePull,但 AVAILABLE 還是 4——maxUnavailable: 0 說到做到,新 Pod 沒 Ready 就一個舊的都不准關。服務完全沒有受影響,而且也因此沒有任何人會發現出事了
ProgressDeadlineExceeded 出現了,然後……就沒有然後了——Available=True 依舊,K8S 只是把這次更新標記成失敗,它不會替你退回去。這件事必須你自己(或 CI)動手undo 之後看 ReplicaSet:壞的 revision 2 被縮到 0(留著當後悔藥),revision 3 直接沿用原本那個好的 RS——回滾不是重新部署一次舊版,是把舊的 ReplicaSet 叫醒,所以它才會這麼快順帶一提我們自己埋的雷:Helm 模板裡有一行
deployed-at: {{ now | date ... }}的 Pod annotation。它讓每次渲染出來的 Pod template 都不一樣,好處是強制重新部署,壞處是rollout history會被灌滿意義不明的 revision,而且在 ArgoCD 眼中永遠 OutOfSync。方便和乾淨,這裡只能挑一個。
這是我覺得整個系列最反直覺的一段。上面那個 kubectl rollout undo 打下去,在我們這套架構裡是沒有用的。
原因在 k8s/argocd-app.yaml 裡,我們自己寫的那行註解:
syncPolicy:
automated:
prune: true
# 如果有人手動改了 K8s(例如 kubectl edit),自動把它改回來,維持與 Git 一致
selfHeal: true
kubectl rollout undo 改的是叢集裡的 live 物件,而 Git 裡的 values.yaml 還寫著那個壞掉的 image SHA。對 ArgoCD 來說,你這叫「手動改了 K8S」——於是它盡忠職守地把壞版本蓋了回來,而且不會有任何錯誤訊息。
這件事我實際跑了一次:把我們 repo 裡的一份 manifest(k8s/base/observability/jaeger.yaml)交給一個 selfHeal: true 的 ArgoCD Application 管理,假設 Git 裡的 1.60.0 就是那個壞版本,然後用 kubectl rollout undo 退回 1.57.0:

▲ GitOps 底下的 rollout undo:undo 成功了 2 秒,就被 ArgoCD 的自動 Sync 蓋回壞版本,全程沒有任何錯誤訊息
兩秒。不是三十秒,是兩秒——undo 印出 rolled back、image 欄位確實變成 1.57.0,我還來不及切視窗,它就變回 1.60.0 了。而且證據鏈非常完整:
kubectl get events 裡那行 Initiated automated sync to '6bdc539...'——這串 SHA 就是我 repo 的 main HEAD,它是拿著 Git 的版本來壓你的initiatedBy: {"automated":true}——沒有任何人按過任何按鈕,這是控制器自己的意志phase=Succeeded。對 ArgoCD 來說,這就是一次正常的同步三層樓一次講清楚:
| 層級 | 指令 | 在我們這套 GitOps 下的下場 |
|---|---|---|
| K8S 物件 | kubectl rollout undo |
⚠️ 2 秒後被 selfHeal 蓋回壞版本,且靜默無聲 |
| Helm release | helm rollback wafer-bi 1 |
❌ 沒有東西可以退——叢集裡根本沒有 Helm release |
| Git commit | git revert <bump commit> |
✅ 唯一真正有效的回滾 |
中間那格要補充一下:Day 10 教的 helm rollback 不是騙人的,它在「你自己 helm upgrade --install」的環境完全有效。但 ArgoCD 是把 Chart 用 helm template 渲染成純 YAML 再 apply,Helm 在這裡只剩下「模板引擎」的身分。Helm 的版本歷史其實是存在 namespace 裡、type 為 helm.sh/release.v1 的 Secret,而在我們的叢集查下去是這樣的:
$ kubectl get secret -n k8sdemo -l owner=helm
No resources found in k8sdemo namespace.
一筆都沒有(截圖最後一段)。沒有 release 就沒有 revision,helm rollback 連要退什麼都不知道。同一個指令在不同架構下的有效性不同,這種事光看文件是看不出來的。
所以正確的緊急回滾長這樣:
# 1. 找到 CI 那筆自動 commit(Day 19 的 bump image tags)
git log --oneline --grep="deploy: bump image tags"
# 2. 把它 revert 掉——values.yaml 的 tag 退回上一個 SHA
git revert <bad-commit-sha>
git push
推上去,ArgoCD 偵測到 Git 變了,自動 sync 回舊版,滾動更新再跑一次。回滾在 GitOps 裡不是一個特殊操作,它就是一次普通的部署,只是方向朝後。
這裡也正好回收 Day 15 的伏筆——當初堅持給每個 image 打上 commit SHA 而不是只有 latest,就是為了這一刻:revert 之後 values.yaml 裡躺著的是一個明確、不可變、絕對拉得到的舊版本。如果當初寫的是 latest,你 revert 完 Git,叢集拉到的還是同一包壞掉的 image,哭都沒地方哭。
實務上還有以下兩件事情:
argocd app set wafer-bi-platform --sync-policy none。這等於暫時退出 GitOps,記得事後要開回來,不然哪天大家都忘了為什麼部署不動了。| 策略 | 做法 | 代價 |
|---|---|---|
| 滾動更新 | 逐步汰換 | 有 bug 全員中獎 |
| 藍綠部署 | 新舊兩套並存,流量一鍵切換、一鍵切回 | 資源雙倍 |
| 金絲雀發布 | 先放一小撮流量試水溫,分階段擴大 | 需要工具支援 |
金絲雀 (Canary) 這名字來自礦坑裡的金絲雀——讓一小部分請求先進新版本,有毒(有 bug)的話犧牲最小。
Argo Rollouts 提供一個 Rollout 資源,幾乎是 Deployment 的替身,但 strategy 換成可編排的發布劇本:
strategy:
canary:
steps:
- setWeight: 25 # 先放 25% 給新版本
- pause: {} # 無限暫停,等人工確認(或自動分析)
- setWeight: 50
- pause: { duration: 20s }
- setWeight: 100
直接在叢集上實際跑一次完整的金絲雀發布——v1 穩定運行 4 個 replica,發布 v2:

▲ Argo Rollouts 金絲雀實測:25% 暫停等確認 → promote → 100% 完成,舊版 ScaledDown
這張圖是整個發布的縮時攝影,有以下兩個關鍵時刻:
Status: ॥ Paused、SetWeight: 25——revision 2 只起了 1 個 canary Pod,另外 3 個還是 revision 1 的 stable。此刻新版本正在接 25% 的真實流量,你可以盯著監控慢慢看,不爽就 abort,流量立刻全部退回 stable 版本——這是第 4 節那三層樓之外的第四種退法,也是唯一能在「還沒全量」的時候就喊停的,受害者只有那 25%。Status: ✔ Healthy,revision 2 變成 stable 4 連發,revision 1 被 ScaledDown 光榮退役。整個過程服務沒有中斷過一秒。跟 Deployment 最大的差別就是那個 pause——發布從「一鍵到底的賭博」變成「可以隨時喊停的分段決策」。
現在的 pause 還是等人工按 promote,終極型態是接上 AnalysisTemplate:Argo Rollouts 自動查 Prometheus(Day 22 就會裝了),canary 的錯誤率超標就自動 abort 回滾,全程不需要人醒著。把「半夜發版」變成「睡覺發版」。
今天做的其實是同一件事的正反兩面:怎麼安全地往前推(滾動更新的旋鈕、金絲雀的分段放行),以及怎麼確實地退回來(rollout undo、helm rollback、git revert 三層樓)。前者大家都會查,後者要等出事那天才知道自己會不會——而出事那天你不會有時間查文件。
**自動化的部署鏈路有多強,回滾就必須走在同一條鏈路上。**繞過 GitOps 的急救,效期只有兩秒。
從 Day 15 到今天,我們把「push code」到「安全上線」整條路走完了:CI 建置 → GitOps 同步 → 金絲雀護航 → 出事能退。自動化篇還剩最後一塊拼圖——明天來掃描 Image 裡的安全漏洞,讓供應鏈也睡得安穩。