本番を止めないRailsマイグレーション入門|strong_migrationsで危険なDDLをデプロイ前に止める

危険なDDLと安全なDDLの比較。通常のadd_indexはテーブル全体をロックしてサイトが固まるが、algorithm: :concurrentlyとdisable_ddl_transaction!を使えば書き込みを止めずにインデックスを作成できる。strong_migrationsが危険な書き方をデプロイ前に検知して停止する。
  • このエントリーをはてなブックマークに追加

スポンサーリンク

マイグレーションを流したら本番サイトが固まった——そんなヒヤリとした経験はありませんか?

ローカルでは一瞬で終わったadd_columnやadd_indexが、行数の多い本番テーブルではテーブル全体をロックし、その間クエリが詰まってサイト全体が応答しなくなる。migration自体は成功しているのに、裏でメタデータロック待ちが連鎖して障害になる、というやつです。

この記事では、そういう「危険なmigration」を個人の注意力ではなく仕組みで止めるための定番gem、strong_migrationsの使い方と、安全なDDLの書き換え手順をまとめます。

まず結論:危険なDDLはデプロイ前に自動で弾く

先に結論です。strong_migrationsを入れて、危険なmigrationは書いた時点で(=デプロイ前に)エラーで止める。これが一番効きます。

このgemの良いところは、単に「危険です」と止めるだけでなく、安全な代替手順をエラーメッセージで教えてくれることです。「このカラム追加は危ないので、こう書き換えてください」というガイドがその場で出るので、DBの内部挙動に詳しくなくても事故を避けられます。

レビューで「この本番テーブル、何万行あるか意識した?」と毎回聞く運用は消耗します。人間のチェックをすり抜けるのが事故です。だからこそ、CIやローカルの実行時点で機械的に弾くのが現実的な答えになります。

危険なDDLと安全なDDLの比較。通常のadd_indexはテーブル全体をロックしてサイトが固まるが、algorithm: :concurrentlyとdisable_ddl_transaction!を使えば書き込みを止めずにインデックスを作成できる。strong_migrationsが危険な書き方をデプロイ前に検知して停止する。
同じインデックス追加でも、書き方でロックの挙動が変わる。strong_migrationsは左の「危険な書き方」をデプロイ前に止めてくれる

なぜ本番が固まるのか

そもそも、なぜmigrationで本番が止まるのでしょうか。代表的なパターンはこの3つです。

  • インデックスの追加:通常のadd_indexは、インデックスを張り終わるまでそのテーブルへの書き込みをブロックします。行数が多いほど時間がかかり、その間ずっとロックが続きます。
  • NOT NULL制約の追加:既存カラムにNOT NULLを付けると、Postgresは全行をスキャンして違反がないか確認します。この間、読み書きがブロックされます。
  • カラムの型変更・リネーム:テーブルの作り直し(リライト)が走り、その間テーブルがロックされます。

怖いのは、ロックそのものよりもロック待ちの連鎖です。1本の重いmigrationがテーブルを掴むと、後続のクエリがメタデータロック待ちの行列を作り、Webのリクエストが次々にタイムアウトしていきます。結果として「migrationは成功したのにサイトは落ちた」という状態になります。

strong_migrationsを導入する

導入はとても簡単です。Gemfileに追加してインストールし、ジェネレータを走らせるだけです。

# Gemfile
gem "strong_migrations"
bundle install
rails generate strong_migrations:install

これで準備は完了です。以降は普段どおりmigrationを書くだけで、危険な操作を検知すると次のようなエラーで止めてくれます(メッセージは操作によって変わります)。

=== Dangerous operation detected #strong_migrations ===

Adding an index non-concurrently blocks writes. Instead, use:

class AddIndexToUsers < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def change
    add_index :users, :some_column, algorithm: :concurrently
  end
end

「危険」と「正しい書き方」がセットで出るのがポイントです。あわせて、migration側に長めのstatement timeoutを設定してくれるので、アプリ側は短いtimeoutで守りつつmigrationは走らせる、という切り分けもできます。

安全な書き換え手順を1枚で

ここからが本題です。よく引っかかる操作を「危険な書き方 → 安全な書き方」で見ていきます。核になる考え方は「一発でやらず、分割する」です。

①インデックスは concurrently で張る

Postgresには、書き込みをブロックせずにインデックスを作るCONCURRENTLYがあります。Railsではalgorithm: :concurrentlyで指定します。ただしこれはトランザクションの外で実行する必要があるため、disable_ddl_transaction!をセットで書きます。

class AddIndexToUsers < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def change
    add_index :users, :email, algorithm: :concurrently
  end
end

disable_ddl_transaction!を書き忘れるとmigrationは失敗するので、その意味でも安全です。なお、同じmigration内で作ったばかりの新しいテーブルに張るインデックスは、まだ誰も使っていないのでこの対応は不要です。

②NOT NULL 制約は3段階で付ける

既存カラムへのNOT NULL追加は全行スキャンでロックが走ります。これを避けるには、先にチェック制約を「未検証」で追加 → あとから検証 → 本来のNOT NULLに置き換え、という順序で分割します。

# 1本目:制約を「検証しない」状態で追加(ここは一瞬で終わる)
class AddSomeColumnNullConstraint < ActiveRecord::Migration[8.1]
  def change
    add_check_constraint :users, "some_column IS NOT NULL",
      name: "users_some_column_null", validate: false
  end
