Skip to content
Published on

マイグレーション事故事例とチェックリスト — 他人の失敗から学ぶ

Authors

はじめに — 最も高い学びは他人の事故から

障害は高い教師です。午前3時に起きて本番DBを復旧しながら学ぶ教訓は一生忘れませんが、その授業料は売上損失と信頼低下とチームの燃え尽きで支払われます。幸い、すべての教訓を自分の事故で学ぶ必要はありません。他人のポストモーテムから学べば、同じ落とし穴をあらかじめ避けられます。

マイグレーションは障害統計で常に上位に来ます。普段うまく動いていたシステムが、スキーマを変えるその一瞬で崩れるからです。本稿はマイグレーション事故の典型的なタイプを整理し、3件の仮想ポストモーテムで「症状から原因と教訓へ」向かう思考過程を示し、予防パターンと総合チェックリストで締めくくります。

よくあるマイグレーション事故タイプ

まずよく繰り返される事故パターンを整理します。

+---------------------+--------------------------------------------------+
|  事故タイプ           |  何が起きるか                                     |
+---------------------+--------------------------------------------------+
|  ロック嵐            |  ALTERが強いロックを握り全クエリが待機             |
|  レプリ遅延          |  大量変更でレプリカが遅れ読み取り一貫性が崩れる       |
|  データ損失          |  誤った変換/削除で復旧不能なデータ消失              |
|  ロールバック不能     |  戻せない変更の後に問題発見                        |
|  タイムアウト         |  マイグレーションが長すぎてデプロイ/接続タイムアウト  |
|  重複実行            |  マイグレーションが同時に二度実行され状態が壊れる      |
+---------------------+--------------------------------------------------+

各タイプは異なる根本原因を持ちますが、共通点があります。すべて「小規模でテストしたときは問題なかった」ことです。本番のデータ量、トラフィック、レプリケーショントポロジーが生む規模の効果が事故を招きます。

ロック嵐

ALTER TABLE は種類によってACCESS EXCLUSIVEのような強いロックを握ります。このロックは当該テーブルのすべての読み書きを止めます。小さなテーブルでは一瞬ですが、数億行のテーブルでは数分かかり、その間に溜まったクエリがコネクションプールを満たして連鎖障害に広がります。

  ALTER TABLE (強いロック取得、5分かかる)
      |
  [クエリ1] 待機... [クエリ2] 待機... [クエリ3] 待機...
      |
  コネクションプール枯渇 -> 新規リクエスト拒否 -> 全面障害

レプリケーション遅延

大量UPDATEやバックフィルはマスターでは速いですが、レプリカはその変更を順次追いつく必要があります。読み取りをレプリカに分散する構成なら、レプリ遅延の間ユーザーは古いデータを見るか、書いたばかりのデータを読めない(read-your-writesの破綻)現象を経験します。

データ損失

最も致命的です。カラムをDROPしたがそのデータがどこにもバックアップされていなかったり、変換ロジックのバグで値が誤って上書きされたり、WHERE 句を抜かしたUPDATEが全行を壊します。データ損失はコードのロールバックでは戻りません。

仮想ポストモーテム1 — 真夜中のNOT NULL

症状から原因、教訓まで追ってみます。

[症状]
  02:14  デプロイ開始 (usersテーブルにcountryカラム追加)
  02:15  API応答時間が急増、タイムアウト殺到
  02:17  コネクションプール枯渇アラート
  02:31  マイグレーション強制中断、サービス復旧開始
  02:48  復旧完了 (合計34分の障害)

[原因]
  マイグレーション:
    ALTER TABLE users ADD COLUMN country VARCHAR(2) NOT NULL DEFAULT 'KR';
  usersテーブルは8千万行。旧DBではDEFAULTのあるNOT NULLカラム追加は
  全行を書き直し、その間ACCESS EXCLUSIVEロックを握る。
  ステージングは5万行なので0.1秒で終わり問題を検出できず。

[教訓]
  1. データ量を本番に近い環境でマイグレーションをリハーサルする。
  2. NOT NULL + DEFAULTはnullable追加 -> バックフィル -> 制約追加に分割する。
  3. マイグレーションリントで危険パターンをPRで捕まえる。

核心の教訓は「ステージングのデータ量が本番と違えば、ステージング通過は安全を保証しない」です。ロックはデータ量に比例して長くなります。

仮想ポストモーテム2 — 消えた住所

[症状]
  デプロイ翌日、カスタマーサポートに「配送先が消えた」という問い合わせ殺到。
  一部ユーザーのaddressフィールドが空値で表示される。

[原因]
  住所を単一文字列から構造化オブジェクトに変えるマイグレーション:
    - 新カラムaddress_jsonを追加
    - 旧address文字列をパースしてaddress_jsonを埋める
    - 旧addressカラムをDROP
  パースロジックが特定形式(カンマなしの住所)を処理できず約3%が空オブジェクトに。
  旧カラムを同じデプロイでDROPしたため原本が消え復旧不能。

