Blue/Green デプロイで schema 変更を安全に通す: 3-phase deploy と feature flag
TL;DR
- Blue/Green の最中は「旧 DB wrapper(ORM / query builder / 手書きリポジトリ)が新 schema を叩く窓」が必ず生まれる
- wrapper 層の try/catch では救えない(型 / row shape が baked-in、INSERT のフォールバック値が無い、enum の switch が落ちる)
- 解は 「schema 変更」と「その schema を使うコード」を別 deploy に分ける
- 具体的には 3-phase deploy(Expand → Migrate → Flip & Contract)+ feature flag に運用を寄せる
- expand-only ルール(schema 変更は追加だけ、削除しない)は方向性として正しいが不十分。enum / 新 FK / NOT NULL 化など、書き込み側を gate しないと壊れる変更がある
全体像
3-phase deploy + flag の動きを sequence で見るとこうなる。
注意点が 2 つある。
- Phase 3 は deploy ではない — Phase 1 / 2 は実際の deploy だが、Phase 3 は「runtime での flag フリップ」と「contract migration」の 2 操作。"3-phase deploy" という呼称はやや雑だが、実務的には deploy パイプラインから両方を起動するため一括りにする
- flag は『書き込みを切り替えるバルブ』 — flag OFF の間は v1.4 コードが load されていても「新 schema への書き込み」は行わない。dual-write も backfill 起動も flag の内側。これにより blue/green が共存する間、新値が DB に流れ込まない
前提: なぜ DB wrapper 層では解けないか
ここで言う DB wrapper は、Prisma / Drizzle / TypeORM / SQLAlchemy / GORM のような ORM、あるいは Knex / sqlx のような query builder、さらには手書きの薄いリポジトリ層まで含む「アプリと DB の間に立つコード」全般を指す。
Blue/Green デプロイでは、新コード(green, v1.4 wrapper)と旧コード(blue, v1.3 wrapper)が同じ DB を一定時間同時に叩く。素朴な発想だと、wrapper を二段構えにして「v1.4 で失敗したら v1.3 にフォールバック」したくなる:
try {
// v1.4 db client
} catch (e) {
// v1.3 db client
}
これは動かない。理由:
| 壊れ方 | catch できる? | catch して何を返せる? |
|---|---|---|
| 新 enum value を読む | switch の default で受けるしかない | デフォルト動作 / データ欠落 |
| 新 NOT NULL FK | INSERT で例外 catch | 入れる ID が無い |
| check 制約強化 | 例外 catch | fallback 値も弾かれる |
| index 削除で N+1 化 | catch できない(slow なだけ) | — |
| カラム drop | SELECT で例外 | 旧コードは新カラムを知らない |
v1.3 wrapper は型もアプリコードも v1.3 の shape 前提で書かれている。catch の中に書ける救済が存在しない。これは Prisma 固有の話ではなく、型を生成する系の wrapper(Prisma / Drizzle / sqlc 等)でも、生 SQL を書く系(Knex / 手書きクエリ)でも同じ。型が無い言語でも、アプリコードが想定する row shape が固定なら同じ問題が起きる。
deploy のレイヤリング側を変えないと根本的に解けない。
解: 3-phase deploy
「1 PR = 1 deploy = schema + code」をやめて、schema の deploy と、その schema を使うコードの deploy を分割する。
[従来] schema + code を 1 deploy
→ blue/green の窓で v1.3 code が v1.4 schema を叩いて事故
[3-phase] schema だけ deploy → [間] → code を deploy
各 deploy 単体では blue/green が安全に両立する
Phase 1: Expand
schema 変更を superset として deploy する。「旧コードも壊れない範囲の追加」のみ。
- 新カラムは
NULL 許可またはdefault 付き - 新 enum value は追加のみ
- 新 FK は nullable
- index 追加は
CONCURRENTLY - check 制約は追加しない / nullable に対する制約だけ
この時点でコードは v1.3 のまま。blue / green とも新 schema を「無視できる superset」として動く。
Phase 2: Migrate(flagged)
新 schema を使うコードを deploy する。ただし feature flag は OFF。dual-write や backfill の経路もこのフェーズで仕込むが、いずれも flag 内に閉じ込める。
- green は v1.4 コードを持っているが、flag OFF なので「新カラムへの書き込み」「新 enum value の発行」「dual-write の old→new 経路」をまだ実行しない
- 既存データの backfill ジョブはコードとして deploy しておく。起動するかどうかは flag(あるいは別の起動 flag)で制御
- blue は v1.3 のまま走り続けるが、green が新値を書かないので「blue が読めない行」が生まれない
Phase 3: Flip & Contract
blue が完全に drain した(ALB target group の healthy count = 0)ことを確認してから flag を ON。
- flag ON の瞬間から、全ノードが v1.4 として振る舞う
- 一定期間(旧コードを戻す可能性が無くなるまで)置いてから、contract migration で旧カラム / 旧 enum 値 / 旧制約を削除
- rollback は flag OFF が第一手。コード revert より速い
phase ごとの状態と rollback path
各 phase で「何が deploy されているか / flag はどっちか / 戻す手段は何か」を並べると次の通り。
| DB schema | blue (v1.3) | green (v1.4) | feature flag | rollback path | |
|---|---|---|---|---|---|
| Phase 1 完了後 | superset | live | – | – | superset を戻す migration(重いが可能) |
| Phase 2 完了後 | superset | live | live | OFF | green を deploy から外す(flag を ON にしていないので writes は安全) |
| Phase 3: Flip 直後 | superset | drained | live | ON | flag OFF が第一手。コード revert より速い |
| Phase 3: Contract 後 | v1.4 | – | live | ON | forward fix のみ(旧 schema は無い) |
「rollback の重さ」は Phase が進むほど増える。contract が走った後は戻れない、という非対称性が 3-phase の本質。
feature flag の役割
flag は「コードと schema の deploy タイミングのズレを吸収するバルブ」。3-phase の Phase 2 → 3 の切り替えを安全に行うための装置。
- 書き込み側を必ず gate — 新 enum value / 新 FK / 新カラム writes は flag 内に閉じ込める
-
読み込み側は defensive に — switch には必ず default、
assertNeverは warn-only、Zod enum は.catch() -
flip は blue drain 完了後 —
aws elbv2 describe-target-health等で確認してから ON - rollback path — 障害時はまず flag OFF。schema 変更は残してもアプリ挙動は v1.3 相当に戻る
基盤は大げさにしなくていい。最小は env var 一行:
// utils/feature-flags.ts
export const flags = {
enableNewFeature: process.env.ENABLE_NEW_FEATURE === "true",
};
使う側(wrapper の API は何でもよい):
// 書き込み側を必ず gate
if (flags.enableVoiceCallAction) {
await db.actions.insert({
type: "voice_call", // 新 enum value
/* ... */
});
}
// 読み側は defensive に
switch (action.type) {
case "email": return handleEmail(action);
case "sms": return handleSms(action);
default:
// 未知の値 (= 新 enum value) が来ても落とさない
logger.warn({ type: action.type }, "unknown action type");
return skip();
}
migration 危険度判定表
| 変更 | phase 分割必要? | flag 必要? | 備考 |
|---|---|---|---|
| nullable カラム追加 | 不要 | 不要 | superset で安全 |
| default 付きカラム追加 | 不要 | 不要 | superset で安全 |
| enum value 追加 | 必要 | 書き込み側に必須 | blue の switch が落ちる |
| カラム drop | 必要 | 不要 | code 側で参照消す → blue drain → drop |
| enum value 削除 | 必要 | 書き込み停止に必要 | 書き込み停止 → backfill → drop |
| NOT NULL 化 | 必要 | 不要 | nullable のまま deploy → backfill → tighten |
| 新 FK (NOT NULL) | 必要 | 書き込み側に必要 | nullable で追加 → backfill → tighten |
| index 追加 | concurrent なら不要 | 不要 | CREATE INDEX CONCURRENTLY |
| カラム rename | 必要 | dual-write 制御に必要 | new add → dual-write → swap reads → drop old |
| check 制約強化 | 必要 | 必要 | 既存行で破れていないか先に backfill |
| 型変更 | 必要 | 必要 | 新カラム別名で add → dual-write → swap → drop |
dual-write の運用と落とし穴
Phase 2 で「old カラムと new カラムに同時書き込み」するパターン。注意点:
- 書き込みコストが二重になる — トランザクション内で 2 列更新、性能を測る
- 整合性ズレが起きる — old だけ書けて new が失敗したケースの retry / 監視を入れる
- 読み側を一気に切り替えない — まず new を書く、しばらく old から読む(fall-back available)、 backfill 完了後に new から読むに切り替え
- flag を消す PR を必ず別 ticket で立てる — 放置すると dual-write のコードが恒久化する
CI/CD への組み込み
最低限のチェック:
- PR template に migration 危険度欄 — 上記表で self-check
-
migration ファイル diff の自動検査 —
DROP COLUMN/ALTER TYPE ... DROP/NOT NULL追加を CI で検出して PR にコメント -
CodeDeploy の hook で smoke —
BeforeAllowTrafficで旧 schema 前提のクエリパスを叩いて、Phase 1 が superset になっているか確認 -
flag を残したまま PR merge を block —
flags.ts内の flag に「いつまでに削除するか」のコメントを必須化、超過したら CI fail
限界
3-phase deploy + flag でも消せないコスト:
- リードタイムが伸びる — 1 機能の roll-out が最短でも 3 deploy = 数時間〜数日
- dual-write の二重コスト — 性能と整合性の監視が増える
- flag が技術負債化しやすい — 「flag を消す」運用を CI で強制しないと残り続ける
- enum 削除は数週間スパン — 書き込み停止 → 既存 row backfill → blue drain → drop。短縮できない
- migration 順序ミスは依然として手作業の領分 — Phase 1 を 2 つの PR で並行してやると、interleave で詰む
まとめ
- Blue/Green の事故は「library で吸収しよう」と発想すると詰む
- 解は deploy のレイヤリングを変えること
- 3-phase deploy(Expand → Migrate → Flip & Contract)+ feature flag が現実的な industry standard
- 危険な migration を「危険」と判定するチェックリストと、書き込み側を必ず gate するコーディング規約が前提
- リードタイムは伸びるが、「画面真っ白」の事故コストよりは安い
Discussion