DBマイグレーション運用の事故を防ぐ:ゼロダウンタイムで列を入れ替える順番
本番DBの列追加・削除で書き込みを止めない順番を、Prisma MigrateとSQLの実例で。expand→埋め→切替→contractの段階適用とロールバックの限界を僕の事故込みで解説。
金曜の夕方、僕はusersテーブルのname列を2つに分ける作業をやっていました。「ついでだから」と、列追加・データ移し替え・NOT NULL化・古い列の削除を、ひとつのマイグレーションにまとめて流したんです。
ステージングでは一瞬で終わりました。本番でも同じだろうと思った。
本番のusersは800万行ありました。ALTER TABLEがテーブル全体にロックをかけ、書き込みが数分止まり、APIがタイムアウトを吐き始めた。慌てて止めようにも、トランザクションの途中。あの数分は、今思い出しても胃が痛いです。
このとき骨身にしみたのは、マイグレーションの怖さはSQLの中身じゃなく「順番」と「一度にやる量」にあるということでした。スキーマをどう設計するかは別の話で、それは姉妹記事のClaude Codeでデータベース設計をレビューする実践ガイドに譲ります。この記事は、すでに決まった変更を本番で止めずに当てる運用だけに絞ります。
この記事の要点
- 本番のスキーマ変更は「expand(受け皿を広げる)→ backfill(埋める)→ 切替 → contract(片付ける)」の4段階に分ける。1つのマイグレーションに詰めない。
- 巨大テーブルへの
ALTERや巨大UPDATEはロックの地雷。CREATE INDEX CONCURRENTLYや小分けバッチで避ける。 - Prisma Migrateは
migrate dev --create-onlyでSQLを作り、目で確認してからmigrate deploy。deployはドリフト検出をしないので「CIが通った=本番が正しい」ではない。 - 戻せるのはアプリのバージョンとフィーチャーフラグまで。列をdropした後のデータはdownマイグレーションでは戻らない。
- Claude Codeは「SQLを書く係」ではなく「危険な順番を指摘するレビュー係」として使うと一番効く。
なぜ「一括マイグレーション」が本番で爆発するのか
ローカルや小さいテーブルだと、列追加もデータ更新もNOT NULL化も一瞬で終わります。だから油断する。僕がやったのもこれです。
でも本番の大テーブルでは、それぞれが別の地雷を踏みます。ALTER TABLEの多くはACCESS EXCLUSIVEという一番強いロックを取り、その間その表への読み書きが全部待たされます。巨大なUPDATEはWAL(更新ログ)を大量に吐き、レプリカ遅延とオートバキューム負荷を引き起こす。これを1本のマイグレーションに混ぜると、コケたときにロックなのかデータなのかタイムアウトなのか切り分けられません。
用語を先に揃えておきます。難しくないです。
| 用語 | かみくだくと |
|---|---|
| ロック | 同じ表を同時に壊さないための「待ち合わせ」。強いロック中は他が止まる |
| バックフィル | 既存の行に、新しい列の値を後から少しずつ埋める作業 |
| expand / contract | 先に受け皿を「広げ」、切り替え、最後に古いものを「片付ける」考え方 |
| ドリフト | マイグレーション履歴と実DBの状態がズレること |
この記事のゴールは、この地雷を踏まないための順番を体に入れることです。
ゼロダウンタイムの順番:expand → 埋め → 切替 → contract
止めない変更の鉄則は「先に広げて、後で片付ける」。古いアプリと新しいアプリが同時に動ける時間帯を作るのがコツです。例として、users.nameをfull_nameとdisplay_nameに分ける作業で流れを見ます。
flowchart LR
A["バックアップ確認とレビュー"]
B["expand: nullableで新列を追加"]
C["デプロイ: 新旧どちらの列も書く"]
D["backfill: 小バッチで既存行を埋める"]
E["検証: ステージングと本番メトリクス"]
F["contract: NOT NULL化・古い列を削除"]
A --> B --> C --> D --> E --> F
ポイントは、各段階を別々のデプロイ・別々のPRにすることです。expandだけのPR、backfillだけのジョブ、contractだけの後続PR。こう分けると、どこで何が起きたかが一目で分かるし、途中で止めても本番が壊れません。僕が金曜にやらかしたのは、この5本の矢印を1本にまとめたからでした。
ステップ1:expand(受け皿を広げる)
古い列を残したまま、新しい列をnullableで足すだけ。ここではNOT NULLもDROP COLUMNも絶対にやりません。
-- 20260602090000_expand_users_names.sql
-- このマイグレーションは小さく保つ。ここでbackfillもDROPもしない。
ALTER TABLE users
ADD COLUMN full_name text,
ADD COLUMN display_name text;
-- インデックスはCONCURRENTLYで。トランザクションの外で実行すること。
-- CREATE INDEX CONCURRENTLY はトランザクションブロック内では動かない。
CREATE INDEX CONCURRENTLY IF NOT EXISTS users_display_name_idx
ON users (display_name);
ADD COLUMNでデフォルト値を付けない(=nullableで足す)と、PostgreSQLはテーブル全体を書き換えずに済むので一瞬で終わります。逆にここでNOT NULL DEFAULT 'xxx'なんて書くと、バージョンによっては全行書き換えが走る。だからexpandは徹底的に軽くします。
ALTER TABLEが取るロックはサブコマンドで変わります。PostgreSQLのALTER TABLE公式ドキュメントと明示的ロックに、どの操作がどのロックを取るかが整理されています。Claude Codeにレビューさせるなら「ロックレベルを推測で書かず、公式ドキュメントに照らして保守的に判定して」と頼むと、思い込みSQLを弾けます。
ステップ2:アプリを新旧両対応にする
expandしたら、アプリを「読みは古い列優先、書きは新旧両方」に直してデプロイします。これで、まだfull_nameが空の行があっても落ちません。
// src/domain/userName.ts
type UserNameRow = {
name: string | null;
fullName: string | null;
displayName: string | null;
};
// 読み: 新→旧の順に、あるものを使う(移行途中でも落ちない)
export function readDisplayName(user: UserNameRow): string {
return user.displayName ?? user.fullName ?? user.name ?? "Unknown user";
}
// 書き: 新旧どちらの列にも同じ値を入れる(dual write)
export function buildNameUpdate(input: { name: string }) {
const normalized = input.name.trim().replace(/\s+/g, " ");
return {
name: normalized,
fullName: normalized,
displayName: normalized.length > 40 ? `${normalized.slice(0, 39)}...` : normalized,
};
}
読み取りの切替はフィーチャーフラグで包むと、DB変更とユーザー体験の切替を分けられます。DBは簡単に巻き戻せませんが、アプリの読み取り経路はデプロイやフラグで戻せる。この「戻せる側/戻せない側」の線引きが運用の肝です。フラグの寿命管理はフィーチャーフラグの段階的リリースをClaude Codeで防ぐにまとめました。
バックフィルは「小分け」が全て
ステップ3のバックフィル。ここでUPDATE users SET full_name = nameなんて一発で流すと、800万行ぶんのロックとWALが一気に来て、レプリカが遅延し、最悪また書き込みが詰まります。僕の二度目のヒヤリはここでした。
正解は、アプリ側のバッチスクリプトで少しずつ。これがコピペで動く本体です。
// scripts/backfill-user-names.mjs
// 1000行ずつ埋めて、間に少し休む。レプリカ遅延を見ながら速度を調整する。
import pg from "pg";
const { Client } = pg;
const batchSize = Number(process.env.BATCH_SIZE ?? 1000);
const sleepMs = Number(process.env.SLEEP_MS ?? 200);
const client = new Client({ connectionString: process.env.DATABASE_URL });
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
await client.connect();
try {
let total = 0;
while (true) {
// FOR UPDATE SKIP LOCKED: 他のバッチと衝突した行は飛ばす(並列実行に強い)
const result = await client.query(
`
WITH target AS (
SELECT id, name
FROM users
WHERE full_name IS NULL
AND name IS NOT NULL
ORDER BY id
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE users AS u
SET
full_name = target.name,
display_name = CASE
WHEN length(target.name) > 40 THEN substring(target.name from 1 for 39) || '...'
ELSE target.name
END
FROM target
WHERE u.id = target.id
RETURNING u.id
`,
[batchSize],
);
total += result.rowCount;
console.log(`updated=${result.rowCount} total=${total}`);
if (result.rowCount === 0) break; // 埋め残しがゼロになったら終了
await sleep(sleepMs);
}
} finally {
await client.end();
}
実行はこれだけです。
BATCH_SIZE=1000 SLEEP_MS=200 node scripts/backfill-user-names.mjs
このスクリプトの肝は3つ。WHERE full_name IS NULLでまだ埋めてない行だけを狙うので、途中で落ちても再実行すれば続きから埋まる(冪等)。FOR UPDATE SKIP LOCKEDで衝突行を飛ばすので、複数プロセスで並列に走らせても安全。SLEEP_MSで間を空けるので、レプリカ遅延を監視しながらアクセルを調整できる。
実運用ではusers.idの型やインデックス、実行時間帯、監視アラートに合わせて数字を変えます。Claude Codeには「並列実行されたら」「途中で落ちたら」「同じ行を二度更新したら」の3ケースをわざとレビューさせると、抜けが見つかります。
バックフィルが効く場面は、ユーザー名・住所・電話番号の正規化、注文合計や残高など既存データから計算できる列の追加、後付けの外部キーやインデックス。どれも「列追加 → アプリ切替 → 埋め → 検証 → 制約追加」と段階を割れば事故率がガクッと下がります。
contractとロールバックの限界
埋めきって、アプリが新しい列だけで安定動作するのを確認してから、最後のステップ4=contractに入ります。ここでもNOT NULLをいきなり付けず、NOT VALIDな制約で穴がないか先に確かめてから昇格させます。
-- 20260602120000_contract_users_names.sql
-- 新バージョンが本番で安定してから実行する。
-- NOT VALIDで足すと既存行を全スキャンしない(ロックが軽い)
ALTER TABLE users
ADD CONSTRAINT users_full_name_present
CHECK (full_name IS NOT NULL) NOT VALID;
-- 検証は別ステートメントで。ここで穴があれば気づける
ALTER TABLE users
VALIDATE CONSTRAINT users_full_name_present;
ALTER TABLE users
ALTER COLUMN full_name SET NOT NULL;
ALTER TABLE users
DROP CONSTRAINT users_full_name_present;
-- 古い列の削除は「読みを切り替えたデプロイ」とは別のデプロイで。
-- ALTER TABLE users DROP COLUMN name;
最後のコメント、DROP COLUMNを別デプロイにしているのが大事です。読み取りを切り替えたのと同じリリースで古い列を消すと、ロールバックした瞬間に存在しない列を読みにいって全面ダウンします。消すのは一拍おいてから。
ここで一番危ない誤解をはっきり書きます。「downマイグレーションがあれば戻せる」は嘘です。列をdropした後のデータ、型変換で丸めた値、上書きした履歴は、SQLを逆向きに流しても戻りません。戻せるのは多くの場合、アプリのバージョン・フィーチャーフラグ・まだ消していない読み取り経路だけ。DB側はバックアップ、ポイントインタイムリカバリ、または前向き修正(forward fix)で考えます。
Prismaのmigrate resolve --rolled-backも魔法ではありません。Prisma CLIリファレンスのとおり、これは失敗したマイグレーションの履歴状態を解決するコマンドで、成功した変更を元に戻すものではない。Claude Codeにロールバック案を出させるときは「データ復元が要る手順」と「アプリだけで戻せる手順」を必ず分けさせます。
Prisma MigrateとCIで段階適用を回す
Prisma Migrateを使う場合、生成SQLをそのまま本番に流さないのが鉄則です。--create-onlyでSQLを作り、目で見てから適用します。
# SQLファイルだけ作る(まだ適用しない)。中身をレビューしてから進める
npx prisma migrate dev --name expand-users-names --create-only
npx prisma validate
# 本番・ステージングへの適用はこれ。dev用のリセットは絶対に使わない
npx prisma migrate deploy
Prisma Migrateの開発と本番ガイドにあるとおり、本番適用はmigrate deployです。ただしdeployはドリフト検出をせず、shadow databaseにも依存しません。だから「CIでdeployが通った」だけでは本番DBが期待どおりとは言い切れない。ここを補うのがCIでのドリフト検査です。
GitHub Actionsで、PRごとに使い捨てPostgreSQLを立て、マイグレーションを頭から当て、最後にドリフトを検査します。
name: migration-check
on:
pull_request:
paths:
- "prisma/**"
- "scripts/backfill-*.mjs"
- ".github/workflows/migration-check.yml"
jobs:
prisma-migrations:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
ports:
- "5432:5432"
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app?schema=public
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npx prisma validate
- run: npx prisma migrate deploy
- run: npx prisma migrate status
- name: Detect schema drift after migrations
run: |
npx prisma migrate diff \
--exit-code \
--from-config-datasource \
--to-schema=prisma/schema.prisma
この例はPrisma ORM v7以降の--from-config-datasourceを使っています。古い記事にある--from-urlや--shadow-database-urlをそのままコピーしないでください。Prisma CLIリファレンスに、これらがv7で削除され、接続設定はprisma.config.tsへ移ったと明記されています。ここを知らずに古いコピペを当てると、CIがいきなり落ちます(僕は一度やりました)。Workflowファイルの書式はGitHub Actions公式を参照。
CIが通っても、ステージングでもう一段確認します。本番に近い行数、同じインデックス、同じタイムアウトで、EXPLAIN・ロック待ち・レプリカ遅延・バックフィル所要時間・アプリログをチェックリスト化させる。CIは「文法が通るか」、ステージングは「本番で詰まらないか」を見る、と役割を分けると迷いません。
Claude Codeを「実装係」ではなく「レビュー係」にする
最後に、Claude Codeの使い方です。マイグレーションでありがちな失敗は、いきなり「列を分けるマイグレーション書いて」と頼むこと。速いので、危険な順番のままファイルを完成させてしまいます。
そこで最初のプロンプトを実装依頼ではなくレビュー依頼にします。
このDBマイグレーション計画を、ファイルを編集する前にレビューして。
前提:
- 本番DB: PostgreSQL / ORM: Prisma Migrate
- 大テーブル: users 約800万行, orders 約2500万行
- デプロイ: blue/green、マイグレーションはCIで実行
- やりたいこと: users.name を full_name と display_name に分割
確認してほしいこと:
1. 新旧アプリは同時に動けるか
2. 強いロックや全表スキャンを起こすSQLはどれか
3. expand / backfill / validate / contract のどれに分けるべきか
4. デプロイ前に要るバックアップ/PITRの確認は何か
5. アプリのデプロイで戻せるものと、前向き修正しかないものはどれか
まず移行計画だけ返して。ファイルはまだ編集しないで。
「編集しないで」と明示するのがコツです。計画が怪しければ「DROP・大テーブル書き換え・backfill前のNOT NULL化を含まない形に書き直して。ステージング予行と本番の中止条件も入れて」と追撃する。人間が「このALTERは本当に今やるのか」を判断し、Claude Codeが「関連ファイル・CI・SQL・検証コマンドを抜け漏れなく並べる」。この分担にすると、出力も僕のレビューも一気に具体的になりました。
チームで使うなら、こういうルールをCLAUDE.mdに書いておくと毎回同じ品質になります。「本番DBではDROP COLUMNを同一PRに入れない」「大テーブルのbackfillはアプリスクリプトで小分け」「Prismaは--create-onlyでSQLレビュー」「公式ドキュメントURLをレビューコメントに含める」。何を書いて何を書かないかはCLAUDE.mdは「何を書かないか」で決まるにまとめています。
よくある質問
Q. expand/contractを使わず、メンテナンス画面を出して一括でやるのはダメ? 小規模で深夜にユーザーがいないなら、それも立派な選択です。expand/contractは「止めたくない」ときの手法。止めていいなら、バックアップを取って一括で当てる方がむしろ単純で安全なこともあります。トレードオフで選んでください。
Q. migrate deployがCIで通れば本番は安心?
いいえ。deployはドリフト検出もshadow DBもしません。文法と適用可否しか見ない。本番が期待どおりかはmigrate statusとmigrate diff --exit-codeでのドリフト検査、そしてステージング予行で別途確かめます。
Q. バックフィルのバッチサイズはいくつが正解?
唯一解はないです。1000行から始めて、レプリカ遅延とロック待ちを見ながら増減するのが現実的。遅延が伸びたらSLEEP_MSを上げる。深夜帯だけBATCH_SIZEを大きくする、という運用もよくやります。
Q. リネームしたいだけなのに、生成SQLがDROP+ADDになる。なぜ?
PrismaやORMは「リネームの意図」を完璧には読めないからです。nameをfullNameにしたつもりでも、生成SQLが列削除+列追加だとデータが消えます。だから生成SQLは必ず目視し、リネームは--create-onlyで手当てします。
Q. ロールバックはどう準備しておけば? 「DBは戻せない」前提で組みます。戻せるのはアプリのバージョンとフィーチャーフラグだけ、と割り切る。DB側はデプロイ前にバックアップ時刻・復元先・復元手順・責任者・想定復旧時間を紙に書いておく。これはツールではなくチームの運用責任です。
実際に試した結果
あの金曜の事故以来、僕がマイグレーションで一番効果を感じたのは「SQLを賢くすること」ではなく、一度にやることを減らすことでした。expandだけのPR、backfillだけのジョブ、contractだけの後続PR。3つに割っただけで、Claude Codeの出力も僕のレビューも嘘みたいに具体的になった。どこで何が起きるかが見えるからです。
Claude Codeは本番DB移行を自動で安全にしてくれる魔法ではありません。でも、expand→埋め→切替→contractという順番と、バックアップ確認・中止条件・復旧手順を先に書かせれば、レビュー漏れを潰す強力な相棒になります。賢い一発SQLを探すより、転んでも戻せる段階に割る。遠回りに見えて、本番事故を減らす一番堅い道がこれでした。
次の1件のマイグレーションで、まず「実装」ではなく「危険な順番を指摘するレビュー」を頼んでみてください。チーム導入のチェックリストは教材・テンプレートにまとめ、運用設計の相談はClaude Code研修・導入相談で受けています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。