alembic stamp head が本番DBを壊した話
alembic upgrade head は正常終了した。CIのログにもエラーはない。なのにアプリが500を返し続ける。
原因を追うと、DBには存在するはずのカラムがなかった。Alembicは「もう最新だから何もしない」と言っている。矛盾しているようだが、alembic stamp head を使っていたことが全ての原因だった。
構成と経緯
対象システムはFastAPI + SQLAlchemy 2.0 + PostgreSQL 16のWebアーカイブアプリケーション。デプロイはGitLab CIで自動化しており、docker compose exec api alembic upgrade head を毎回実行する構成になっている。
DBの初期構築は init-db/01_schema.sql で行っている。PostgreSQL の公式イメージは初回起動時に /docker-entrypoint-initdb.d/ 内の SQL を実行する。ここに 01_schema.sql を配置することで、コンテナ起動と同時に全テーブルが作成される。この構成に後から Alembic を追加したため、初期構築済みのDBには alembic_version テーブルが存在しない。
この状態で alembic upgrade head を実行すると、マイグレーション001(初期スキーマ作成)が CREATE TABLE を発行し、「テーブルが既に存在する」エラーで落ちる。
そこでCI/CDのデプロイステップに以下のロジックを追加した。
# alembic_version テーブルが存在するか確認
ALEMBIC_EXISTS=$(docker compose exec -T db psql -U webarchive -tAc \
"SELECT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_name = 'alembic_version')")
【注意】この確認と `stamp` の実行の間に別のデプロイプロセスが走ると、二重 stamp が発生する可能性がある。並列デプロイが起きうる環境では、PostgreSQL の advisory lock(`pg_try_advisory_lock`)やデプロイキューで排他制御を行うことを検討する。
if [ "$ALEMBIC_EXISTS" = "f" ]; then
# init-dbで作成済みのDB → stampで現在のheadをマーク
docker compose exec -T api alembic stamp head
fi
# 常にupgrade実行
docker compose exec -T api alembic upgrade head
alembic stamp head は「マイグレーションのSQLを実行せず、alembic_versionテーブルにリビジョン番号だけを書き込む」コマンドだ。
【補足】alembic_version テーブルは version_num という単一カラムを持つ1行のテーブルで、Alembicはここを参照して「どこまで適用済みか」を判断する。stamp はこの値を直接 UPDATE/INSERT するだけであり、実際のDDLは一切発行しない。init-dbで作成したDBのスキーマはAlembicのheadと同じはず——そう考えてこのコードを書いた。
障害の発生
数週間後、supplementary_pages テーブルに status と url_hash カラムを追加するマイグレーション005/006を作成してデプロイした。
アプリは全ページの詳細表示で500エラーを返すようになった。
sqlalchemy.exc.ProgrammingError: (asyncpg.exceptions.UndefinedColumnError)
column supplementary_pages.status does not exist
SQLAlchemyのモデルは status カラムを期待しているが、DBにはない。alembic current を確認すると 006 (head) と表示される。マイグレーション済みのはずなのに、カラムが存在しない。
原因
時系列を追うと問題は明確だった。
[初期] init-db/01_schema.sql でDB作成
→ supplementary_pages に status カラムなし
→ alembic_version テーブルなし
[デプロイA] マイグレーション 001-004 がコードに存在
→ alembic_version なし → stamp head 実行
→ alembic_version = 004 と記録
→ 実際のスキーマ: 004相当(init-dbと一致)✓
[デプロイB] マイグレーション 005, 006 を追加してデプロイ
→ alembic_version あり → stamp スキップ
→ alembic upgrade head 実行
→ 004 → 005 → 006 を適用... するはずが
ここで問題:デプロイA時点で stamp head が
「004」ではなく「その時点のhead」を記録していたら?
実際に起きたのはこうだった。デプロイAの時点で、コードベースにはすでにマイグレーション005/006が含まれていた(同じリリースに含まれていた)。stamp head は「コード上の最新リビジョン」をマークするので、006 が記録された。
結果:
| 項目 | 値 |
|------|-----|
| alembic_version | 006 |
| 実際のDBスキーマ | 004相当(init-dbのまま) |
| alembic upgrade head の動作 | 「006で最新、何もしない」 |
【図解提案】以下のような状態遷移図を追加すると因果関係が視覚的に整理される:
デプロイA時点のコード: 001→002→003→004→005→006 (head)
↑
init-db実スキーマ: ████████████████████ |
alembic_version: stamp→ 006
デプロイB後:
alembic_version=006, 実スキーマ=004相当 → upgrade は何もしない
stamp は「このDBは指定リビジョンまで適用済み」と宣言するコマンドであり、実際のスキーマを検証しない。init-dbのスキーマがAlembicのheadと一致していなければ、嘘の宣言をすることになる。
修正
即時対応: CI/CDに安全策を追加
IF NOT EXISTS による条件付き ALTER TABLE を安全策として追加した。
# stamp で飛ばされた可能性があるカラムを条件付きで追加
docker compose exec -T db psql -U webarchive -c "
DO \$\$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'supplementary_pages' AND column_name = 'status'
) THEN
ALTER TABLE supplementary_pages
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_supplementary_pages_status
ON supplementary_pages(status);
END IF;
-- url_hash, started_at, completed_at も同様...
END \$\$;
"
これを alembic upgrade head の前に実行することで、stampで飛ばされたカラムがあっても補完される。
根本対策: stamp head を使わない
stamp head の代わりに、init-dbに対応する固定リビジョンを指定する方法が安全だ。
if [ "$ALEMBIC_EXISTS" = "f" ]; then
# init-db/01_schema.sql は 004 相当
docker compose exec -T api alembic stamp 004
fi
docker compose exec -T api alembic upgrade head
# → 005, 006 が実行される
これなら init-db に含まれないマイグレーション(005以降)が確実に適用される。ただし 01_schema.sql を更新するたびにstamp先のリビジョンも更新する必要がある。
【補足】固定リビジョンのIDは alembic history コマンドで確認できる。01_schema.sql と同等のスキーマを生成するマイグレーションの末尾リビジョンを指定する。IDは alembic/versions/ 以下のファイル名先頭の英数字(例: a3f2c1d8e9b0)と一致する。
もう一つの選択肢は、init-dbを廃止して Alembic に一本化することだ。初回デプロイ時に alembic upgrade head だけで完結するなら、stamp は不要になる。
stamp が危険になる条件
alembic stamp 自体は正当なコマンドだ。問題になるのは以下の条件が揃ったときに限られる。
- DBスキーマの作成元が Alembic 以外にもある(init-db, 手動DDL等)
- stamp 先が
head(動的)で固定リビジョンでない - init-db と Alembic のスキーマ定義が別管理で乖離しうる
1だけなら stamp で正しく対処できる。1+2が揃うと、マイグレーション追加時に乖離が発生する。1+2+3が揃うと、乖離の検出手段がなくなる。
【補足】乖離の事前検出には alembic check(Alembic 1.9+)が使える。これは autogenerate を利用して実際のDBスキーマとモデル定義を比較し、未適用の差分があれば非ゼロで終了する。CIのデプロイ前ステップに組み込むことで、stamp による乖離を本番適用前に検知できる。
init-db と Alembic を共存させるなら
完全にAlembicへ移行するのがベストだが、init-dbを残す理由がある場合(開発環境の素早いセットアップ等)は以下を守ると安全だ。
stampには固定リビジョンを指定する。headは使わない- init-db 更新時に対応リビジョンをコメントで明記する。
-- Alembic equivalent: revision 004 - CIで
IF NOT EXISTSの安全策を入れる。 stamp の前提が崩れても致命的にならない防御線
stamp head は「今のDBはAlembicのheadと同じスキーマだ」という強い主張をする。その主張が正しいことを保証できないなら、使うべきではない。