按下 rollout undo 就沒事了?程式退得回去,資料庫可不會
上線後出事,大家第一個反應大概都是:「先退版再說。」
在 Kubernetes 上,退版只要一行:
kubectl rollout undo deployment/api
等個幾十秒,舊版 Pod 回來,錯誤率掉下來,大家鬆一口氣。
最近我在整理 Kubernetes 的 runbook,寫到這行的時候突然卡住:等等,那如果這次上線有跑 DB migration 呢?
想了一輪,結論有點反直覺:rollout undo 退得回程式,退不回資料庫。這次回滾安不安全,其實早在寫 migration 的時候就決定了,跟按下 undo 那一刻沒什麼關係。
undo 其實只退了 Pod template,資料庫根本沒動
先搞清楚 undo 到底退了什麼。
Kubernetes 官方文件講得很直白:Deployment 只有在 Pod template(.spec.template)有改的時候,才會產生新的 revision;回滾的時候,也只有 Pod template 這一塊會被退回去(Deployments:Rolling Back a Deployment)。
講白話就是,undo 會幫你把 image、環境變數、資源設定這些 Pod 規格換回上一版。但下面這些它管不到:
- 已經跑完的 DB migration
- 新版已經寫進資料庫的資料
- 同名 ConfigMap / Secret 裡被改掉的內容
所以你以為退版之後「一切回到昨天」,實際上是:舊版程式,跑在新版的 schema 上。
舊程式碰上新 schema:加欄位多半沒事,刪欄位跟加 NOT NULL 就麻煩了
那舊程式配新 schema 會不會出事?要看 migration 做了什麼。我整理成一張表:
| migration 做了什麼 | 舊程式讀取 | 舊程式寫入 | 退版之後 |
|---|---|---|---|
| 加 nullable 欄位、加新表 | 不知道它存在,直接無視 | 不寫這欄,DB 自己補 NULL | 通常沒事 |
| 刪欄位、改欄位名 | 查不存在的欄位 → 報錯 | 寫不存在的欄位 → 報錯 | 一讀就壞 |
| 加 NOT NULL 又沒有 default 的欄位 | 沒影響 | INSERT 沒帶這欄 → 違反約束 | 寫入失敗 |
第一列我寫「通常沒事」,是因為還是有例外。如果舊程式有靠欄位順序的寫法,像是 INSERT 沒列欄位名,那連加欄位都可能中招。MySQL 這時要求每個欄位都要給值,表多一個欄位,值的數量就對不上,直接報錯。
第三列是最容易被忽略的。欄位有 default 的話,舊程式 INSERT 時資料庫會自己補值,還不會壞;會出事的是沒有 default 的 NOT NULL,不管是一開始就這樣加,還是回填完才補上約束。
還有一個坑跟 schema 無關:新版已經寫進去的「新格式資料」。像是多了一個新的狀態值、JSON 結構改過,舊版程式讀到不一定看得懂。
然後有件事我覺得很值得講:就算你根本沒回滾,這個問題也會發生。
rolling update 的過程中,新舊 Pod 本來就會同時在線。如果 migration 在新版 Pod 起來之前就跑完,那些還沒被換掉的舊 Pod,從那一刻起就已經在跟新 schema 打交道了。所以「刪欄位跟新版程式一起上」這種做法,不用等到回滾,上線當下就可能炸。
解法:一次上線只「加」,要「拆」等下一次
這個問題的標準解法叫 expand/contract,也有人叫它 parallel change。Martin Fowler 網站上有篇 Danilo Sato 寫的文章,把它拆成三個階段:expand(擴充)、migrate(遷移)、contract(收縮)(ParallelChange)。
套到 DB migration 上,我自己記成一句話:
一次上線只做向後相容的擴充;刪欄位、改名、加約束這種破壞性的收尾,留到之後另一次上線,等新版穩定、確定不會回滾了再做。
這樣做的好處是,不管哪一次上線出問題,資料庫都還停在「新舊兩版程式都能用」的狀態,undo 又變回那顆可以放心按的按鈕。
欄位改名,拆成六步來做
光講原則有點抽象,拿最常見的「欄位改名」來走一遍。
假設要把 users.name 改成 users.full_name。最直覺的做法是一行 RENAME COLUMN 搞定,但這一行下去,舊程式馬上就壞。
用 expand/contract 的話,會拆成六步:
| # | 做什麼 | 怎麼上 | 萬一要退回上一步 |
|---|---|---|---|
| 1 | 加新欄位 full_name(nullable) | migration | 舊程式根本不知道有這欄 |
| 2 | 雙寫:name 和 full_name 都寫,讀還是讀 name | 程式上線 | 退回後這段時間只寫了 name,第 3 步會補 |
| 3 | 回填:把舊資料的 name 複製到 full_name | 批次 Job | 讀的還是 name,回填失敗不影響線上 |
| 4 | 改成讀 full_name,但還是雙寫 | 程式上線 | 兩欄都是最新的,改回讀 name 沒問題 |
| 5 | 不再寫 name | 程式上線 | 退回第 4 步沒事,它讀的是 full_name |
| 6 | 刪掉 name | migration | 唯一退不回去的一步,所以放最後 |
SQL 大概長這樣:
-- 第 1 步(expand)
ALTER TABLE users ADD COLUMN full_name VARCHAR(255) NULL;
-- 第 3 步(回填):按 id 分段跑完整張表,不要一條 UPDATE 掃全表
UPDATE users
SET full_name = name
WHERE id BETWEEN :start_id AND :end_id;
-- 第 6 步(contract):另一次上線才跑
ALTER TABLE users DROP COLUMN name;
幾個順序上的眉角,我覺得是整件事最容易踩雷的地方:
- 先雙寫,再回填。 順序反過來的話,從回填做完到雙寫上線中間這段時間寫進來的資料,
full_name就會漏掉。如果中間有退過版,回填就整批重跑一次,所以回填一定要寫成可以重跑的。 - 第 5 步要等第 4 步穩了再做。 停寫之後
name就不會再更新,這時候萬一一路退回第 3 步(還在讀name),讀到的就是過期資料。 - 第 6 步要等第 5 步穩了,而且確定不會再回滾。 刪之前先備份,順便確認所有會讀這張表的地方,像報表、其他服務、排程,都已經不碰
name了。
六步聽起來很多,但不一定要真的上線六次。第 1、2 步可以併在同一次:migration 先跑、程式再 rollout,就算程式退回去,多出來的欄位也不會影響舊版。唯獨第 6 步一定要自己一次,而且要等前面都穩了才做。
migration 我會拆成獨立的 Job,不在程式啟動時跑
另一個常見做法,是把 migration 塞進 container 的啟動指令:每個 Pod 起來先 migrate 一下,再開始服務。寫起來很省事,但我會避開,改成跑一個獨立的 Job。原因有三個:
- 多個 replica 會搶著跑。 Deployment 一次起好幾個 Pod,就有好幾份 migration 同時對同一個資料庫動手。Liquibase、Flyway 這類工具有鎖可以擋,但被擋下來的 Pod 也只是卡在那邊等。
- 啟動時間被拖長。 migration 跑多久,每個 Pod 就晚多久才 Ready,整個 rollout 跟著變慢。probe 如果設得比較緊,migration 跑到一半 container 就可能被判定失敗、被重啟,留下沒釋放的鎖,或是跑一半的變更。
- 權限會綁在一起。 在程式啟動時跑 migration,代表線上服務的 DB 帳號也得有
ALTER、DROP權限。拆成 Job 之後,migration 用另一組帳號,app 只要拿讀寫資料需要的權限就好。
拆開之後順序就很清楚:expand 的 migration Job 先跑完,再 rollout 新版程式。contract 的 Job 則是之後另一次上線的事。如果你用 Helm,可以掛 pre-upgrade hook;用 Argo CD 的話,可以掛 PreSync hook,讓工具幫你排好這個順序(Helm:Chart Hooks、Argo CD:Sync Phases and Waves)。
Job 大概長這樣:
apiVersion: batch/v1
kind: Job
metadata:
# Job 的 Pod template 建立後不能改,每次上線用新名字
name: api-migrate-v1-8-0
spec:
backoffLimit: 0 # 失敗先讓人看過,不自動重試
activeDeadlineSeconds: 900 # 超過 15 分鐘就判定失敗,不要無限卡住
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/api:1.8.0 # 跟這次上線的程式同一個 image
command: ["./migrate", "up"]
envFrom:
- secretRef:
name: api-db-migrator # 有 DDL 權限的另一組帳號
backoffLimit: 0 是故意的。migration 不一定是「全部成功」或「全部失敗」,像 MySQL 的 ALTER TABLE 這類 DDL 會隱式 commit(MySQL:Statements That Cause an Implicit Commit),一個檔案裡有好幾條 DDL,就可能停在一半。這種狀態讓它自動重試只會越弄越亂,不如先停下來讓人看一眼。
不可逆的 migration 已經跑了?往前修,別急著退
最慘的情況是:破壞性的 migration 已經跑完了(可能第 6 步做太早,也可能根本沒拆),然後新版又出包。
這時候 rollout undo 多半不是答案。舊程式碰上欄位已經被刪掉的 schema,退版本身就會變成第二次事故,而且通常比原本的問題還嚴重。
比較實際的做法是:
- 往前修。 在新版上趕快出 hotfix。如果出問題的功能有 feature flag,先把它關掉止血,再來修。
- 從備份還原,是最後手段。 還原會把資料庫帶回備份那個時間點,之後寫進來的資料全部不見。就算有 point-in-time recovery,還原到 migration 之前,migration 之後的寫入還是得另外想辦法補回來。
說到底,真正的防線在前面:破壞性變更拆出去、晚點做,做之前先確認備份真的還原得回來。等走到「已經不可逆」這一步,手上剩下的選項沒有一個是好的。
有趣發現
整理到最後我發現,rolling update 本身就是一次小型的「舊程式配新 schema」演練:新 Pod 還沒全部起來之前,舊 Pod 早就在跟新 schema、跟新版寫進去的資料打交道了,跟 undo 之後的狀態一模一樣。
所以,rolling update 撐不住的 migration,undo 也救不了。