Claude CodeでAlembicのマイグレーションを書く — 往復検証と本番DBの権限分離
Claude Codeにautogenerateの出力を直させ、upgradeとdowngradeの往復で検証し、本番DBの認証情報はClaudeの手元に置かない構成を設定例つきで示します。
Claude CodeでAlembicのマイグレーションを書く — 往復検証と本番DBの権限分離
Claude CodeにAlembicのマイグレーションを書かせるときの論点は2つあります。1つは生成物の誤りを機械的に見つける手順、もう1つは本番DBに触れない構成です。
前者は、alembic revision --autogenerateが出す候補を、upgradeとdowngradeの往復で確かめる流れで潰せます。後者は、Claude Codeの許可ルールだけに頼らず、本番の接続情報そのものを手元に置かないことで担保します。
Alembicのautogenerateは何を間違えるか
Alembicの公式ドキュメントは、autogenerateを「完全を意図していない機能」と位置づけています。候補のマイグレーションは、必ず人が見直して直す前提です。Claude Codeに任せる場合も、この前提は変わりません。
検出の可否は次のとおりです。
| 変更の種類 | autogenerateの扱い |
|---|---|
| テーブルの追加・削除 | autogenerateの扱い検出する |
| カラムの追加・削除 | autogenerateの扱い検出する |
| NULL許可の変更 | autogenerateの扱い検出する |
| インデックス・明示名つきUNIQUE・外部キーの基本的な変更 | autogenerateの扱い検出する |
| カラム型の変更 | autogenerateの扱い既定で検出(compare_typeをFalseにすると止まる) |
| サーバーデフォルトの変更 | autogenerateの扱いcompare_server_defaultを有効にしたときだけ |
| テーブル名・カラム名の変更 | autogenerateの扱い検出できない。追加と削除の組として出る |
| 名前のない制約 | autogenerateの扱い検出できない |
| 非対応バックエンドのEnum | autogenerateの扱い検出できない |
このうち実害が大きいのは、名前の変更です。users.nameをusers.full_nameに改名したつもりでも、生成物はdrop_columnとadd_columnの組になります。そのまま適用すると、列の中身が消えます。
Claude Codeは差分を読んで素直に「列を足して消す」マイグレーションを受け入れがちです。指示のなかに「改名はautogenerateで検出できない。op.alter_columnに手で書き換える」と入れておくと、この誤りは減ります。
def upgrade():
# autogenerateの出力は drop_column + add_column になっていた。
# データを残すため、改名として書き直す
op.alter_column("users", "name", new_column_name="full_name")
def downgrade():
op.alter_column("users", "full_name", new_column_name="name")指示に入れておく規約をCLAUDE.mdに置く
毎回口頭で伝えると抜けるため、プロジェクトのCLAUDE.mdに短い規約として置きます。FastAPIとSQLAlchemyのモデルを前提にした構成はClaude CodeでFastAPIバックエンドを構築する手順とSQLAlchemyのモデル実装にあります。ここではマイグレーション固有の部分だけを示します。
## マイグレーション(Alembic)
- スキーマ変更は models を直したあと `alembic revision --autogenerate -m "..."` で作る
- 生成物は必ず開いて直す。特に次を確認する
- 改名が drop_column + add_column になっていないか
- 制約に name= が付いているか
- downgrade() が pass のままになっていないか
- 検証は開発DBだけで行う。接続先は環境変数 DATABASE_URL に入っている
- 本番用の設定ファイルと本番の接続情報は読まない・実行しない
- マイグレーションを直したら、下の往復検証を最後まで実行して結果を報告する最後の1行が要点です。「実行して結果を読ませる」と書いておけば、Claude Codeは出力を確認してから完了を宣言するようになります。
upgradeとdowngradeを往復して検証する
Alembicのチュートリアルは、テンプレートのdowngrade()をpassだけにした状態でファイルを生成します。この空の関数はエラーなく成功します。つまり、downgradeを書き忘れてもalembic downgrade -1は何事もなく終わります。
「コマンドが成功した」では往復の検証になりません。スキーマが元に戻ったことまで確かめます。
# 開発DBで、現在のスキーマを控える
pg_dump --schema-only "$DATABASE_URL" > before.sql
alembic upgrade head # 最新まで進める
alembic downgrade -1 # 1つ戻す
alembic upgrade head # もう一度進める
# 戻したあとの状態を、控えと比べる
alembic downgrade -1
pg_dump --schema-only "$DATABASE_URL" > after_down.sql
diff before.sql after_down.sqldiffが空でなければ、downgrade()がupgrade()の逆になっていません。ここまでをClaude Codeに実行させ、差分の内容を読ませます。出力を読んで直す反復に入れるのが、人が見る前の1周目として有効です。
相対指定は公式のチュートリアルにあるものです。alembic downgrade -1で1つ前、alembic upgrade +2で2つ先へ進みます。alembic downgrade baseはすべて戻し、alembic history -r-3:currentで直近3つの履歴を見られます。リビジョンIDの先頭だけを書くalembic upgrade ae1のような部分指定も使えます。一意に決まらなければAlembicが止まります。
モデルとの食い違いはalembic checkで拾う
マイグレーションを適用した後、モデルとDBがまだ食い違っていないかはalembic checkで確認できます。このコマンドは--autogenerateと同じ比較をして、新しいリビジョンが必要ならエラーコードを返します。ファイルは生成しません。
alembic upgrade head
alembic checkNo new upgrade operations detected.と返れば、モデルとマイグレーションが揃っています。CIに組み込む用途も公式が挙げています。注意点は、compare_typeなどの設定とautogenerateの検出限界がそのまま適用されることです。改名や名前のない制約の食い違いは、checkでも見つかりません。
本番DBの権限を分ける
ここからが後半の主題です。マイグレーションは、定義上DDLを実行します。Claude Codeに開発DBで試させるのは構いませんが、本番の接続情報が手元にあれば、同じコマンドが本番にも通ります。
分け方の原則は、役割ごとに認証情報を別にすることです。
| 役割 | 持たせる権限 | 認証情報の置き場所 |
|---|---|---|
| Claude Codeが触る開発DB | 持たせる権限開発DB内で自由 | 認証情報の置き場所開発者のローカルの環境変数 |
| アプリの実行ロール | 持たせる権限本番のSELECT・INSERT・UPDATE・DELETE | 認証情報の置き場所本番のシークレット管理 |
| マイグレーション実行ロール | 持たせる権限本番のDDL | 認証情報の置き場所CIのデプロイジョブだけ |
アプリの実行ロールにDDLを持たせない点が、設計のいちばん効く部分です。アプリが侵害されても、テーブルの削除はできなくなります。また、Claude Codeの環境にはどの本番ロールも置きません。
MCP経由で本番に接続する場合の権限設計はMCPで本番環境に接続する権限設計で詳しく扱っています。許可ルールとバックエンド側の権限が別の層であるという考え方は、マイグレーションでもそのまま当てはまります。
alembic.iniに接続情報を書かない
Alembicの公式ドキュメントによれば、sqlalchemy.urlはenv.pyが参照して初めて使われる値です。接続先を環境変数など別の経路から取りたい場合は、env.pyを書き換えて取得方法を変えることが推奨されています。
つまり、本番の接続情報をalembic.iniに書く理由はありません。env.pyで環境変数から読み、値が無ければ失敗するようにします。
# alembic/env.py(抜粋)
import os
url = os.environ.get("DATABASE_URL")
if not url:
raise RuntimeError("DATABASE_URL が未設定です")
config.set_main_option("sqlalchemy.url", url.replace("%", "%%"))%を二重にしているのは、公式がalembic.ini系の設定値で%のエスケープを要求しているためです。パスワードに%が含まれるURLで落ちる原因になります。
こうしておけば、ローカルでは開発DBのURLだけが入り、本番のURLはCIのジョブにだけ渡す構成にできます。Claude Codeが本番に届く経路自体が無くなります。
許可ルールで本番の設定と直接実行を塞ぐ
認証情報を置かないことを土台にしたうえで、許可ルールを重ねます。.claude/settings.jsonの例です。
{
"permissions": {
"deny": [
"Read(./.env.production)",
"Read(./secrets/**)",
"Bash(alembic -c alembic.prod.ini *)",
"Bash(psql *)",
"Bash(pg_dump *prod*)"
]
}
}評価順はdeny、ask、allowの順で、最初に一致したものが結果になります。denyに一致した呼び出しは、より狭いallowがあっても止まります。Readのdenyは同じパスへのEditとWriteも塞ぎます。
ただし、Bashのdenyは境界として弱い点を知っておく必要があります。公式ドキュメントは、Bashルールがマッチするのは「Claudeが書いたコマンドの文字列」だけだと明記しています。たとえばBash(rm *)はrm -rf build/を止めますが、/bin/rm -rf build/やbash -c 'rm -rf build/'は止めません。上のpsqlも同じで、/usr/bin/psqlやsh -c経由は別の形です。
そのためBashのdenyは「いつも出るコマンドを止める保険」と捉え、本命は次の2つに置きます。
- 本番の接続情報が、そもそもClaude Codeのプロセスから見えない
- ファイルとネットワークの制限が必要なら、コマンド文字列に依存しないサンドボックスを併用する(公式も、コマンド文字列に依存しない強制にはサンドボックスを使うよう案内しています)
コマンドの全文を自前のロジックで検査したいときは、PreToolUseフックが使えます。フックはpermissionDecisionとしてdenyを返すことで、呼び出しを止められます。「alembicコマンドの引数にprodが含まれていたら拒否する」といった条件を、文字列の完全一致ではなく独自の判定で書けます。
本番への適用は人とCIに渡す
Claude Codeの仕事は、マイグレーションファイルを作って開発DBで検証するところまでです。本番への適用は、レビューを通ったあとにCIのデプロイジョブが行います。
DDLの実行権限を限定している組織向けに、Alembicは--sqlオプションも用意しています。upgradeやdowngradeのコマンドに付けると、DBへ接続せずSQLをテキストで出力します。公式ドキュメントは、DDLへのアクセスが制限され、SQLスクリプトをDBAに渡す環境を想定した機能だと説明しています。
# 現在のリビジョンから次のリビジョンまでのSQLを書き出す
alembic upgrade 1975ea83b712:ae1027a6acf --sql > migration.sqlこの方法なら、Claude Codeはmigration.sqlを生成するだけで、DBへの接続はそもそも不要です。DBAがその内容を読んで本番で実行します。--sqlモードではDBから値をSELECTする処理が使えないため、env.pyとマイグレーションの両方を両対応にしておく必要があります。
よくあるつまずき
往復検証がdowngradeで落ちる
upgrade()でデータの変換を入れた場合、downgrade()で元に戻せないことがあります。その場合は無理に対称にせず、downgrade()でNotImplementedErrorを投げる選択肢もあります。大事なのは、Claude Codeが黙ってpassを残さないことです。CLAUDE.mdに「戻せない場合は理由をコメントに書いて例外を投げる」と入れておきます。
Bashで生成したファイルにフックが効かない
alembic revision --autogenerateはBashコマンドが生成したファイルです。EditやWriteのPostToolUseフックは発火しません。生成直後の検証を自動化するなら、Bashに対するフックを別に用意します。詳しくはFastAPIバックエンドの記事にあるフックの節を参照してください。
Enumや名前のない制約が毎回差分に出る
非対応バックエンドのEnumは検出されません。名前のない制約も、比較できないため差分の原因になります。制約にはname=を必ず付ける、とCLAUDE.mdに書いておくと、Claude Codeが生成するモデル側のコードで揃います。
まとめ
Alembicのマイグレーションを任せるときの設計は、生成物を疑う手順と、本番に届かない構成の2点です。
- autogenerateは改名を追加と削除の組として出す。
op.alter_columnへの書き換えを規約にする downgrade()のpassは成功するため、往復後にスキーマの差分まで確認するalembic checkで、モデルとDBの食い違いをCIで見張る- 本番の接続情報はClaude Codeの環境に置かない。
env.pyは環境変数から読む - Bashのdenyは文字列照合で、境界にはならない。認証情報の分離が本体
Drizzleなど別のORMでの似た流れはClaude CodeでDrizzle ORMを使う手順にあります。