[教訓]
  1. 変換と旧データ削除を同じデプロイに入れない(expand-contract)。
  2. 旧カラムは十分な検証期間の後に削除する。
  3. 変換前後でサンプル・不変条件検証を回す(空値比率チェック)。
  4. 破壊的作業の前に必ずバックアップ、PITR可能時点を確認する。

この事故の本当の原因はパースバグそのものではなく、検証なしに旧データを同じデプロイで消したことです。旧カラムが残っていれば30分で復旧できました。

仮想ポストモーテム3 — 二度実行されたマイグレーション

[症状]
  残高増減履歴テーブルに重複レコードを発見。
  一部ユーザーのポイントが二倍に付与された。

[原因]
  Kubernetesロールアウト中、initコンテナ方式でマイグレーションを実行。
  データバックフィルマイグレーション(過去取引にポイント再計算)が
  二つのPodでほぼ同時に開始。バックフィルSQLが冪等でなく
  (INSERTのみで重複チェックなし)同じデータを二度挿入。
  マイグレーションバージョンロックはDDLにはかかったが、バックフィルロジックの
  一部がロックの外で回るよう書かれていた。

[教訓]
  1. バックフィルを含むすべてのマイグレーションを冪等に書く
     (INSERT ... ON CONFLICT、または存在確認後に挿入)。
  2. マイグレーションは単一実行を保証する(Jobパターン、明示的ロック)。
  3. initコンテナ同時実行パターンを避ける。
  4. 金銭関連バックフィルはドライランで影響行数を先に確認する。

冪等性はマイグレーションのシートベルトです。一度の実行でも十度の実行でも結果が同じであってこそ、再試行と同時実行という分散環境の現実に耐えられます。

仮想ポストモーテム4 — 追いつけなかったレプリカ

[症状]
  大量バックフィルのデプロイ後、一部ユーザーが「今変えた設定が見えない」と問い合わせ。
  読み取り専用APIが古いデータを返す。

[原因]
  3億行のテーブルに大量UPDATE(全ユーザーの等級再計算)を一度に実行。
  マスターでは8分で終わったが、読み取りレプリカ3台がこの変更を
  順次適用し最大22分遅れた。読み取りトラフィックはレプリカに
  分散していたため、その間ユーザーは古いデータを見た。

[教訓]
  1. 大量変更はバッチに分けてレプリカが追いつく時間を与える。
  2. バッチ間でレプリケーション遅延を監視し、閾値超過時は待機。
  3. 読み取り一貫性が重要な経路はマスターから読むかセッション一貫性を保証。
  4. バックフィル中はレプリ遅延アラートをオンにしておく。

大量変更の落とし穴はマスターの成功がそのままシステムの成功ではないことです。レプリカまで収束して初めて本当の完了です。バッチ間でレプリ遅延を確認しながら進めるのが核心です。

-- バッチバックフィル (レプリ遅延を考慮し1万行ずつ、間に待機)
DO $$
DECLARE
  rows_affected int;
BEGIN
  LOOP
    UPDATE users SET tier = compute_tier(score)
    WHERE id IN (
      SELECT id FROM users WHERE tier IS NULL LIMIT 10000
    );
    GET DIAGNOSTICS rows_affected = ROW_COUNT;
    EXIT WHEN rows_affected = 0;
    PERFORM pg_sleep(0.5);  -- レプリカが追いつく時間
  END LOOP;
END $$;

仮想ポストモーテム5 — タイムアウトの連鎖

[症状]
  CDパイプラインのマイグレーションステップが10分後タイムアウトで失敗。
  しかしマイグレーションはDBで実行中のまま。再試行が二つ目の
  マイグレーションを開始しロック競合が発生。

[原因]
  インデックス生成が見込みより長くかかりパイプラインタイムアウト(10分)を超過。
  パイプラインは失敗と判定したがDBセッションは生きてインデックス生成を継続。
  自動再試行が同じマイグレーションをまた開始 -> ロック衝突 -> 両方停止。

[教訓]
  1. マイグレーションのタイムアウトを実際の所要時間より十分長く取る。
  2. タイムアウト時はDBセッションを確実に整理(キャンセル)してから再試行する。
  3. 自動再試行を切るか、冪等性とロックで重複実行を防ぐ。
  4. 長くかかる作業(インデックス生成)はマイグレーションの外で非同期に。

タイムアウトは単に「遅い」という信号ではなく、失敗処理の隙間を露呈します。タイムアウト後にゾンビセッションが残り次の実行と衝突するパターンはよくあり危険です。

安全なオンラインDDLツール

