About

ローリングデプロイ中の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%保てという意味なので、新タスクが先に立ち上がり、旧タスクはそのあとで抜ける。

The new task migrates. The old tasks still take every request.Old tasksold code100% of traffic, column list cached at bootstoppedNew tasknew codedb:migratepuma boothealth check x2drain oldtakes traffic1 schema changed2 old tasks goneold code x new schema, 1 to 3 minutesWhat the old code does in that window (column dropped / renamed)INSERTfailslists every column it knows, incl. the dropped oneUPDATEokonly the changed columnsSELECT *okno column names in the SQLWHERE renamedfailsand reading it quietly returns nil

新タスクが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_columnINSERT失敗(partial_inserts = false)ignored_columnsのリリース → 削除のリリース
rename_columnINSERT・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つの場面

  1. ヘルスチェックの期限内で回る。 その案件はgrace 120秒 + unhealthy 10回 × 30秒 = 420秒。超えるとECSがマイグレーション途中のプロセスを殺す。disable_ddl_transaction! を使ったマイグレーションなら中途半端に適用されたまま残る
  2. デプロイ以外でも回る。 オートスケールでタスクが増えるたびに回る。未適用のマイグレーションが残っていれば、負荷が集中した瞬間に実行される
  3. 失敗してもデプロイを止められない。 失敗 → 起動中断 → タスク入れ替え → また失敗。止まったときには一部がもう適用済み
  4. タスクの数だけ同時に回る。 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(削除)を旧コードが完全に消えたのを確かめてから回せる

キーポイント

1

コンソールでActiveRecord::Base.partial_insertsを見る。falseならカラム削除でその区間のINSERTが全部壊れる

2

カラム削除はignored_columnsだけのリリース → remove_columnのリリース、の2回で出す

3

リネームは追加 → 両方書き込み + バックフィル → 読み出し切り替え → 削除、の4回に分ける

4

strong_migrationsで危ないマイグレーションを開発時点で止め、safety_assuredはレビューで問う

5

migrateがトラフィック切り替えの前か後かを確かめる。前なら削除・リネーム、後なら新カラムをすぐ読むコードが危ない

ユースケース

コンテナの起動スクリプトでdb:migrateを回しているRailsアプリでカラムを消す・リネームするとき ECS・Kamalでmigrateを起動時・デプロイ前ジョブ・デプロイ後フックのどこで回すか決めるとき
← Pumaのスロット枯渇とは?CPU 21%で502が6万件出た理由 password_digestカラムでDeviseのinspectが落ちる理由 →