前提
bin/docker-entrypoint は起動時に db:prepareされる
# If running the rails server then create or migrate existing database
if [ "${1}" == "bin/rails" ] && [ "${2}" == "server" ]; then
./bin/rails db:prepare
fi
exec "${@}"
-
rails server起動時にdb:prepare - 本番では 複数コンテナが同時起動
- 結果として 複数プロセスが同時に migrate
大規模サービスでは、前段に migrate worker を置く設計ですが、ここでは Rails new の状態です。
例えば GitLab では migrate を 明確に別フェーズに分離しています。
https://docs.gitlab.com/development/migration_style_guide/?utm_source=chatgpt.com
Rails は advisory lock を使い、同時migrateを防いでいる
Rails(ActiveRecord)は、同時に複数の migration が走らないように PostgreSQL の advisory lock(アドバイザリロック) を使って migration 実行全体を排他します。
https://apidock.com/rails/v6.0.0/ActiveRecord/Migrator/with_advisory_lock?utm_source=chatgpt.com
ActiveRecord::Migrator#with_advisory_lock でロック取得を行い、ロックが取れない場合は ConcurrentMigrationError を投げる実装になっている。
https://apidock.com/rails/v5.2.3/ActiveRecord/Migrator/with_advisory_lock?utm_source=chatgpt.com
Rails の advisory lock は、CONCURRENTLY の内部ロックを制御しない
Rails の advisory lock は 「migration 全体を直列化する」ための論理ロックであり、
CREATE INDEX CONCURRENTLY の内部フェーズ
PostgreSQL が取得する relation lock(例:ShareUpdateExclusiveLock)
「他トランザクションの終了待ち」などのDB内部待ち
までは 制御できません。
つまり advisory lock は、
✅ 「migrate プロセス同士の排他」
❌ 「DB が内部で取るロックや待ちの安全性保証」
ではない、という整理になります。
https://www.postgresql.org/docs/current/explicit-locking.html?utm_source=chatgpt.com
CONCURRENTLY が “並列実行される状況” ではエラーになり得る
CREATE INDEX CONCURRENTLY は「書き込みを止めにくい」一方で、内部的には複数フェーズを持ち、他トランザクションとの待ち合わせやロック取得が発生します。
そのため、複数プロセスが同時に DDL を走らせる状況では、デッドロックが起き得ます。
process競合によるエラー例:
Process 60356 waits for ShareLock on virtual transaction
Process 60363 waits for ShareUpdateExclusiveLock on relation
ERROR: deadlock detected
このパターンは実際に「CREATE INDEX CONCURRENTLY 同士」でも発生し得ることが報告されています。
Rails + Docker 環境で、rails server 起動時に db:prepare(≒ db:migrate)を実行していると、
本番デプロイ時に複数コンテナが同時起動 → 同時 migrate という構図が生まれます。
その状態で add_index algorithm: :concurrently を含む migration があると、
テーブルが空でも deadlock が発生することがあります。
問題の本質は「データ量」ではなく「同時実行」
今回の事故の原因は、
- ❌ テーブルが重いから
- ❌ レコードが多いから
ではありません。
本質はこれです。
- ✅ 複数プロセスが
- ✅ 同時に
- ✅ DDL(特に index 作成)を実行すること
CREATE INDEX CONCURRENTLY は何をしているか(データ0件でも)
CREATE INDEX CONCURRENTLY は、
テーブルが空でも必ず複数フェーズで動作します。
概念的には以下の流れです。
- index 定義を system catalog に登録
-
ShareUpdateExclusiveLockを取得 - 他トランザクションの終了を待つ
- テーブル全体を再スキャン(※ 0件でも発生)
- index を有効化
重要な点
- データ件数に関係なく
- ロック取得
- トランザクション待ち
- catalog 更新
が発生する
- 「空テーブル = ノーロック」ではない
なぜ「0件なのに0.001秒以上かかる」のか
レコード0件なのに index に0.001秒以上秒かかるのはおかしい?
おかしくありません。
理由:
-
CONCURRENTLYは- 他トランザクションの終了待ちを含む
- DDL 同士でも待ち合う
- production では
- 常時接続が張られている
- idle transaction が残りやすい
- その結果
- データが無くても普通に秒単位で待つ
速さはデータ量ではなく、周辺トランザクションに支配される。
同時 migrate が走ると何が起きるか
構成:
- コンテナA:
CREATE INDEX CONCURRENTLY - コンテナB:別 migration / 同じ migration
発生する状況:
- A は B のトランザクション終了を待つ
- B は A が取得した relation lock を待つ
- 相互待ちが成立
- PostgreSQL が deadlock と判定
実際に出るエラー例:
"""
Process 60356 waits for ShareLock on virtual transaction
Process 60363 waits for ShareUpdateExclusiveLock on relation
ERROR: deadlock detected
"""
これは データ量では絶対に起きません。
同時 DDL 実行が原因です。
「concurrently が無ければ問題ない?」への正確な答え
結論
半分 Yes、半分 No
Yes 寄りの理由
-
CONCURRENTLYは- フェーズが多い
- 待ちが長い
- DDL 同士が絡みやすい
- deadlock が 顕在化しやすい
No 寄りの理由
- concurrently が無くても
- 同時に
add_column - 同時に
create_table - 同時に
add_index
は普通に衝突する
- 同時に
- deadlock にならなくても
- 長時間ロック
- 起動待ち
- タイムアウト
という形で事故る
根本原因は concurrently ではない。
「同時に migrate が走る設計」そのもの。
Rails は advisory lock を使っている(が、万能ではない)
Rails の migration は、
PostgreSQL の advisory lock を使って
同時 migrate を防ごうとします。
参考リンク:
- Rails Guides – Active Record Migrations
https://guides.rubyonrails.org/active_record_migrations.html - ActiveRecord migration source
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/migration.rb - PostgreSQL Advisory Locks
https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
ただし、以下の条件では崩れることがあります。
- PgBouncer の transaction pooling
- 接続方式・構成差
- migrate 以外の DDL が別経路で走る
- 異常終了後のロック待ち
そのため、
Rails がロックしてるから大丈夫
は 運用前提としては危険。
まとめ
- 問題は データ量ではなく同時実行
-
CREATE INDEX CONCURRENTLYは- データ0件でもロックと待ちが発生する
- concurrently は事故の 引き金
- 根本は 同時 migrate 設計
「空だから大丈夫」は開発環境の感覚。
本番は、偶然が設計の穴を正確に殴ってくる。