データベースマイグレーションが実行中のアプリケーションを壊すとき
チームが新機能をデプロイした。ロールアウトは順調に見える。しかし5分後、オンコールエンジニアがエラーログのスクリーンショットを送ってくる。古いアプリケーションインスタンスがデータベースエラーでクラッシュしている。クエリ SELECT * FROM users WHERE status = 'active' が突然失敗している。何が起きたのか?
マイグレーションで status カラムを VARCHAR から INT に変更したのだ。新しいアプリケーションコードは整数を問題なく扱える。しかしローリングアップデート中は、新旧両方のアプリケーションインスタンスが並行して動作する。古いインスタンスは依然として文字列を期待している。データベーススキーマがその下で変わってしまい、壊れたのだ。
これが現代のデプロイにおけるデータベースマイグレーションの核心的な緊張関係である。データベースは共有されるが、アプリケーションバージョンは共有されない。
共有データベースの問題
ローリングアップデート、ブルーグリーンデプロイ、カナリアリリースでデプロイする場合、複数バージョンのアプリケーションが同時に実行される。それらはすべて同じデータベースに接続する。しかし各バージョンはスキーマに対して異なる期待を持っている。
古いアプリケーションは特定のカラム、データ型、制約を期待する。新しいアプリケーションは少し異なる構造を期待する。両方が移行期間中に正しく動作する必要がある。マイグレーションが古いアプリケーションとの互換性を壊すと、本番エラーが発生する。
これは理論上の問題ではない。マイグレーションが実行中のコードが依存するものを変更するたびに発生する。
後方互換性:譲れないルール
基本ルールは単純である。すべてのマイグレーションは古いアプリケーションと後方互換性がなければならない。古いコードは、マイグレーション実行後もエラーなくデータの読み書きができなければならない。
次の2つのSQLマイグレーションで違いを見てみよう。
-- 安全:デフォルト値を持つNULL許容カラムを追加
-- 古いアプリケーションはphone_numberを指定せずにINSERTできる
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) DEFAULT NULL;
-- 破壊的:カラム型をVARCHARからINTに変更
-- 古いアプリケーションのSELECT * FROM users WHERE status = 'active' は失敗する
-- 'active'は文字列であり、整数ではないため
ALTER TABLE users ALTER COLUMN status TYPE INT USING status::integer;
一部の変更は自然に後方互換性がある。たとえば、デフォルト値を持つNULL許容カラムの追加は既存のクエリを壊さない。古いアプリケーションの INSERT INTO users (name, email) は、新しい phone カラムがNULLを許容するため、引き続き動作する。
他の変更は即座に互換性を壊す。カラム型の変更、カラム名の変更、データが入っているカラムへのNOT NULL制約の追加、既存データが満たせない外部キーの追加は、古いアプリケーションインスタンスでエラーを引き起こす。
このルールはオプションではない。後方互換性を保証できない場合、ゼロダウンタイムで安全にデプロイすることはできない。
Expand-Contractパターン
破壊的変更を安全に扱う最も安全な方法は、Expand-Contractパターン(デュアルライトとも呼ばれる)である。アイデアは変更を段階的に行い、すべてのアプリケーションインスタンスが更新されるまで古い構造を削除しないことである。
次のシーケンス図はExpand-Contractパターンのタイムラインを示し、各フェーズで新旧アプリケーションインスタンスがデータベースとどのように相互作用するかを表している。
フェーズ1: Expand。 古い構造を削除せずに新しい構造を追加する。status(VARCHAR)を status_id(INT)に置き換えたい場合、古いカラムを維持したまま新しいカラムを追加する。新しいアプリケーションは両方のカラムに書き込む。古いアプリケーションは引き続き status を使用する。両方が動作する。
フェーズ2: データ移行。 古いカラムから変換した値で新しいカラムをバックフィルする。これはバックグラウンドジョブまたは別のマイグレーションステップとして実行できる。
フェーズ3: アプリケーションコードの更新。 新しいアプリケーションバージョンをすべてのインスタンスにデプロイする。これで実行中のすべてのインスタンスが両方のカラムを認識する。
フェーズ4: Contract。 別のデプロイで古いカラムを削除する。この時点では、実行中のアプリケーションは古いカラムに依存していない。
このパターンは複雑さを増す。アプリケーションコードは移行期間中にデュアルライトロジックを処理する必要がある。一時的に余分なカラムを維持する必要がある。しかしこれがデプロイ中のダウンタイムとエラーを回避するための代償である。
前方互換性:もう一つの方向
後方互換性は古いアプリケーションコードを保護する。前方互換性は、データベースが完全に移行されていない場合に新しいアプリケーションコードを保護する。
新しいアプリケーションを先にデプロイしたが、すべてのデータベースレプリカでマイグレーションが実行されていないシナリオを考えてみよう。新しいコードは新旧両方のスキーマ形式を処理する必要がある。status をVARCHARとして読み取るがINTを期待する場合、変換を適切に処理する必要がある。
前方互換性は達成が難しく、通常は限界がある。つまり、新しいコードは読み取るデータに対して防御的でなければならない。スキーマがすでに変更されていると想定してはいけない。これは多くの場合、マイグレーションが完了するまでアプリケーション層にフォールバックロジックやデータ変換を追加することを意味する。
カラム以外:インデックス、制約、外部キー
互換性はカラムとデータ型だけの問題ではない。インデックス、制約、外部キーも実行中のアプリケーションを壊す可能性がある。
新しい外部キー制約を追加すると、参照先データが存在しない場合に既存のINSERTやUPDATEクエリが失敗する可能性がある。以前は重複を許容していたカラムにUNIQUE制約を追加すると、重複値を挿入しようとするクエリがすべて壊れる。インデックスを追加するだけでも、インデックス作成中にデータベースがテーブルをロックするとパフォーマンス問題が発生する可能性がある。
すべてのスキーマ変更は、実行中のアプリケーションコードへの影響を評価する必要がある。自問してほしい。この変更は古いアプリケーションのクエリを失敗させるか?古いコードが予期しない方法で動作を変えるか?
安全なマイグレーションのための実践的チェックリスト
本番でマイグレーションを実行する前に、以下の点を確認する。
- 古いアプリケーションはマイグレーション後も既存のすべてのデータをエラーなく読み取れるか?
- 古いアプリケーションはマイグレーション後も新しいデータをエラーなく書き込めるか?
- 新しいカラムはすべてNULL許容か、デフォルト値を持っているか?
- 新しい制約は既存データに対してすでに成立しているか?
- マイグレーションはクエリをブロックするテーブルロックを引き起こすか?
- 何か問題が発生した場合のロールバック計画はあるか?
まとめ
ゼロダウンタイムデプロイ中のデータベースマイグレーションでは、スキーマをアプリケーションバージョン間の共有インターフェースとして扱う必要がある。すべてのマイグレーションは古いコードと後方互換性がなければならない。破壊的変更にはExpand-Contractパターンが必要で、最初に新しい構造を追加し、すべてのインスタンスが更新された後にのみ古い構造を削除する。
データベースはすべてのアプリケーションバージョンが共有する単一の真実源である。不注意に変更すると、実行中のコードを壊す。新旧両方のアプリケーションが安全に渡れる橋のようにマイグレーションを設計しよう。すべてのインスタンスが新しい側に移動した後にのみ、橋自体を変更すべきである。