Alembic「Multiple head revisions」を防ぐ ─ 並行PRのmigration head分岐を、マージ前の合成ツリー検査で止めた話

当ページのリンクには広告が含まれています。
この記事の結論
並行して進むPRがそれぞれマイグレーションを足すと、各PRのブランチ単独ではheadが1つに見えるのに、両方がmainに入った時点でheadが2つになります。これを防ぐには、マージの前に「最新mainとPRをGitで実際に合成した結果」を検査し、その結果を検査したmainとPRのSHAに結び付けるのが確実だと考えた話です。mainかPRが動いたら、古い緑は使いません。

お疲れ様です!IT業界で働くアライグマです!

個人開発している開発関連ニュースのキュレーションサービス「DevPick」では、バックエンドのデータベース構造をAlembicで管理しています。2026年10月6日、並行していた2本のPRのマイグレーションが同じ親から枝分かれしたままmainへ入り、本番デプロイが3回続けて止まりました。復旧したあと、10月12日に「同じ種類の事故をマージ前に止める検査」を入れています。今回は、その検査が何を見て、何を見ていないのかを書きます。

目次

並行PRのmigrationがmainで合流して、本番デプロイが3回止まった

Alembicのマイグレーションは、revisionと呼ばれるファイルが1つずつ連なる形で管理されます。各revisionは「直前のrevision(down_revision)」を1つ指し、その連なりの先頭がheadです。通常はheadが1つで、alembic upgrade head はそこまでを順番に適用します。ファイルの中身は、単純化すると次のとおりです。

# 単純化した例(revision名は実在しないダミー)
revision = "bbbb"
down_revision = "aaaa"   # 直前のrevision
depends_on = None
branch_labels = None

10月6日に入った2本のPRは、別々に進んでいて、それぞれがマイグレーションを追加していました。どちらも同じrevisionを down_revision にしていたので、両方がmainへ入ると、同じ親から枝が2本に分かれてheadが2つになります。Alembicは「どちらのheadまで進めるか」を決められないため、alembic upgrade head は「Multiple head revisions」というエラーで止まります。

実際に起きたのは次の流れです。

  1. 後から入った側のPRをマージした時点のデプロイが、Multiple head revisionsで失敗した
  2. その後に別のPRをマージしたデプロイも、同じエラーで失敗した
  3. さらにもう1本のPRのデプロイも、同じエラーで失敗した

つまり、デプロイ失敗は3回ですが、原因になった分岐は1回です。後ろの2回は、すでに壊れているmainに新しい変更を乗せて、同じ場所で止まっただけでした。後ろ2回のログでは、headが2つあるという診断が出た直後に、alembic upgrade head が終了コード255で止まっていました。この間、新しい変更が本番に出ない状態が続いています。分岐そのものは、のちのPRで2つのheadを親に持つ合流用のrevision(merge revision)を足して解消しました。どちらのPRも、それぞれのブランチ単独では問題のない変更でした。個々のPRを丁寧に見ていても、この事故は防げなかったということです。

IT女子 アラ美
デプロイが3回止まったけど、原因は1個なのね。後ろの2回は巻き込まれただけだわ。

ITアライグマ
はい。そのため、3件の別々の障害ではなく、1回の分岐事故として扱いました。

ブランチ単独のCIでは、どちらのPRもheadが1つに見える

厄介なのは、事故の直前まで、どちらのPRも「正しい」ことです。それぞれのブランチだけを見れば、親から子へ1本につながっていて、headは1つです。リポジトリの手順書にも、この限界は以前から書いてありました。PRのCIはそれぞれのブランチ単独ではheadが1つなので、検出できない、という内容です。

検査する対象ごとに見ていくと、1本目のブランチ単独でも、2本目のブランチ単独でも、headは1つです。1本目がmainに入ったあとの2本目も、mainを取り込んでいなければ、そのブランチ単独ではやはり1つに見えます。

headが2つに見えるのは、両方が入ったあとのmainだけです。それはマージが終わったあとの話で、その時点でデプロイは止まります。

すでに入れていた検証も、この穴を埋めてはいませんでした。SQLiteとMySQLの両方でマイグレーションのupgradeとdowngradeを往復する検証は、PRのブランチを単独で動かします。PRで全テストが通ったツリーの記録を、main側のCIで流用する仕組み(verified-tree)も、記録できるのは「そのファイルの組み合わせで通った」ことまでです。mainが別のマイグレーションを足したあとに、headが1つのままかどうかまでは証明できません。

振り返ってみると、足りなかったのはテストの数ではなく、「どの状態に対してテストするか」という視点でした。必要だったのは、マージの前に、最新のmainとPRを実際に組み合わせた状態を検査することです。

IT女子 アラ美
テストは全部緑だったのに、組み合わせたら壊れるのね。見る場所が違ったってことか。

ITアライグマ
はい。検証の中身が弱かったのではなく、そもそも検査する対象が実際の状態より足りていませんでした。

最新mainとPR headを git merge-tree で合成し、そのツリーを検査する

