デプロイとマイグレーションの事故集
Claude Codeのデプロイ事故は、派手な削除より静かに壊れるほうが怖い。成功表示の空振りやテストの誤爆を、防ぎ方と一緒にまとめた。
エージェントの本番事故と聞くと、データベースを丸ごと消した、といった派手な話が思い浮かびます。けれども編集部の検証環境で手間がかかったのは、成功と表示されたのに何も起きていない、テストのつもりが本番で動いていた、という静かな事故のほうでした。気づくのが遅れるぶん、戻すのも大変です。この記事では、そうした事故の形と、防ぎ方をまとめます。再現は、手元の一時ディレクトリでダミーのファイルだけを使って行いました(2026-10-16)。
「最新です」は成功ではない
マイグレーションを本番に当てるコマンドが、「最新です」という成功のメッセージで終わったのに、変更が入っていなかったことがあります。エラーは出ていないので、エージェントは「適用しました」と報告しました。
仕組みを知ると、これは不思議ではありません。マイグレーションの道具の多くは、適用済みのものを管理用のテーブルに記録し、まだ記録に無いものだけを実行します。記録の側が「適用済み」なら、実際のスキーマがどうであれ、何も実行されません。
手元で、この仕組みだけを真似たダミーを SQLite で作りました。管理用のテーブルには「適用済み」の記録だけがあり、実際のテーブルはまだ無い、という状態です。
Remote database is up to date.
items テーブル: 0
記録しか見ないので、「最新です」と言いながら、テーブルは1つも作られていません。記録と実物がずれるのは、管理画面から直接スキーマを変えた、記録だけを手で直した、といったときです。
そこで、成功の判定はメッセージの種類ではなく、「どのマイグレーションを適用した」という行が出たかどうかで行います。行が1つも無ければ、成功の表示でも「何もしなかった」と読みます。そのうえで、適用の状況を一覧で出すコマンドや、実際のテーブルの定義で確かめます。
公開されている例もあります。例えば Supabase CLI の公式ドキュメントは、適用済みのマイグレーションを管理用のテーブルで記録し、db push はローカルのフォルダとそのテーブルを比べて、まだ適用されていないものだけを順に実行する、と説明しています(出典:https://supabase.com/docs/guides/deployment/database-migrations 、2026-10-16確認)。GitHub の issue には、supabase db push が Remote database is up to date. と表示したのにリモートに変更が入っていなかった、という報告があります(出典:https://github.com/supabase/cli/issues/2136 、2026-10-16確認)。上のダミーの出力は、この表示を借りたものです。
ロールバックは、記録だけを戻すことがある
マイグレーションが失敗したとき、エージェントは管理用の記録を直して「戻しました」と言いがちです。上の Supabase CLI の公式ドキュメントは、記録を直すコマンド(migration repair)は管理用のテーブルを更新するだけで、SQL を適用も取り消しもしない、と注意しています(同出典)。
記録を戻すことと、スキーマを戻すことは別です。ロールバックのつもりで記録だけを直すと、記録と実物の食い違いが1つ増えます。戻すときは、戻す SQL を別のマイグレーションとして書き、それを当てて、実物を確かめます。
マージがそのままデプロイなら、先に当てる
main へのマージがそのまま本番のデプロイになる構成では、順番が問題になります。新しいコードが先に出て、それが前提とするテーブルや列がまだ無いと、その間はエラーになります。
編集部では、マイグレーションを含むプルリクエストは、マージの前に本番へ先に当てています。そのためには、マイグレーションが古いコードとも両立する形(列の追加は先、削除は後)で書かれている必要があります。エージェントに書かせるときは、「古いコードのままでも動くか」を必ず聞きます。
タイムゾーンの無い日時
日時の扱いも、静かに壊れます。PostgreSQL の公式ドキュメントでは、タイムゾーン付きの型に、タイムゾーンを書かない文字列を入れると、システムの TimeZone の設定のタイムゾーンとみなして UTC に変換する、とされています(出典:https://www.postgresql.org/docs/current/datatype-datetime.html 、2026-10-16確認)。
エージェントが書いたコードが、タイムゾーンの無い日時をそのまま渡していると、手元と本番で設定が違うだけで時刻がずれます。エラーは出ません。日時は、タイムゾーン付きで作って渡すようにします。この節は手元では再現しておらず、PostgreSQL の公式ドキュメントの記述に基づいています。
テストのつもりが、本番で動いた
検証のつもりで、本番の処理待ちの列に手書きのテストのジョブを置いたことがあります。本番の仕組みがそれを拾い、実際にメッセージを投稿しました。送ったメッセージは、こちらから消せないこともあります。
文章のルールで防ぐのはやめて、仕組みにしました。本番へ送る処理は、対話端末以外から呼ばれたら断ります。手元で、ダミーの送信スクリプトで確かめました。
== 非対話(cron 相当)から prod
refuse: 対話端末以外から本番には送らない
exit=4
== 非対話から test
send to test: (dummy)
exit=0
テストは、本番とは別の状態ディレクトリを使うか、送信しないモードで行います。正規の予約から来たのではないジョブは、本番では処理せず隔離します。
glob の削除は、名前を並べて消す
ファイルの片付けで、エージェントが glob を使った rm を打ち、消すつもりのなかった、コミット済みの資料まで巻き込んだことがあります。手元のダミーのファイルで、何が起きるかを見ました。
== 消すつもり: draft-1, draft-2, draft-final
rm draft-* が展開する先:
draft-1.md draft-2.md draft-final.md draft-notes-source.md
消すつもりの3つに加えて、名前の似た資料まで展開されます。いまは、消すファイルは名前を並べて指定し、rm -v で何を消したかを残すようにしています。コミット済みのファイルなら git から戻せますが、コミットしていないものは戻りません。
本番に触れる権限をどう分けるか
ここまでの事故は、どれもエージェントが本番に書き込めたから起きています。読み取りはエージェントに任せ、書き込みは人間が確かめてから、という分け方は「AIエージェントの権限設計」にまとめました。常駐のマシンで本番に近い処理を回すときの安全設計は「AIエージェントをVPSで24時間動かす」に書いています。
PR:次の文にアフィリエイトリンクを含みます。検証用の環境を本番と分けて持つなら、時間課金で作っては壊せる ConoHa VPS
のような国内VPSが手軽です。
まとめ
- 「最新です」は成功ではない。適用した行が出たかで判定し、実物で確かめる。
- 記録を直すことは、スキーマを戻すことではない。戻すときは戻す SQL を当てる。
- マージがデプロイになる構成では、マイグレーションを先に当てる。古いコードと両立する形で書く。
- 日時はタイムゾーン付きで渡す。
- 本番への送信は、対話端末以外から断る仕組みにする。
- 削除は glob ではなく、名前を並べて行う。