大きなテーブルのスキーマを無停止で変える専用ツールがあります。これらは元のテーブルを直接ALTERせず、新しいテーブルを作ってデータを漸進的にコピーした後、入れ替えます。

+------------------+------------------+--------------------------------+
|  ツール           |  対象             |  方式                           |
+------------------+------------------+--------------------------------+
|  gh-ost          |  MySQL           |  binlogベース、トリガーなし      |
|  pt-online-schema|  MySQL           |  トリガーベースのシャドーテーブル  |
|  -change         |                  |                                |
|  pg_repack       |  PostgreSQL      |  bloat除去、テーブル再作成        |
+------------------+------------------+--------------------------------+

動作原理は共通して次のとおりです。

1. 元と同じ構造の新テーブルを作成 (望む変更を適用)
2. 元データを新テーブルへチャンク単位でコピー
3. コピー中に発生した変更を追跡し新テーブルに反映 (トリガーまたはbinlog)
4. 追いついたら短いロックでテーブル名を入れ替え (原子的スワップ)
5. 旧テーブルを整理

この方式の利点は長いロックなしで大きなテーブルを変えることです。欠点は複雑で、ディスクを二倍使い、外部キーやトリガーがあると厄介なことです。本番級の大型テーブル変更では、こうしたツールが事実上の標準です。

予防パターン — 小さく、可逆に、検証しながら、観測しながら

3件のポストモーテムから共通して導いた予防原則を整理します。

小さく (Small)

大きなマイグレーションを小さなステップに分割します。小さな変更は実行が速く、ロックが短く、問題時の影響範囲が狭いです。

悪い:  ALTER TABLE ... (全テーブルを一度に書き直し、ロック5分)
良い:  nullable追加(速い) -> バッチバックフィル(1万行ずつ) -> 制約追加(速い)

可逆に (Reversible)

expand-contractですべての変更を戻せるようにします。旧構造を即座に消さず、新構造が検証されるまで残します。

expand   : 新構造を追加 (旧と共存、戻しやすい)
migrate  : 両方書き込み + バックフィル
switch   : 新構造へ読み取り切り替え
contract : 旧構造を削除 (十分安定した後、別デプロイ)

検証しながら (Verified)

変換前後でデータを検証します。数量、サンプル、不変条件、空値比率を自動で点検します。

-- 変換後の空値比率を点検 (不変条件検証)
SELECT
  count(*) FILTER (WHERE address_json = '{}'::jsonb) AS empty_count,
  count(*) AS total,
  round(100.0 * count(*) FILTER (WHERE address_json = '{}'::jsonb) / count(*), 2) AS empty_pct
FROM users;
-- empty_pctが閾値を超えたらデプロイ中断

観測しながら (Observed)

マイグレーションの進行状況をリアルタイムで見ます。所要時間、ロック待ち、レプリ遅延をダッシュボードで見守り、異常時に即座に中断できるようにします。

観測すべきシグナル:
  - マイグレーション経過時間 (見込み対比)
  - ロック待ちキューの長さ (pg_locks, SHOW PROCESSLIST)
  - レプリケーション遅延 (replica lag)
  - コネクションプール使用率
  - エラー率、応答時間

大規模マイグレーションのリハーサル

本番級事故を防ぐ最も強力な道具はリハーサルです。本番データの複製(または本番級規模の合成データ)でマイグレーションをそのまま実行してみます。

1. 本番スナップショットを隔離環境に復元 (データ量同一)
2. マイグレーションを実際に実行し所要時間を測定
3. ロック持続時間、レプリ遅延、ディスク使用量を観察
4. ロールバック/undo手順も一緒にリハーサル
5. 見込み所要時間とリスクをランブックに記録

リハーサルで「このマイグレーションは本番で12分かかり、その間テーブルがロックされる」という事実を事前に知れば、無停止戦略に変えるかメンテナンスウィンドウを取る決定をあらかじめ下せます。

コミュニケーションと承認

マイグレーション事故の半分は技術ではなくコミュニケーションの問題です。

  • 事前告知: 危険なマイグレーションは関連チームに事前に知らせます。「今夜usersテーブルのマイグレーションがあり、約10分間書き込みが遅くなる可能性があります。」
  • 承認ゲート: 本番マイグレーションは二人目のレビューを経ます。一人で深夜に打つSQLが最も危険です。
  • ロールバック決定者: 誰が「中断してロールバック」を決めるか事前に定めます。障害の最中に決めると遅いです。
  • 状態共有: 進行中のマイグレーションの状態をチャンネルにリアルタイムで共有します。

メンテナンスウィンドウ vs 無停止

マイグレーション戦略は大きく二つに分かれます。

