🚦

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 で smokeBeforeAllowTraffic で旧 schema 前提のクエリパスを叩いて、Phase 1 が superset になっているか確認
  • flag を残したまま PR merge を blockflags.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