ローリングデプロイ中のremove_columnでINSERTだけ落ちる理由
起動時のdb:migrateが守るのは新しいコンテナだけ。その間トラフィックを受けるのは旧コードで、partial_inserts = falseだと消したカラムをINSERTに入れてしまう
Railsをコンテナでデプロイしていて、起動スクリプトで db:migrate を回しているなら、その構成でなぜ remove_column が障害になるのかを扱う。読み終わると、カラムの削除・リネームが自分のアプリを本当に壊すかをコンソール1行で判断でき、リリースを2回に分けて安全に出せるようになる。
結論から。起動時のmigrateが守ってくれるのは、立ち上がる新しいコンテナだけだ。そのコンテナがmigrateしてヘルスチェックを通るまでの1〜3分、トラフィックを受けているのは旧コンテナで、旧コードは消したはずのカラムをまだ覚えている。
安全だと思っていた理由
仕事の案件の構成はこうだった。
# Dockerfile
CMD sh /ready.sh && /usr/bin/supervisord -c /etc/supervisord.conf
# ready.sh
bundle exec rake db:migrate
マイグレーションが終わるまでPumaはポートを開かない。だから新コードが旧スキーマを見ることはない。ここまでは正しい。問題は、図をコンテナ1台だけで描いていたことだった。
抜けていたのは旧タスク
ECSのローリングデプロイのデフォルトは minimumHealthyPercent = 100・maximumPercent = 200。healthyなタスクを常に100%保てという意味なので、新タスクが先に立ち上がり、旧タスクはそのあとで抜ける。
新タスクがmigrateを終えた瞬間(1)、スキーマはもう変わっている。Pumaが立ち上がり、ヘルスチェックを2回通り、旧タスクがドレインされて消えるまで(2)、リクエストは全部旧コードが受ける。これが1〜3分だ。
サーバーを1台に減らしても消えない。台数ではなく順番の問題だからだ。1台ならむしろ、その1台が全トラフィックを受ける。
実際に動かしたら、落ちたのはINSERTだけだった
旧プロセスが実際に何をするのか見るため、Rails 8.0.4 + SQLiteで再現した。モデルを一度読んでカラム一覧をキャッシュさせたあと、別の接続から legacy_status を消し、note を memo にリネームした。そのあと同じプロセスでクエリを投げた結果がこれだ。
Order.all OK SELECT "orders".* なのでカラム名を書かない
Order.where(note: 'n') 失敗 no such column: orders.note
Order.first.note nil エラーもなく空の値
o.update!(name: 'b') OK 変わったnameだけSET
Order.create!(name: 'c') 失敗 has no column named legacy_status
INSERTだけ落ちるのは partial_inserts のせいだ。false だと、INSERT文に変わった値だけでなく、モデルが知っているカラムを全部並べる。ログを見ると、誰も代入していない legacy_status が入っている。
INSERT INTO "orders" ("name", "legacy_status", "note") VALUES (?, ?, ?)
ActiveRecord自体のデフォルトは true だ。ところが load_defaults 7.0 以上だと false になる(railties 8.0.4 configuration.rb の when "7.0" ブロック)。このブログが動いているリポジトリも load_defaults 7.1 なので該当する。同じ再現で partial_inserts = true にすると、INSERTは name だけを書いて通った。
だから障害になるかどうかは一つで決まる。その1〜3分に、そのテーブルへINSERTが来るか。来れば全部失敗、来なければ静かに過ぎる。カラム削除を何度か無事に乗り切っているなら、そのテーブルへの書き込みが少なかった可能性が高い。
rename_column はもっと悪い。そのカラムで絞り込む・並べるSELECTまで壊れる。読み出しはエラーなしで nil になるので、500が出るほうがまだましだ。空の値が何食わぬ顔で画面に出てしまう。
順番を逆にしても鏡写しになるだけ
「じゃあアプリを先に出して、migrateを後にしたら?」このブログのリポジトリがまさにその構成だ。Kamalでデプロイし、migrateはデプロイが終わったあと post-deploy フックで db:prepare として回る。旧コンテナはもういないので、カラム削除はむしろ安全になる。代わりに今度は、新コードが旧スキーマを一瞬見る。add_column したカラムを新コードがすぐ読むと、その間は no such column だ。
実際、このフックが抜けていた間はサーバーのDBが schema.rb より遅れていて、ブログの画像を表示する画面が no such column: assets.slug で500を出した。フックファイル冒頭のコメントにその経緯がそのまま残っている。
無停止デプロイである以上、旧コードと新スキーマ(またはその逆)が同居する瞬間はなくせない。だから順番を合わせるのではなく、その瞬間に動いているコードがどちらのスキーマでも動くようにする。
解決 — リリースを2回に(expand / contract)
カラム削除はこう分ける。
# リリース1 — マイグレーションなし。コードにカラムを忘れさせる
class Order < ApplicationRecord
self.ignored_columns += %w[legacy_status]
end
# リリース2 — ここで消す
remove_column :orders, :legacy_status
リリース1にはマイグレーションがないので、危険な区間そのものがない。リリース2にも区間はできるが、そのとき生きている旧コードはもうそのカラムを知らない。上の再現に ignored_columns を入れて回し直したら、カラムを消したあとでもINSERTは ("name") だけを書いて通った。
リネームは一度にやらない。新カラム追加 → 両方に書き込み + バックフィル → 読み出しを新カラムへ → 旧カラム削除(上の2段階)、の4回だ。NOT NULL・UNIQUEの追加など他のケースは下の詳細の表にまとめた。
ルールをドキュメントに書くだけだと、いつか漏れる。strong_migrations を入れると、remove_column・rename_column などを開発時点で例外にして止めてくれる。わざとやるときは safety_assured { ... } で囲む必要があり、それがdiffに見えればレビューで「2回に分けた?」と聞くことになる。
migrateをどこで回すかは別の問題
起動時・デプロイ前の単独ジョブ・デプロイ後のフック、どこで回しても上の区間は残る。単独ジョブに切り出す理由は別にある。起動時のmigrateはオートスケールでタスクが増えるときにも走り、ヘルスチェックの期限を超えると途中で殺され、失敗してもデプロイを止められない。逆に、タスク1台・短いマイグレーション・オートスケールなしなら、起動時方式がいちばん簡単で、それで十分だ。3方式の比較と、GitHub Actionsに切り出したときにはまった罠は下の詳細にある。
自分のアプリでまず見るところ
ActiveRecord::Base.partial_inserts # false ならカラム削除でINSERTが壊れる
grep -rlE "remove_column|rename_column" db/migrate | wc -l # もう何回やったか
grep -rn "ignored_columns" app/models | wc -l # 0 なら分けて出したことがない
それから、migrateがトラフィックの切り替え前に回るのか後に回るのかを確かめる。前なら削除・リネームが、後なら追加したばかりのカラムを新コードがすぐ読むことが危ない。デプロイ中にだけ出るエラーの原因がつかめない、あるいはカラム整理を前にデプロイ順を一緒に組んでほしいなら、negabaro に気軽に聞いてほしい。
マイグレーションごとの分け方
| 変更 | 旧コードに起きること | 分け方 |
|---|---|---|
| remove_column | INSERT失敗(partial_inserts = false) | ignored_columnsのリリース → 削除のリリース |
| rename_column | INSERT・WHERE・ORDERが失敗、読み出しはnil | 追加 → 両方書き込み + バックフィル → 読み出し切り替え → 削除 |
| NOT NULL追加 | 旧コードがNULLを入れるとINSERT失敗 | NULLを入れないコード → バックフィル → 制約 |
| UNIQUEインデックス | 旧コードが重複を作ると失敗 | アプリ側の検証を先に → 重複整理 → インデックス |
| 型変更 | 広げる方向(int → bigint)はおおむね大丈夫 | 狭める方向は新カラムへ移すリネーム手順で |
| drop_table | そのモデルを使うクエリが全部失敗 | コードからモデルを消すリリース → 削除のリリース |
migrateをどこで回すか — 3方式
| 起動時(ready.sh) | デプロイ前の単独ジョブ | デプロイ後のフック(Kamal post-deploy) | |
|---|---|---|---|
| 同居する組み合わせ | 旧コード × 新スキーマ | 旧コード × 新スキーマ | 新コード × 旧スキーマ |
| 危ない変更 | 削除・リネーム | 削除・リネーム | 追加したカラムをすぐ読む |
| いいところ | イメージさえあればどこで立ち上げてもスキーマが合う。1行 | パイプラインごとに1回、失敗したらデプロイが止まる | 設定が軽い |
| 崩れる条件 | タスク複数・オートスケール・長いマイグレーション | CIにタスク起動の権限が要る | フックが抜けるとDBが静かに遅れる |
起動時migrateが崩れる4つの場面
- ヘルスチェックの期限内で回る。 その案件はgrace 120秒 + unhealthy 10回 × 30秒 = 420秒。超えるとECSがマイグレーション途中のプロセスを殺す。
disable_ddl_transaction!を使ったマイグレーションなら中途半端に適用されたまま残る - デプロイ以外でも回る。 オートスケールでタスクが増えるたびに回る。未適用のマイグレーションが残っていれば、負荷が集中した瞬間に実行される
- 失敗してもデプロイを止められない。 失敗 → 起動中断 → タスク入れ替え → また失敗。止まったときには一部がもう適用済み
- タスクの数だけ同時に回る。 PostgreSQLならadvisory lockのおかげで適用は1台だけ、残りは
ActiveRecord::ConcurrentMigrationError。リトライのループを入れることになり、起動が遅くなる
GitHub Actionsに切り出したときに引っかかったこと
- ✓
aws-actions/amazon-ecs-deploy-task-definition@v2のrun-task: trueは、サービス更新より先にワンショットのタスクを回し、exit codeが0でなければジョブを失敗させる。ポーリングを自分で書く必要はない - ⚠
aws ecs wait tasks-stoppedは6秒 × 100回 = 10分であきらめる。長いマイグレーションならアクションのwait-for-minutesを使う - ⚠アクションはタスク内の全コンテナのexit codeを見る。Webのタスク定義をそのまま使うとdatadogのようなサイドカーまで判定に入り、マイグレーションが成功してもデプロイが止まることがある。コンテナ1つだけのmigrate専用タスク定義を別に置く
- ⚠migrateジョブだけを回す
workflow_dispatchを用意しておくと、リリース2(削除)を旧コードが完全に消えたのを確かめてから回せる
キーポイント
コンソールでActiveRecord::Base.partial_insertsを見る。falseならカラム削除でその区間のINSERTが全部壊れる
カラム削除はignored_columnsだけのリリース → remove_columnのリリース、の2回で出す
リネームは追加 → 両方書き込み + バックフィル → 読み出し切り替え → 削除、の4回に分ける
strong_migrationsで危ないマイグレーションを開発時点で止め、safety_assuredはレビューで問う
migrateがトラフィック切り替えの前か後かを確かめる。前なら削除・リネーム、後なら新カラムをすぐ読むコードが危ない