+----------------------+----------------------------------------------+
|  メンテナンスウィンドウ |  無停止 (online)                              |
+----------------------+----------------------------------------------+
|  サービスを一時停止     |  サービス維持しつつ漸進的に適用                 |
|  単純、危険な作業も可能 |  複雑、expand-contract必須                    |
|  計画的ダウンタイム発生 |  ダウンタイムなし                             |
|  小規模/内部システム向き |  大規模/24x7サービス向き                      |
+----------------------+----------------------------------------------+

無停止が常に正しいわけではありません。内部ツールやトラフィックの少ない時間帯なら、短いメンテナンスウィンドウが複雑な無停止マイグレーションより安全で単純なことがあります。核心は「このサービスにどの程度のダウンタイムが許容されるか」を先に合意することです。

マイグレーション中の観測クエリ

マイグレーションが本番で動いている間、次のクエリで状態をリアルタイムに点検します。

-- PostgreSQL: 現在のロック待ち状況を確認
SELECT
  blocked.pid AS blocked_pid,
  blocking.pid AS blocking_pid,
  blocked.query AS blocked_query,
  blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));

-- PostgreSQL: レプリケーション遅延を確認 (マスターで)
SELECT
  client_addr,
  state,
  pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;

-- 進行中の長いクエリを確認
SELECT pid, now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '1 minute'
ORDER BY duration DESC;

これらのクエリをダッシュボードやアラートに束ねておけば、マイグレーションがロックを長く握りすぎたりレプリケーションが遅れたりしたとき即座に分かります。「見えなければ対応できない」が観測の第一原則です。

総合事前/事後チェックリスト

マイグレーション前 (事前)

  • 本番級のデータ量でリハーサルしたか
  • 見込み所要時間とロック持続時間を測定したか
  • 大きなテーブル変更にCONCURRENTLY/バッチを適用したか
  • expand-contractで可逆に設計したか
  • 破壊的作業の前にバックアップとPITR時点を確認したか
  • バックフィルが冪等で単一実行が保証されるか
  • 変換検証クエリ(数量/サンプル/不変条件)を準備したか
  • 関連チームに告知し承認を得たか
  • ロールバック手順と決定者を定めたか

マイグレーション中/後 (事後)

  • 進行状況(経過時間、ロック、レプリ遅延)を観測しているか
  • 検証クエリでデータ整合性を確認したか
  • レプリカが追いついたか(lag回復)確認したか
  • エラー率/応答時間が正常に戻ったか
  • 結果をチャンネルに共有し記録したか
  • (異常時)定めた手順どおり中断/ロールバックしたか
  • 事故があったなら非難なきポストモーテムを書いたか

非難なきポストモーテムの書き方

事故が起きたなら、それを学習資産に変えるのがポストモーテムです。核心は「人を責めない(blameless)」という原則です。「誰が間違って打ったか」ではなく「どんなシステム的な隙間がその間違いを可能にしたか」を問います。

良いポストモーテムの構造:
  1. サマリー    - 何が、いつ、どれだけ影響したか (一段落)
  2. タイムライン - 分単位で何が起きたか
  3. 根本原因    - 「5 whys」で表面の先の原因まで
  4. 影響        - ユーザー/売上/データへの実際の影響
  5. 何がうまくいったか - 速い検知、良い対応なども記録
  6. アクションアイテム - 再発防止のための具体的・期限つき項目

非難なき文化が重要な理由は単純です。人を責めれば事故が隠され、隠されれば学習が止まります。誰でも午前3時に間違えうることを認め、その間違いが障害につながらないようシステムを直すのが目標です。「なぜそのマイグレーションがレビューなしに本番に適用できたのか」が「なぜあなたがそれを打ったのか」よりはるかに生産的な問いです。

5 whysの例

事故: NOT NULLマイグレーションが本番を止めた
  なぜ? -> 8千万行を書き直しロックを握ったから
  なぜ? -> NOT NULL + DEFAULTが全体再書き込みを誘発するから
  なぜ? -> そのパターンが危険か知らず、レビューで捕まえられなかったから
  なぜ? -> マイグレーションリントがパイプラインになかったから
  なぜ? -> マイグレーションをコードのように検証する文化がなかったから
根本原因: ツール(リント)と文化(検証)の不在。個人の間違いではない。
アクション: リント導入 + 本番級リハーサル + 危険パターンのガイド文書化

おわりに

マイグレーション事故の教訓はほとんど同じところに収束します。小さく分割せよ、戻せるようにせよ、本番級規模でリハーサルせよ、検証せよ、観測せよ、そして一人で深夜に打つな。これらの原則は新しくありませんが、事故を経験するまではなかなか守られません。幸い私たちは他人のポストモーテムから学べます。本稿の三つの仮想事故があなたの本物の事故を一件でも防ぐなら、最も高い授業料を最も安く払ったことになります。良いエンジニアリングは英雄的な復旧からではなく、そもそも復旧が不要になるようにする退屈な規律から生まれます。

参考資料