最初に考えたのは、「どの状態を検査していれば、あの事故をマージの前に見つけられたのか」でした。答えは、最新のmainとPRを組み合わせた状態です。そこで、全PRのCIで動く専用のジョブを追加しました。やることは3つです。

  1. 検査した瞬間のmainのSHAと、PRのheadのSHAを取得する(ブランチ名ではなくSHAで固定する)
  2. git merge-tree --write-tree で、2つを合成した結果のツリーを作る
  3. 合成結果のツリーから、マイグレーションのファイルだけを読み出して検査する

git merge-tree --write-tree は、作業ディレクトリを書き換えずに、マージした結果のツリーオブジェクトをGitの中に作ります。チェックアウトやマージコミットを作る必要がありません。コマンドの骨格は、単純化すると次のとおりです。

# 単純化した例
git fetch --no-tags origin "$BASE_SHA" "$HEAD_SHA"

# 合成結果のツリーIDが1行目に出る(コンフリクトがあれば失敗する)
tree=$(git merge-tree --write-tree "$BASE_SHA" "$HEAD_SHA" | head -n 1)

# マイグレーションのディレクトリだけを列挙し、中身はblobとして読む
git ls-tree -rz "$tree" -- backend/migrations/versions
git cat-file blob "$BLOB_SHA"

ここで使うのは、PRのブランチ単独のツリーでも、2つのファイル一覧を足し合わせたものでもありません。Gitが実際にマージするときと同じ結果です。mainにだけ増えたマイグレーションのファイルも、その結果の中に入ります。この点はテストでも確かめています。mainにだけ存在するファイルが、合成結果に含まれることを確認するテストです。

Gitがコンフリクトで合成できなかった場合は、検査の失敗として扱い、先へ進みません。人が見て直すべき状況なので、ここで止まるのが自然だと考えています。

IT女子 アラ美
チェックアウトしないで合成だけできるのね。CIの中で軽く済むのは助かるわ。

ITアライグマ
はい。作業ディレクトリを触らず、合成結果のツリーだけをGitの中で読めます。

migrationをimportせず、ASTでliteralだけを読んでfail-closedにする

合成ツリーから取り出したファイルは、Alembic本体で読み込めばheadを数えられます。ただし、それには各マイグレーションのPythonファイルをimportすることになります。PRで追加されたコードを検査のために実行するのは避けたいので、検査用のコードはファイルをimportせず、標準ライブラリの ast で構文木として読むだけにしました。DBへの接続もDDLの実行もありません。

読むのは、トップレベルの revision、down_revision、depends_on、branch_labels の4つの代入です。値がリテラルで書かれていれば読み、そうでなければ止めます。

# 読める(リテラル)
revision = "bbbb"
down_revision = "aaaa"

# 止める(実行しないと値が決まらない)
down_revision = get_parent_revision()

ここで気をつけたのは、読めないものを「たぶん大丈夫」と推測して通さないことです。次のようなものは、すべて検査の失敗として扱います。

  • 値が動的に決まる、または同じ名前を2回代入している
  • depends_on や branch_labels を使っている(このリポジトリでは未対応)
  • 親のrevisionが存在しない、revisionが重複している、親子の関係が循環している
  • revisionが1つも見つからない(空のグラフ)
  • 通常のファイルではないもの、入れ子のディレクトリ、取得に失敗したもの

そのうえで、親として一度も指されていないrevisionを数え、ちょうど1つであれば通します。2つ以上なら、headが分岐しているとして止めます。

判定に迷うものは通さずに止める、という作りは、以前書いたCIジョブを省略する許可リストと同じ考え方です。こちらは「走らせる」側ではなく「マージさせない」側に倒しています。止めすぎて困るのは、手で直せば済む話です。一方、通しすぎて困るのは、今回のように本番のデプロイが止まることです。私は前者のほうがずっとましだと考えました。

自前で読む検査は、Alembic本体の判断とずれると意味がありません。そこで、現在のリポジトリのマイグレーションを検査用のコードに読ませた結果が、Alembic自身の ScriptDirectory が返すheadと一致することを、テストで確かめています。

IT女子 アラ美
自前でASTを読むのって、Alembicとズレたら怖くない?どう保証してるの?

ITアライグマ
はい。現在のグラフをAlembic本体の結果と突き合わせるテストを置いて、ずれたら落ちるようにしています。

検査後にmainやPRが動いたら、古い緑を無効にする

合成してheadを数えるだけでは、まだ足りないと考えました。検査した時点のmainが、マージの時点でも同じとは限らないからです。検査のあとで別のPRがmainに入れば、さっきの「headは1つ」は、もう別の組み合わせの話になります。

そこで、検査の結果を、検査したmainとPRのSHAに結び付けています。マージまでの流れは、順に次のとおりです。どれもマージの前に行います。まず、PRのCIで最新mainとPR headを合成して検査し、検査のあいだにmainやPRが動いていないかも読み戻します。マイグレーションに変更があるPRでは、このとき、mainとPR headと合成ツリーの3つのSHAを、CIの実行URLと一緒に専用のコミットステータスへ記録します。次に、マージ手順の確認段階で同じ検査をもう一度行い、記録と完全に一致するかを確かめます。ほかのマージ手順が動いていれば、待たずに拒否します。最後に、マージの直前にmainとPR headをもう一度読み戻し、変わっていれば止めます。PR headはGitHubの条件付きマージで固定します。