end
# 2本目:既存行を検証してから、本来の NOT NULL に置き換える
class ValidateSomeColumnNull < ActiveRecord::Migration[8.1]
  def up
    validate_check_constraint :users, name: "users_some_column_null"
    change_column_null :users, :some_column, false
    remove_check_constraint :users, name: "users_some_column_null"
  end

  def down
    add_check_constraint :users, "some_column IS NOT NULL",
      name: "users_some_column_null", validate: false
    change_column_null :users, :some_column, true
  end
end

validate: falseの追加は瞬時に終わり、重い全行チェックはvalidate_check_constraintで行います。この検証は書き込みをブロックしないので、その間もサービスは動き続けます。分割することで「一瞬のロック」と「ブロックしない検証」に分けられる、というのがこの手順のメリットです。

③デフォルト値つきカラムは「定数」なら怖くない

「デフォルト値つきのadd_columnはテーブルを作り直すから危険」という話を聞いたことがあるかもしれません。これはPostgres 10以前の挙動で、現在のPostgres 11以降では定数のデフォルト値であればメタデータの更新だけで完了し、全行の書き換えは起きません。つまり、こう書いても安全です。

# 定数デフォルトなら Postgres 11+ では安全
add_column :users, :status, :string, default: "active", null: false

危ないのは実行するたびに値が変わる(volatile な)デフォルト、たとえばgen_random_uuid()のようなケースです。この場合は全行に別々の値を入れるため、テーブルの書き換えが発生します。カラム追加とデフォルト設定を分けて書きましょう。

def up
  add_column :users, :token, :uuid
  change_column_default :users, :token, "gen_random_uuid()"
end

④バックフィルはバッチ+トランザクション外で

既存行に値を埋める(バックフィルする)ときも注意が必要です。migrationは既定でトランザクションに包まれるため、その中で全行をupdate_allすると、更新が終わるまでテーブルを掴み続けます。

安全なバックフィルの鍵は「バッチで刻む・少し休む・トランザクションの外で回す」の3つです。disable_ddl_transaction!を付け、in_batchesで分割し、間にsleepを挟みます。

class BackfillUserStatus < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def up
    User.unscoped.in_batches(of: 10000) do |relation|
      relation.where(status: nil).update_all(status: "active")
      sleep(0.01) # DBに息継ぎさせる
    end
  end
end

こうしておくと、1回のロックが「1万行ぶんの短い更新」に収まり、その合間に他のクエリが差し込めます。全行を一撃で更新するのとは、本番への負荷がまったく変わってきます。

⑤型変更・リネームは「新カラム追加」で段階移行

カラムの型変更やリネームは、テーブルの作り直しやアプリとの不整合を招くため、strong_migrationsは直接の操作を止めます。安全にやるなら、次の手順で段階的に移行します。

  1. 新しいカラムを追加する
  2. アプリを「新旧どちらにも書き込む」状態にデプロイする
  3. 既存データを新カラムにバックフィルする(④の要領で)
  4. 読み取りを新カラムに切り替える
  5. 旧カラムへの書き込みを止める
  6. 旧カラムを削除する

手数は増えますが、各ステップは短いロックで済み、いつロールバックしてもアプリが壊れません。「無停止でやる」とはつまり「一発を諦めて分割する」ということです。

意図的に許可したいときは safety_assured

もちろん、テーブルが小さくて明らかに安全なケースもあります。そういうときはsafety_assuredでブロックを囲むと、そのmigrationだけチェックを通せます。

class RemoveLegacyColumn < ActiveRecord::Migration[8.1]
  def change
    safety_assured { remove_column :users, :legacy_flag }
  end
end

「危険と分かった上で、これは大丈夫」と明示する仕組みなので、レビューでも意図が伝わります。ただし乱用すると導入した意味がなくなるので、使うときはコメントで理由を残すのがおすすめです。

正直な線引き:どこまで神経質になるか

最後に、現実的なバランスの話です。すべてのmigrationをここまで丁寧にやる必要はありません。

  • 開発初期・小さいテーブル:行数が数千程度なら一瞬で終わるので、神経質にならなくてよいです。
  • 本番トラフィックが乗ってから:テーブルが育ち、常時アクセスがある状態で効いてきます。strong_migrationsを入れておけば、「気づかないうちに危険ラインを越えた」ときに自動で教えてくれます。

gemの選択肢としては、より積極的に「安全な形へ自動で書き換え・実行」まで踏み込むonline_migrationsもあります。まずは検知に徹するstrong_migrationsから始め、リネームやバックグラウンド移行のヘルパーまで欲しくなったらonline_migrationsを検討する、という順で十分だと思います。

まとめ

本番を止めないマイグレーションの要点を振り返ります。

  • 危険なDDLは個人の注意力ではなくstrong_migrationsで機械的に弾く
  • インデックスはalgorithm: :concurrently+disable_ddl_transaction!
  • NOT NULLはチェック制約→検証→置き換えの3段階
  • 定数デフォルトはPostgres 11+なら安全、volatileなデフォルトは分割
  • バックフィルはバッチ+throttle+トランザクション外
  • 型変更・リネームは新カラム追加で段階移行

共通しているのは「一発でやらず、分割する」という考え方です。仕組みで事故を防げるようになると、マイグレーションを流すときの緊張がぐっと減ります。

本番DBまわりの構成は、Solid系gemを使ったときのdatabase.ymlの書き方やKamal 2でのデプロイ手順もあわせて読むと、運用の全体像がつかみやすいと思います。仕様が変わりやすい領域なので、迷ったら公式ドキュメントで一次情報を確認する習慣もセットにしておくと安心です。

スポンサーリンク

  • このエントリーをはてなブックマークに追加