跳至主要內容
技術

按下 rollout undo 就沒事了?程式退得回去,資料庫可不會

按下 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雙寫:namefull_name 都寫,讀還是讀 name程式上線退回後這段時間只寫了 name,第 3 步會補
3回填:把舊資料的 name 複製到 full_name批次 Job讀的還是 name,回填失敗不影響線上
4改成讀 full_name,但還是雙寫程式上線兩欄都是最新的,改回讀 name 沒問題
5不再寫 name程式上線退回第 4 步沒事,它讀的是 full_name
6刪掉 namemigration唯一退不回去的一步,所以放最後

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 同時對同一個資料庫動手。LiquibaseFlyway 這類工具有鎖可以擋,但被擋下來的 Pod 也只是卡在那邊等。
  • 啟動時間被拖長。 migration 跑多久,每個 Pod 就晚多久才 Ready,整個 rollout 跟著變慢。probe 如果設得比較緊,migration 跑到一半 container 就可能被判定失敗、被重啟,留下沒釋放的鎖,或是跑一半的變更。
  • 權限會綁在一起。 在程式啟動時跑 migration,代表線上服務的 DB 帳號也得有 ALTERDROP 權限。拆成 Job 之後,migration 用另一組帳號,app 只要拿讀寫資料需要的權限就好。

拆開之後順序就很清楚:expand 的 migration Job 先跑完,再 rollout 新版程式。contract 的 Job 則是之後另一次上線的事。如果你用 Helm,可以掛 pre-upgrade hook;用 Argo CD 的話,可以掛 PreSync hook,讓工具幫你排好這個順序(Helm:Chart HooksArgo 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,退版本身就會變成第二次事故,而且通常比原本的問題還嚴重。

比較實際的做法是:

  1. 往前修。 在新版上趕快出 hotfix。如果出問題的功能有 feature flag,先把它關掉止血,再來修。
  2. 從備份還原,是最後手段。 還原會把資料庫帶回備份那個時間點,之後寫進來的資料全部不見。就算有 point-in-time recovery,還原到 migration 之前,migration 之後的寫入還是得另外想辦法補回來。

說到底,真正的防線在前面:破壞性變更拆出去、晚點做,做之前先確認備份真的還原得回來。等走到「已經不可逆」這一步,手上剩下的選項沒有一個是好的。

有趣發現

整理到最後我發現,rolling update 本身就是一次小型的「舊程式配新 schema」演練:新 Pod 還沒全部起來之前,舊 Pod 早就在跟新 schema、跟新版寫進去的資料打交道了,跟 undo 之後的狀態一模一樣。

所以,rolling update 撐不住的 migration,undo 也救不了。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →