- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- なぜロールバックは難しいのか
- 元に戻せる変更 vs 元に戻せない変更
- ロールバック可能なマイグレーションを書く
- バックアップと復旧ポイント
- 多段階の検証
- カナリアと段階的ロールアウト
- フィーチャーフラグで分離する
- 自動化されたデータ検証
- インシデント対応ランブック
- ポストモーテム
- 落とし穴
- 最終チェックリスト
- おわりに
- 参考資料
はじめに
デプロイを元に戻す作業は、たいてい簡単です。以前のバージョンのコンテナイメージを再び立ち上げるか、トラフィックを以前のデプロイメントに切り替えればよいのです。コードは冪等で、状態を持たず、いつでもきれいに置き換えられます。ところがデータベースマイグレーションは、この直感を正面から裏切ります。
マイグレーションは状態を変更します。カラムを落とせば、その中にあったデータは消えます。型を変えれば、変換の過程で精度が失われることがあります。数百万行のバックフィル(backfill)の途中で失敗すれば、一部は新しい値で一部は古い値という中途半端な状態が残ります。コードのロールバックは「以前の状態に戻る」ことですが、データのロールバックはしばしば「すでに起きたことを起きなかったことにする」ことです。そしてデータの世界では、「起きなかったことにする」のはほとんど常に不可能です。
この記事は、マイグレーションを安全に扱うための安全網(safety net)を設計する方法を扱います。中心となる問いは一つです。「この変更が間違っていたとき、私たちはどうやって生き延びるのか」。ロールバックできるようにマイグレーションを書く方法、バックアップと復旧ポイントを取る方法、スキーマとデータと性能とアプリケーションを段階的に検証する方法、カナリアとフィーチャーフラグでリスクを分散する方法、そして何かが間違ったときにたどるランブックとポストモーテムまで、順に見ていきます。
この記事で扱う内容は次のとおりです。
- ロールバックが本質的に難しい理由と、データ損失の非可逆性
- 元に戻せる変更と元に戻せない変更の区別
- ロールバック可能なマイグレーションを書くパターン
- バックアップと復旧ポイント(recovery point)の戦略
- スキーマ、データ、性能、アプリケーションスモークの多段階検証
- カナリアデプロイと段階的ロールアウト
- フィーチャーフラグによるコードとスキーマの分離
- チェックサムと再集計を用いた自動化されたデータ検証
- インシデント対応ランブックとポストモーテム、そして最終チェックリスト
なぜロールバックは難しいのか
データ損失は非可逆である
ソフトウェアエンジニアリングの多くの作業は元に戻せます。間違って書いたコードは git revert で戻し、間違って立ち上げたサーバーは再び落とせばよい。しかしデータは違います。一度削除された行、一度上書きされた値、一度切り詰められた文字列は、原本なしには復元できません。
これを最も明確に示す例がカラムの削除です。
-- このマイグレーションは「ロールバック」できません。
ALTER TABLE users DROP COLUMN legacy_phone;
構文的には、次のような「down」マイグレーションを書けます。
-- これはカラムの「構造」だけを復元し、データは復元しません。
ALTER TABLE users ADD COLUMN legacy_phone VARCHAR(20);
しかしこの down マイグレーションは嘘です。カラムは戻りますが、その中にあったすべての電話番号は永遠に消えています。マイグレーションツールは「down スクリプトが存在する」という事実だけを知っており、そのスクリプトが意味のある復元をするかどうかは知りません。これがロールバックを危険にする最初の罠です。ツールが保証する「元に戻す」と、実際にデータが保存される「元に戻す」は、まったく別の概念です。
時間が経つと新しいデータが積み重なる
ロールバックを難しくする二つ目の要因は時間です。マイグレーションがデプロイされた直後の5分以内に問題を見つければ、その間に入ってきたデータは多くありません。しかし問題を6時間後に見つければ、その間にユーザーは新しいスキーマの上で数万件のトランザクションを生み出しています。
いまや以前のスキーマに戻すには、その6時間に積み重なった新しいデータをどう扱うかを決めなければなりません。捨てるのか。変換するのか。新しいスキーマにのみ存在するカラムに入った値は、以前のスキーマに入る場所がありません。単純な「元に戻す」ではなく、二つのスキーマ間のデータ整合性の問題になってしまいます。
T0 マイグレーション適用 (カラム追加、NOT NULL)
T0+5m デプロイ完了、トラフィック正常化
T0+10m ユーザーが新しいカラムにデータ入力を開始
...
T0+6h 性能問題を発見 → ロールバックを決定
いまや6時間に積み重なった新しいデータをどうする?
単純な down マイグレーションはこのデータを無視するか破壊する
ロックと大容量テーブル
三つ目の要因は、本番環境の物理的な制約です。数億行のテーブルに ALTER を掛けた瞬間、データベースエンジンによってはテーブル全体にロックが掛かり、その間すべての読み書きが止まることがあります。ロールバックのマイグレーション自体がさらに別の長時間ロックを引き起こし、ロールバックが元のインシデントより大きなインシデントを生み出すこともあります。
ですから良いマイグレーション戦略は、「ロールバックをうまくやる方法」ではなく、そもそもロールバックが不要になるように、あるいはロールバックが安全になるように変更を設計するところから出発します。
元に戻せる変更 vs 元に戻せない変更
すべてのスキーマ変更を一つの軸の上に置いて見ると、一方の端には完全に可逆な変更が、反対の端には完全に非可逆な変更があります。
| 変更の種類 | 可逆性 | リスク | 備考 |
|---|---|---|---|
| カラム追加 (nullable) | 高 | 低 | 落とせばよい、既存データに影響なし |
| インデックス追加 | 高 | 低 | 落とせば原状復帰、性能のみ影響 |
| テーブル追加 | 高 | 低 | 落とせばよい |
| カラム名変更 | 中 | 中 | アプリケーションコードと同期が必要 |
| カラム追加 (NOT NULL + 既定値) | 中 | 中 | バックフィル必要、大容量ならロックの危険 |
| 型変更 (拡張) | 中 | 中 | int を bigint へなど、通常は安全 |
| 型変更 (縮小) | 低 | 高 | 精度損失の可能性、データ検証必須 |
| カラム削除 | 非常に低 | 高 | データ永久損失 |
| テーブル削除 | 非常に低 | 非常に高 | データ永久損失 |
| データ変換バックフィル | 低 | 高 | 原本を保存しなければ非可逆 |
この表が与える最も重要な教訓は、非可逆な変更を可逆な変更の連続に分解せよということです。カラムを削除する代わりに、まずコードでそのカラムをもう読まないようにし、しばらく観察したのち、十分に安全だと判断できればそのとき削除します。この間のすべての段階は可逆です。
拡張・収縮パターン (Expand-Contract)
この分解を定型化したものが、拡張・収縮パターン(expand and contract)、あるいは並行変更(parallel change)と呼ばれる手法です。Martin Fowler が進化的データベース設計で整理したこのパターンは、次の三段階から成ります。
1. Expand (拡張)
- 新しい構造を追加しつつ、既存の構造はそのまま残す
- 例: 新しいカラム追加、既存カラム維持
- コードは両方に書くか、新しいものを優先して読み古いものをフォールバックに使う
2. Migrate (移行)
- 既存データを新しい構造へバックフィル
- 新しく入るデータは両方に記録 (dual write)
- 検証: 二つの構造のデータが一致するか確認
3. Contract (収縮)
- コードがもう古い構造を参照しないことを確認
- 十分な観察期間ののち、古い構造を除去
このパターンの核心は、各段階が独立してデプロイされ、各段階が可逆だという点です。拡張段階で問題が起きれば新しいカラムを落とせばよい。移行段階で問題が起きれば dual write を切って古いカラムだけ使えばよい。最も危険な収縮段階は、その前のすべての段階が安定していることを確認したあとにのみ実行します。
ロールバック可能なマイグレーションを書く
up と down を常に対で
ほとんどのマイグレーションツールは、up(適用)と down(元に戻す)スクリプトを対で書かせます。golang-migrate はファイル名の規約でこれを表現します。
000012_add_user_status.up.sql
000012_add_user_status.down.sql
up スクリプトは次のとおりです。
-- 000012_add_user_status.up.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20);
down スクリプトは次のとおりです。
-- 000012_add_user_status.down.sql
ALTER TABLE users DROP COLUMN status;
この場合 status カラムは新しく追加されたものなので、落としても失うデータがありません。これが本当に可逆なマイグレーションです。
Flyway の Undo マイグレーション
Flyway は U 接頭辞で undo マイグレーションを表現します。バージョンが付いたマイグレーション V12 に対応する元に戻すスクリプトは U12 です。
-- V12__add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20);
-- U12__add_user_status.sql
ALTER TABLE users DROP COLUMN status;
ただし Flyway の公式ドキュメントも強調するとおり、undo は万能ではありません。DROP されたデータを蘇らせる undo は存在しえず、undo スクリプトは慎重に検討されなければなりません。
Liquibase の自動・手動ロールバック
Liquibase は変更セット(changeset)単位でロールバックを扱います。一部の変更は自動でロールバック方法を推論でき、そうでない場合は開発者が明示的にロールバック節を書きます。
databaseChangeLog:
- changeSet:
id: add-user-status
author: youngjukim
changes:
- addColumn:
tableName: users
columns:
- column:
name: status
type: varchar(20)
rollback:
- dropColumn:
tableName: users
columnName: status
非可逆な変更を可逆にする
本当に危険なのはデータを破壊する変更です。カラムを削除しなければならないなら、すぐに DROP せず、次のように分解します。
-- ステップ1: すぐ削除せず、名前を変えて「使用停止」を示す
ALTER TABLE users RENAME COLUMN legacy_phone TO legacy_phone_deprecated;
-- ステップ2 (数日後、誰も参照しないことを確認後): バックアップテーブルに保存
CREATE TABLE archive_users_legacy_phone AS
SELECT id, legacy_phone_deprecated
FROM users
WHERE legacy_phone_deprecated IS NOT NULL;
-- ステップ3 (十分な観察後): 実際の削除
ALTER TABLE users DROP COLUMN legacy_phone_deprecated;
こうすればステップ1とステップ2はいずれも可逆であり、ステップ3を実行するまでデータは archive テーブルに安全に保存されます。もしステップ3のあとに問題が見つかっても、archive テーブルからデータを復元できます。
PostgreSQL でロックを避けるパターン
大容量テーブルに NOT NULL カラムを追加するのは危険です。PostgreSQL では次の順序でロックを最小化します。
-- 1. nullable で追加 (メタデータのみ変更、速い)
ALTER TABLE orders ADD COLUMN region VARCHAR(10);
-- 2. バッチ単位でバックフィル (一度に全体をロックしない)
UPDATE orders SET region = 'UNKNOWN'
WHERE region IS NULL AND id BETWEEN 1 AND 100000;
-- 範囲を移しながら繰り返す
-- 3. NOT NULL 制約を NOT VALID でまず追加
ALTER TABLE orders ADD CONSTRAINT orders_region_not_null
CHECK (region IS NOT NULL) NOT VALID;
-- 4. 別途検証 (この段階は ACCESS EXCLUSIVE ロックを避ける)
ALTER TABLE orders VALIDATE CONSTRAINT orders_region_not_null;
インデックスも同様に CONCURRENTLY オプションを使ってテーブルロックを避けます。
CREATE INDEX CONCURRENTLY idx_orders_region ON orders (region);
バックアップと復旧ポイント
ロールバックスクリプトが嘘でありうるなら、本当の安全網は何でしょうか。それはバックアップです。どんなマイグレーションも間違いうるという前提の上で、私たちは変更直前の状態に戻れる復旧ポイントを必ず確保しなければなりません。
復旧ポイント目標と復旧時間目標
二つの重要な指標を理解する必要があります。
| 指標 | 意味 | マイグレーションの文脈 |
|---|---|---|
| RPO (復旧ポイント目標) | どれだけのデータを失ってよいか | マイグレーション直前のスナップショットがあればその時点 |
| RTO (復旧時間目標) | 復旧にどれだけかかってよいか | バックアップから復元するのにかかる時間 |
マイグレーションを実行する前にスナップショットを取っておけば、最悪の場合そのスナップショットで復元し、RPO をマイグレーション開始時点に保証できます。ただしそれ以降に入ってきたデータは失うので、これは本当に最後の手段です。
マイグレーション前のスナップショット
本番環境で大きなマイグレーションを実行する直前には、常にスナップショットを取ります。
# 論理バックアップ (小さいテーブル、特定のスキーマ)
pg_dump --format=custom --table=users --table=orders \
--file=/backups/pre_migration_$(date +%Y%m%d_%H%M%S).dump \
mydb
# 特定のテーブルだけ素早く保存したいとき (CTAS)
psql mydb -c "CREATE TABLE users_backup_20260616 AS TABLE users;"
クラウドのマネージドデータベースなら、スナップショット機能を使うほうがはるかに速いです。重要なのは、マイグレーションランブックの最初の段階が常に「復旧ポイント確保の確認」であるべきだという点です。
時点復旧 (PITR)
PostgreSQL の時点復旧(point-in-time recovery)は、WAL(write-ahead log)を保存することで、特定の時刻に正確に復元できるようにします。マイグレーションが T0+30分にデータを壊したなら、T0 直前の時点に復元できます。PITR を使うには、ベースバックアップと連続的な WAL アーカイブがあらかじめ設定されている必要があります。
復旧の流れ:
ベースバックアップ (T-1d) + WAL アーカイブ (T-1d ~ 現在)
→ recovery_target_time = 'T0 直前'
→ その時刻までのすべてのトランザクションを再生して復元
多段階の検証
マイグレーションが適用されたあと、「成功」と宣言する前に何を確認すべきでしょうか。検証は一度の合否ではなく、複数の層で行われるべきです。
┌─────────────────────────────────────────┐
│ 4. アプリケーションスモークテスト │ 主要なユーザーフローが動くか
├─────────────────────────────────────────┤
│ 3. 性能検証 │ クエリ計画、レイテンシ、ロック
├─────────────────────────────────────────┤
│ 2. データ検証 │ 行数、チェックサム、整合性
├─────────────────────────────────────────┤
│ 1. スキーマ検証 │ 構造が意図どおり変わったか
└─────────────────────────────────────────┘
下から上へ上がりながら検証
段階1: スキーマ検証
まず、スキーマが意図どおり変わったかを確認します。カラムが追加されたか、型が正しいか、制約とインデックスが存在するかをシステムカタログから照会します。
-- カラムが期待した型で存在するか
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'region';
-- インデックスが作成されたか
SELECT indexname FROM pg_indexes
WHERE tablename = 'orders' AND indexname = 'idx_orders_region';
段階2: データ検証
スキーマが正しいからといってデータまで正しいとは限りません。バックフィルがすべての行を処理したか、NULL が残っていないか、変換された値が正しいかを確認します。
-- バックフィルが終わったか: NULL は0であるべき
SELECT count(*) AS remaining_nulls
FROM orders WHERE region IS NULL;
-- マイグレーション前後で行数が保存されたか
SELECT count(*) AS total_rows FROM orders;
-- 分布を見て異常な値がないか
SELECT region, count(*) FROM orders GROUP BY region ORDER BY 2 DESC;
段階3: 性能検証
スキーマとデータが正しくても、新しいインデックスが抜けてクエリがフルスキャンで回れば本番で障害になります。主要なクエリの実行計画を確認します。
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE region = 'KR' ORDER BY created_at DESC LIMIT 50;
新しいインデックスを使うか、見積もりコストが妥当か、実際の実行時間が基準線の中に収まるかを確認します。ロック待ちが積み重なっていないかも併せて見ます。
-- 現在のロック待ち状況を確認
SELECT pid, state, wait_event_type, query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL;
段階4: アプリケーションスモークテスト
最後に、実際のアプリケーションの観点から主要なユーザーフローが動くかを確認します。データベースだけ見れば問題ないのに、アプリケーションコードが新しいスキーマを理解できない場合がよくあります。
# 主要エンドポイントのスモークチェック
curl -fsS https://api.example.com/health
curl -fsS https://api.example.com/orders/recent | jq '.items | length'
この四段階を自動化してマイグレーションパイプラインの一部にすれば、「成功」の定義が明確になります。四段階すべてを通過してはじめて成功です。
カナリアと段階的ロールアウト
コードのデプロイではカナリアはおなじみです。新しいバージョンを一部のインスタンスにだけ先にデプロイし、指標を観察したのち段階的に拡大します。データベースマイグレーションでも似た発想を適用できます。
スキーマ変更そのものは通常オールオアナッシングですが、そのスキーマを使うコード経路は段階的にオンにできます。拡張・収縮パターンと組み合わせると、新しいカラムを追加するところまではすべてに適用しつつ、その新しいカラムを実際に読み書きするロジックは少数のトラフィックにだけ先にオンにするのです。
段階1: スキーマ拡張 (全体に適用、リスク低)
新しいカラム追加 — まだ誰も使わない
段階2: バックフィル + dual write (全体に適用、観察)
新しいデータを両方に記録、既存データをバックフィル
段階3: 新しい経路を1%のトラフィックで有効化 (カナリア)
指標を観察 — エラー率、レイテンシ、データ整合性
段階4: 5% → 25% → 50% → 100% 段階的に拡大
各段階で止めて検証
段階5: 古い経路を無効化、収縮 (古いカラムを除去)
各カナリア段階で何を観察すべきでしょうか。アプリケーションのエラー率とレイテンシは当然として、新しい経路で作られたデータが古い経路のデータと整合性を保つかを併せて見なければなりません。この整合性確認が、次節の自動化されたデータ検証と直結します。
フィーチャーフラグで分離する
マイグレーションで最も厄介な部分は、スキーマ変更とコード変更のデプロイ時点がずれるという点です。スキーマを先に変えると古いコードが壊れることがあり、コードを先に変えると新しいコードがまだ存在しないスキーマを参照することがあります。
フィーチャーフラグ(feature flag、あるいは feature toggle)は、この二つを分離する強力な道具です。スキーマはあらかじめ拡張しておき、コードはすべてデプロイしつつ、新しい動作はフラグでオフにしておきます。そしてすべての準備が整ったときにフラグをオンにします。フラグをオンにすることとオフにすることは即座で可逆なので、事実上「データに触れないロールバック」が得られます。
# フィーチャーフラグで新しいスキーマ経路を分離する
def get_order_region(order, flags):
if flags.is_enabled("use_new_region_column", order.user_id):
# 新しい経路: 新しいカラムを直接読む
return order.region
else:
# 古い経路: 既存のロジックで計算する
return derive_region_from_address(order.shipping_address)
# 書き込み経路では安全に dual write する
def save_order_region(order, region, flags):
order.region = region # 常に新しいカラムに記録 (拡張段階)
if flags.is_enabled("backfill_legacy_region"):
order.legacy_region_code = to_legacy_code(region) # 古いカラムも維持
この構造の利点は、新しい経路で問題が見つかればデータベースに触れずフラグだけ切れば、即座に古い経路に戻るという点です。マイグレーションロールバックという危険な作業を、フラグトグルという安全な作業に置き換えるのです。
ただし注意すべき点があります。フラグを永遠に残しておくと、コードが分岐でいっぱいになり複雑になります。フラグは一時的な装置です。新しい経路が安定したら、フラグと古い経路を一緒に片付ける後続作業を必ずバックログに残さなければなりません。
自動化されたデータ検証
大容量データマイグレーションで、人が目ですべての行を確認するのは不可能です。自動化されたデータ検証が必要です。中心となる手法は二つです。チェックサム(checksum)と再集計(re-aggregation)です。
チェックサムでデータの一致を確認する
拡張・収縮パターンで古い構造と新しい構造が同じデータを保持しているべきなら、二つの構造のチェックサムを比較して一致するかを素早く確認できます。
-- 古いカラムと新しいカラムが意味的に一致するかチェックサムで比較
SELECT
md5(string_agg(legacy_region_code, ',' ORDER BY id)) AS old_checksum,
md5(string_agg(to_legacy_code(region), ',' ORDER BY id)) AS new_checksum
FROM orders;
二つのチェックサムが同じなら、すべての行で変換が一貫して適用されたという強力な証拠です。異なれば、どの行でずれたのかを絞り込んでいけます。
-- 一致しない行だけを探す
SELECT id, region, legacy_region_code, to_legacy_code(region) AS expected
FROM orders
WHERE legacy_region_code IS DISTINCT FROM to_legacy_code(region);
再集計で保存を確認する
データを変換するマイグレーションでは、合計や件数のような集計値が保存されるべき場合が多いです。マイグレーション前後の集計値を比較すれば、データが失われたり重複したりしていないことを確認できます。
-- マイグレーション前に基準線の集計を保存
CREATE TABLE migration_baseline AS
SELECT
count(*) AS row_count,
sum(amount) AS total_amount,
count(distinct user_id) AS distinct_users
FROM orders;
-- マイグレーション後に同じ集計を再計算して比較
SELECT
b.row_count = a.row_count AS row_count_ok,
b.total_amount = a.total_amount AS amount_ok,
b.distinct_users = a.distinct_users AS users_ok
FROM migration_baseline b
CROSS JOIN (
SELECT count(*) AS row_count,
sum(amount) AS total_amount,
count(distinct user_id) AS distinct_users
FROM orders
) a;
三つのカラムがすべて true なら、行数と金額の合計と一意ユーザー数がすべて保存されたことになります。こうした集計検証は軽くて速く、大容量テーブルでも全数比較よりはるかに実用的です。
標本検証と全数検証
検証のコストと信頼度の間にはバランスがあります。
| 方法 | コスト | 信頼度 | 適する場合 |
|---|---|---|---|
| 標本検証 | 低 | 中 | 素早い一次確認、大容量 |
| 集計検証 | 低 | 中上 | 合計・件数の保存確認 |
| チェックサム検証 | 中 | 高 | カラム単位の整合性 |
| 全数比較 | 高 | 非常に高 | 金融など整合性必須の領域 |
実務では通常、集計検証とチェックサム検証を既定で回し、疑わしい領域に限って全数比較を追加します。
インシデント対応ランブック
どれほどよく準備しても、マイグレーションは間違いうる。間違ったときに即興で対応すると状況が悪化します。あらかじめ書かれたランブック(runbook)があれば、プレッシャーの中でも定められた手順をたどれます。
=== マイグレーションインシデントランブック ===
[0] 検知
- アラーム: エラー率上昇、レイテンシ増加、ロック待ち急増
- 誰がインシデント指揮者(IC)かを即座に指定
[1] 評価 (5分以内)
- 変更は可逆か? (上の可逆性の表を参照)
- データが壊れているのか、それとも止まっているだけか?
- 影響範囲: 全ユーザーか、一部か?
[2] 出血を止める
- フィーチャーフラグがあれば即座にオフ (最も速く安全)
- バックフィル作業が回っていれば一時停止
- 必要ならトラフィックを古い経路に切り替え
[3] 復旧を決定
- フラグオフで十分 → データに触れない (推奨)
- スキーマだけ戻せばよい → down マイグレーション実行
- データが壊れた → バックアップ/PITR で復元を検討
[4] 復旧を実行
- 復旧ポイントを確認 (マイグレーション前スナップショット存在?)
- 実行前に IC 承認
- 復旧後に多段階検証を再実行
[5] 安定化を確認
- 4段階検証 (スキーマ/データ/性能/スモーク) 通過
- 指標が基準線に戻ったか観察
[6] コミュニケーション
- ステータスページ更新、関係者に共有
- タイムライン記録 (ポストモーテム資料)
ランブックで最も重要な原則は、データに触れる復旧は最後の手段だという点です。可能ならフラグオフやトラフィック切り替えのように、データに触れない方法でまず出血を止めます。バックアップ復元はそれ自体がさらに別のデータ損失(復元時点以降のデータ)を意味するので、最も慎重に決めなければなりません。
ポストモーテム
インシデントが収まれば終わりではありません。同じことが繰り返されないよう、ポストモーテム(postmortem)を書きます。核心は非難しない(blameless)態度です。「誰が間違えたか」ではなく「どんなシステム的な条件がこの間違いを可能にしたか」を問います。
良いポストモーテムは次を含みます。
- タイムライン: 何がいつ起きたか。検知、評価、復旧の各時刻。
- 影響: どれだけのユーザーが、どれだけの間、どのように影響を受けたか。
- 根本原因: 表面的な症状ではなく、その下の構造的な原因。「なぜ」を5回問う。
- 何がうまくいったか: 安全網が機能した部分も記録する。フィーチャーフラグのおかげで素早く止められたなら、それも学びである。
- 改善項目: 具体的で、担当者がいて、期限のあるアクションアイテム。
たとえば「バックフィルが遅すぎた」が症状なら、根本原因は「バッチサイズの設定がなく単一トランザクションで全体をロックした」かもしれず、改善項目は「すべてのバックフィルマイグレーションにバッチ処理テンプレートを適用し、大容量テーブルのマイグレーションは事前リハーサルを義務化する」になります。
落とし穴
ここまでの内容を、実務でよく出会う落とし穴として改めて整理します。
落とし穴1: down スクリプトがあるから安全だと信じること。 前に見たとおり、down スクリプトの存在がデータ復元を保証しません。DROP を戻す down はカラムの構造だけを蘇らせ、データは蘇らせません。down スクリプトが本当に可逆かを常に疑ってください。
落とし穴2: スキーマとコードを一度に変えること。 スキーマとコードを同じデプロイで同時に変えると、二つの間の短い不一致区間で障害が発生します。拡張・収縮で分離し、フィーチャーフラグで時点を制御してください。
落とし穴3: 本番環境で初めて実行すること。 本番と同じ規模のデータでリハーサルしていないマイグレーションは、本番で初めてロック時間やバックフィル時間に出会います。本番レプリカで事前リハーサルをすれば、見込み所要時間とロックの危険を前もって知れます。
落とし穴4: バックフィルを単一トランザクションで。 数百万行を一つの UPDATE で処理すると、巨大なロックと長いトランザクション、そして WAL の急増が発生します。常にバッチに分けて処理してください。
落とし穴5: 検証なしの成功宣言。 マイグレーションがエラーなく終わったから成功ではありません。多段階検証を通過してはじめて成功です。特にデータ検証とアプリケーションスモークを省略しないでください。
落とし穴6: 復旧ポイントなしで進めること。 マイグレーション直前のスナップショットや PITR の設定がなければ、最悪の場合戻る先がありません。ランブックの0段階は常に復旧ポイント確保の確認です。
落とし穴7: フィーチャーフラグを永遠に放置。 フラグは一時的な装置です。安定化後もフラグと古い経路を片付けなければ、コードの複雑さとさらに別の種類のリスクが積み重なります。
最終チェックリスト
マイグレーションを本番に適用する前に、次を点検してください。
[ 準備 ]
[ ] 変更の可逆性分類 (可逆/部分可逆/非可逆)
[ ] 非可逆な変更は拡張・収縮で分解したか
[ ] up/down (または undo) スクリプトが対で存在するか
[ ] down スクリプトが本当にデータを保存するか (または保存できないと明示)
[ ] 本番レプリカでリハーサルを終えたか (所要時間/ロックを測定)
[ 安全網 ]
[ ] マイグレーション前スナップショットまたは PITR 復旧ポイント確保
[ ] 大容量バックフィルはバッチ処理で書いたか
[ ] インデックス作成に CONCURRENTLY を使ったか
[ ] 新しい動作をフィーチャーフラグで分離したか
[ 検証 ]
[ ] スキーマ検証クエリ準備 (カラム/型/インデックス/制約)
[ ] データ検証クエリ準備 (NULL/行数/分布)
[ ] 集計基準線の保存と事後比較の自動化
[ ] チェックサム比較 (古い/新しい構造の整合性)
[ ] 性能検証 (EXPLAIN、ロック待ち確認)
[ ] アプリケーションスモークテスト準備
[ ロールアウト ]
[ ] カナリア/段階的ロールアウト計画 (1% → 100%)
[ ] 各段階の観察指標を定義 (エラー率/レイテンシ/整合性)
[ 対応 ]
[ ] インシデントランブック作成・共有
[ ] IC(インシデント指揮者)の指定方法を合意
[ ] 出血を止める第1優先 = フラグオフ を明示
[ ] ポストモーテムテンプレート準備
[ 片付け ]
[ ] 安定化後にフラグ/古い経路の片付け作業をバックログに登録
おわりに
データベースマイグレーションにおける本当の技術は、「ロールバックをうまくやること」ではなく、そもそも危険なロールバックが不要になるように変更を設計することです。非可逆な変更を可逆な段階の連続に分解し、スキーマ変更とコード変更をフィーチャーフラグで分離し、変更直前に復旧ポイントを確保し、適用後は複数の層で検証します。
これらすべての装置が作り出すのは一つの安全網です。安全網の目的は、決して落ちないことではなく、落ちたときに生き延びることです。マイグレーションはいつか間違うでしょう。そのとき私たちが頼るのは、英雄的な即興対応ではなく、あらかじめ設計しておいた可逆性とバックアップと検証とランブックです。安全網をまず築き、その上で変更してください。
参考資料
- Flyway Documentation — Undo Migrations
- Flyway 公式サイト
- golang-migrate/migrate (GitHub)
- Liquibase — Rollback
- Liquibase 公式サイト
- Martin Fowler — Evolutionary Database Design
- Martin Fowler — Parallel Change (expand and contract)
- Martin Fowler — Feature Toggles (Feature Flags)
- PostgreSQL Documentation — ALTER TABLE
- PostgreSQL Documentation — Continuous Archiving and Point-in-Time Recovery
- PostgreSQL Documentation — Backup and Restore