確認段階で一致を求めるのは、同じSHAの記録だけです。mainが進んだ、PRに追加のpushがあった、といった場合、古いCIの緑や、すでに取ってあった両DBでの検証記録、verified-treeは使えません。mainを取り込む、merge revisionを足すなどの修正をしたうえで、CIを最初からやり直します。

マイグレーションに変更がないPRも、合成後のグラフは検査します。ただし、記録は書き込みません。依存パッケージを更新するボットのように、読み取り専用のトークンで動くPRでも、ゲートが通れるようにするためです。

ゲートそのものが省略されないことも、テストで固定しました。この検査のジョブは、verified-treeの判定に依存せず、PRごとに必ず走ります。そして、「全テスト成功」を記録するジョブは、この検査の成功を条件に含んでいます。この2点をワークフローの定義から読み取って確かめるテストがあります。

古い結果を前提に判定してはいけない、という点は、以前書いたno-deployのPRをマージしたら前のデプロイまで消えていた話と同じ構造です。あのときは「直前のコミットだけ」を見ていました。今回は「PR単独」を見ていました。見る範囲が実際の状態より狭い、という点が共通しています。

回帰テストでは、次のような場面を再現しています。

  • 単独では正しい2本のPRが、1本目のマージ後に、2本目の検査で失敗する
  • あとから足したmerge revisionで、正しく合流できていれば許可する
  • mainまたはPR headが変わったら、検査の結果が無効になる
  • 記録のmain・PR head・合成ツリーのSHA、状態、CIの実行URLのどれかがずれている、または記録がないときは、マージを通せない
  • 取得に失敗したときは、マージを許可しない
  • 同じリポジトリの2つのマージ手順は、片方を拒否し、終了後にロックを解放する

IT女子 アラ美
緑を取ったあとに、mainが動いたら無効にするのね。確認した瞬間の話だけ信じるってことか。

ITアライグマ
はい。検査した対象のSHAと一致しない記録は、緑でも使いません。

これで防げないことと、置き換えないもの

この検査は、万能ではありません。手順書にも、防げない範囲を先に書いています。

まず、このゲートは、リポジトリのエージェントが使うマージ完了の手順に組み込んだものです。GitHubの画面から手でマージする場合や、別のマシンからマージする場合は、このロックを通りません。サーバー側で強制するには、ブランチ保護のような仕組みが要りますが、現在の非公開リポジトリでは、プランの制限でブランチ保護のAPIを使えません。GitHubのマージAPIには、「ベースのSHAが一致していること」を原子的に確かめる条件もありません。そのため、ロックを使わない操作が、最後の読み戻しのあとでmainを変える競合までは防げません。

次に、静的な合成グラフの検査は、SQLiteとMySQLでのupgrade/downgradeの往復検証の代わりにはなりません。今回の検査が見るのは、親子関係の形だけです。マイグレーションの中身が実際に動くかどうかは、これまでどおり往復検証で確かめます。

最後に、運用のルールも変えていません。すでにmainへ入ったrevisionの親は書き換えません。分岐したときは、書き換えではなく、merge revisionで合流させます。この検査は、そのルールを破らずに済むよう、分岐をマージの前に見つけるためのものです。

この検査を入れたあとに、マイグレーションを含む変更はまだmainに入っていません。そのため、実際に分岐を止めた実績は、この記事を書いた時点ではありません。テストでは、事故と同じ形を再現して失敗することを確かめています。

IT女子 アラ美
GitHubの画面から手でマージしたら素通りするのね。そこは割り切っているの?

ITアライグマ
はい。強制できる仕組みが使えない以上、できる範囲と、できない範囲を手順書に分けて書いています。

まとめ

並行PRのマイグレーションは、各PRのブランチ単独では正しく見えます。headが2つになるのは、両方がmainに入ったあとで、そのときにはデプロイがすでに止まります。

今回の対策で大事だったのは、次の3点です。

  • 見る対象を、ブランチ単独から「最新mainとPRを実際に合成した結果」へ変える
  • 合成結果は、PRのコードを実行せず、構文木から読めるものだけで判定し、迷ったら止める
  • 検査の結果は、検査したmainとPRのSHAに結び付け、どちらかが動いたら使わない

静的な検査は、往復検証を置き換えるものではありません。手動のマージまでは防げない、という限界も残っています。それでも、「各PRが緑でも、組み合わせると壊れる」という穴は、マージの前に機械的に塞げるようになりました。今の私は、並行して進むPRがある限り、検査の対象はブランチ単独ではなく合成後の状態であるべきだと考えています。

IT女子 アラ美
緑でも組み合わせると壊れるって、並行開発だとありがちよね。見る場所を変えるだけで防げたのか。

ITアライグマ
はい。まず検査する対象を実際の状態に合わせることが、一番効きました。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次