スキーマ進化とローリングデプロイの互換性
デプロイ中のスキーマ不整合でエラーを出さないために。新旧コードが同時に動く瞬間を前提に、expand/contractで破壊的変更を安全な多段に分解する原理と、前方・後方互換の作法がわかります。
- 1.ローリングデプロイ中は新旧コードが必ず同時にDBへアクセスするため、列のリネームや削除のような破壊的変更を1コミットで適用すると、片方のバージョンが必ず壊れる。
- 2.expand/contract(拡張収縮)パターンは、破壊的変更を「拡張→両対応コードへ移行→旧参照の除去→収縮」の後方互換な小ステップに分解し、どの瞬間でも新旧両コードが動ける状態を保つ。
- 3.コードとスキーマは別々にデプロイされ順序も保証されないので、各ステップは前方互換(旧コードが新スキーマで動く)と後方互換(新コードが旧スキーマで動く)の両方を満たす必要がある。
問題の起点:デプロイ中は新旧コードが必ず同居する
ローリングデプロイ(/devops/deployment-strategies/)は、サーバー群を一斉に置き換えず、一部ずつ新バージョンへ入れ替えます。その帰結として、更新が完了するまでの一定期間、旧コード(vN)と新コード(vN+1)が同時に本番で稼働します。この同居期間の長さは、置き換え戦略とインスタンス数で決まります(その台数計算は /devops/rolling-update-math/ を参照)。
ここで両バージョンが同一のデータベースを共有しているのが急所です。アプリのコードはローリングで段階的に切り替わりますが、共有DBのスキーマはある瞬間に切り替わる単一の状態しか持てません。つまり、
ある時点のスキーマ S に対して、
vN のコードも S 上で正しく動く必要がある(古い側)
vN+1 のコードも S 上で正しく動く必要がある(新しい側)
を同時に満たさねばなりません。列のリネーム email -> email_address を1回のマイグレーションで適用したとします。マイグレーション直後、まだ生き残っている vN は email を読みに行き、存在しない列で即座に落ちます。逆にマイグレーション前に vN+1 を出せば、vN+1 が email_address を探して落ちます。破壊的変更を1ステップで当てると、新旧どちらかが必ず壊れる——これがローリングデプロイとスキーマ進化の根本的な緊張です。
マイグレーションの適用とコードのロールアウトは別の操作で、一般に原子的でも同時でもありません。マイグレーションが先に走ることも、コードが先に出ることもあり、ローリング中はその両方の「途中」状態が観測されます。さらにロールバックも考えると、「新スキーマ+旧コード」「旧スキーマ+新コード」のどちらの組み合わせも一時的に成立しうると考えるべきです。だから安全なマイグレーションは、起こりうる組み合わせのどれでも動くことを要件にします。
前方互換と後方互換:二方向の両立が要件
この要件を二つの互換性に分解します。視点は「コードがスキーマをどう見るか」です。
- 後方互換(backward compatible):新しいコードが、古いスキーマ上でも正しく動く。新コードを先に出しても落ちない。
- 前方互換(forward compatible):古いコードが、新しいスキーマ上でも正しく動く。マイグレーションを先に当てても旧コードが落ちない。
ローリングデプロイの同居期間を生き延びるには、各マイグレーションが前方互換であることが決定的です。なぜなら、マイグレーション適用の瞬間にはまだ旧コードが動いており、その旧コードは新スキーマに対して「何も知らないまま」クエリを投げ続けるからです。新スキーマが旧コードのクエリを壊さない——これが前方互換の意味です。
安全なステップの条件:
スキーマ変更を当てた直後、
「変更を知らない旧コード」のすべてのクエリが
そのまま成功し続けること(= 前方互換)
列の追加(NULL 許容またはデフォルト付き)はこの条件を満たします。旧コードはその列を SELECT 句に書かず INSERT 時にも触れませんが、列が NULL 可かデフォルトを持つので旧コードの INSERT は成功します。一方、列の削除・リネーム・NOT NULL 化・型の縮小(例:bigint が int 未満へ)はいずれも前方互換を壊します。破壊的変更とは、本質的に前方互換を破る変更のことだと言い換えられます。
既存列を NOT NULL に変えると、その列に値を入れない旧コードの INSERT が制約違反で落ちます。新規列を NOT NULL で足すのも同じで、旧コードはその列を知らないため値を入れられません。デフォルト値を与えれば旧コードの INSERT は通りますが、「DBのデフォルト」と「新コードが書くはずの値」がずれると、同居期間中に生まれた行が後で意味的に不正になります。NOT NULL 化は単独ステップにせず、後述の expand/contract に組み込むのが定石です。
expand/contract(拡張収縮)パターン
破壊的変更を安全にする中心戦略が expand/contract(parallel change とも呼ぶ) です。発想は単純で、破壊的な1ステップを、前方互換な複数ステップに分解すること。各ステップ単独では何も壊さず、ステップ間には必ずデプロイの完了を挟みます。email -> email_address のリネームを例に、四段で追います。
[1] Expand(拡張):
新列 email_address を NULL 許容で追加(破壊なし・前方互換)
必要なら既存行を email -> email_address へバックフィル
[2] Migrate(二重書き+読み替え):
新旧両対応コードをデプロイ。
書き込みは email と email_address の両方へ(dual write)
読み込みは email_address を優先、無ければ email にフォールバック
[3] Cleanup(旧参照の除去):
email を一切参照しないコードをデプロイ。
二重書きをやめ email_address のみを使う
[4] Contract(収縮):
どのコードも email を読まなくなったことを確認し、
旧列 email を削除(このときには安全)
要点は、列の削除(収縮)が、その列をもう誰も読まなくなってから初めて行われることです。ステップ間でデプロイ完了を待つのは、「旧コードが本番から完全に消えた」ことを保証してから次の破壊度の高い操作へ進むためです。各ステップは「列追加」「両対応コードのデプロイ」「片対応コードのデプロイ」「列削除」であり、どれも単独では新旧コードを壊しません。
| 変更 | 一発適用(危険) | expand/contract(安全) |
|---|---|---|
| 列リネーム | 旧コードが旧名で落ちる | 新列追加→二重書き→旧参照除去→旧列削除 |
| 列削除 | 削除前のコードが参照して落ちる | コードから参照を消すデプロイ→確認→削除 |
| NOT NULL化 | 値を入れない旧INSERTが落ちる | デフォルト付与+バックフィル→新コード徹底→制約追加 |
| 型変更 | 旧コードが旧型を前提に落ちる | 新型の別列を足し二重書き→移行→旧列削除 |
書き込み側の INSERT も同じ理屈で対称に守られます。新列を追加する段階では旧コードはその列を知らないので、新列は NULL 許容かデフォルト付きでなければ旧コードの INSERT が壊れます。だから expand は必ず「足すだけ・緩めるだけ」の方向にし、「消す・厳しくする」方向の操作はすべて contract 側へ寄せます。
バックフィルと二重書きの一貫性
expand の後、既存行には新列の値が入っていません。これを埋めるのがバックフィルです。素朴に UPDATE table SET email_address = email を一発で流すと、大きなテーブルでは長時間のロック・WAL 膨張・レプリカ遅延を招きます。実務では主キー範囲でバッチに刻み、各バッチを小さなトランザクションでコミットしながら進めます。
# バックフィルの骨格(バッチ処理)
last = 0
loop:
UPDATE table
SET email_address = email
WHERE id が last 超かつ last+1000 以下
AND email_address IS NULL # 二重書きで埋まった行は触らない
last = last + 1000
if 影響行が0なら終了
(短い休止でレプリカ遅延を吸収)
ここで効くのが、ステップ[2]の 二重書き(dual write) との組み合わせです。バックフィルが過去の行を埋めている間も、新規・更新される行は二重書きで email_address が常に最新に保たれます。「過去はバックフィルが、現在以降は二重書きが」両側から埋めることで、収縮の時点で全行に値が揃います。二重書きを欠いたままバックフィルだけ行うと、バックフィル中に更新された行で email だけが新しく email_address が古い、という取りこぼしが生じます。
二重書きは「同一トランザクション内で email と email_address の両方を更新」しなければなりません。アプリ側で別々の文に分け、片方だけコミットされてクラッシュすると、二列の値が食い違ったまま残ります。これは即時のエラーにならず後から発覚する整合性破壊で、デバッグが困難です。可能なら単一の UPDATE 文で両列を同時に書く、それが無理なら同一トランザクションで束ねるのが鉄則です。読み込み側のフォールバック(新列優先・無ければ旧列)は、移行途中の不揃いを吸収する安全網として併用します。
ロック・ロールバック・運用上の落とし穴
スキーマ変更そのものがダウンタイムを生む経路にも注意が要ります。多くのDBで、列追加はメタデータ操作で済む一方、デフォルト値付きの列追加や型変更が全行の書き換え(テーブル全体の排他ロック)を伴うことがあります(バージョンに依存。近年の PostgreSQL は定数デフォルトの列追加を即時化しています)。安全側に倒すなら、まず NULL 許容で足し、デフォルトはアプリ側で書くかバックフィルで埋め、制約は後から追加します。
ロールバックも互換性の対象です。vN+1 に問題が見つかり vN へ戻すとき、スキーマは前進したままであることが普通です。expand/contract を守っていれば、新スキーマは前方互換なので vN は新スキーマ上でも動き続け、安全にコード側だけ巻き戻せます。逆に収縮(列削除)を急いで先に当ててしまうと、ロールバック先の vN が消えた列を参照して戻れなくなります。**「収縮は、そのバージョンへロールバックする可能性が消えてから」**が運用上の鉄則です。
ゼロダウンタイムのスキーマ進化でレビューすべきは四点です。(1) 各マイグレーションは前方互換か(旧コードが新スキーマで落ちないか)。(2) 破壊的変更は expand/contract に分解され、ステップ間にデプロイ完了の境界が挟まっているか。(3) 新列は NULL 許容またはデフォルト付きで追加され、NOT NULL 化・列削除は最後尾に置かれているか。(4) 二重書きとバックフィルが組で設計され、二重書きが原子的か。CI/CD(/devops/ci-cd/)やGitOps(/devops/gitops/)でマイグレーションを自動化する場合は、これらが「単一PRに破壊的変更を詰め込まない」よう機械的に守られる仕組みを併せて評価します。
これは結局、どのバージョンが何を読み書きしてよいかという一貫性の境界をデプロイの時間軸に沿って設計する問題で、状態一貫性の議論(/devops/consistency-models/)と地続きです。
まとめ
- ローリングデプロイ中は新旧コードが共有DBへ同時アクセスするため、破壊的変更を1ステップで当てると新旧どちらかが必ず壊れる。
- 安全なマイグレーションの要件は前方互換(旧コードが新スキーマで落ちない)。破壊的変更とは前方互換を破る変更のことだと捉える。
- expand/contract は、リネーム・削除・NOT NULL 化・型変更を「拡張→二重書き&読み替え→旧参照の除去→収縮」の前方互換な小ステップに分解し、各ステップ間にデプロイ完了の境界を置く。
- バックフィルは主キー範囲でバッチ化し、原子的な二重書きと組で過去と現在の両側から新列を埋める。読み込みは新列優先・旧列フォールバックで移行中の不揃いを吸収する。
- 列削除(収縮)は、その列を誰も読まなくなり、かつそのバージョンへロールバックする可能性が消えてから初めて行う。
DevOps/インフラ Article
スキーマ進化とローリングデプロイの互換性を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
スキーママイグレーション
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 5
導入後に効く点
expand/contract(拡張収縮)パターンは、破壊的変更を「拡張→両対応コードへ移行→旧参照の除去→収縮」の後方互換な小ステップに分解し、どの瞬間でも新旧両コードが動ける状態を保つ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「スキーママイグレーション / ローリングデプロイ」に近いか確認する。
- 強みである「ローリングデプロイ中は新旧コードが必ず同時にDBへアクセスするため、列のリネームや削除のような破壊的変更を1コミットで適用すると、片方のバージョンが必ず壊れる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。