無停止DBマイグレーションの設計 — スキーマ変更で止めないために

スキーマ変更を本番環境で無停止に行うには、変更を一度に当てようとする発想を捨て、後方互換を保ちながら段階的に移行する設計が必要です。どのタイミングで何を変え、アプリとDBのどちらを先に切り替えるかという判断の連鎖が、本番停止の有無を分けます。排他ロックの発生条件を把握し、Expand-Contractパターンで新旧カラムを並走させ、切り戻しの可否を事前に確認する——この3点が無停止設計の骨格です。

スキーマ変更で本番を止めてしまう構造

排他ロックが発生する操作の種類

MySQLやPostgreSQLに対してALTER TABLEを実行すると、操作の種類によっては対象テーブル全体に排他ロックが掛かります。カラムの追加・削除・型変更、インデックスの再構築、NOT NULL制約の追加といった操作がその代表例です。ロックが保持されている間、該当テーブルへの読み書きはすべてキューに積まれ、タイムアウトしたリクエストからエラーとして返ります。数百万行を超えるテーブルでは、ロック保持時間が数分から数十分に及ぶこともあり、これが「本番を止める」最初の構造です。MySQLのInnoDBではバージョンによってオンラインDDLのサポート範囲が異なるため、変更の種類ごとにロック挙動を事前に確認することが設計の起点になります。

「メンテナンスウィンドウで済ませる」という判断がなぜ危うくなったか

以前は深夜にサービスを止めてスキーマ変更を流す手順が一般的でした。しかし、24時間対応を前提とするBtoBサービスや、グローバルに展開するシステムでは、停止許容ウィンドウ自体が存在しないケースが増えています。テーブル行数の増加に伴いALTERの所要時間が読みにくくなり、「30分で終わるはず」が2時間を超えて朝の業務に影響した、という話を現場でよく聞きます。さらにマイクロサービス化が進んだ環境では、DBを1つ止めると依存サービスが連鎖停止するリスクも無視できません。メンテナンスウィンドウという前提を崩された状態で初めて、無停止設計が「選択肢」ではなく「必須要件」として浮上してきます。

アプリとDBの変更タイミングがずれると何が起きるか

スキーマ変更とアプリのデプロイを同時に実施しようとすると、必ずタイムラグが生じます。DBへのALTER完了直後にアプリがまだ古いカラム名でSELECTを発行すれば、カラム不在エラーが発生します。逆に新しいカラムを参照するコードを先にデプロイすると、カラムがまだ存在しないためエラーになります。どちらの順番でも「変更中の瞬間」に不整合が生じる構造になっており、この順序問題を解消しない限り無停止は実現できません。アプリとDBをそれぞれ独立してデプロイできる状態を設計の前提として置くことが出発点になります。

後方互換を保つ段階的マイグレーションの設計

Expand-Contract(拡張→収縮)パターンの考え方

無停止マイグレーションの基本設計として広く採用されているのが、Expand-Contractパターンです。「拡張フェーズ」では既存の構造を壊さずに新しいカラムや構造を追加し、「収縮フェーズ」では古い構造を安全に削除します。カラム名を変更したい場合を例に取ると、いきなり既存カラムをリネームするのではなく、新しい名前のカラムを追加して両カラムへの読み書きを並走させ、移行完了を確認してから旧カラムを削除するという3段階を踏みます。この「一気に変えない」設計思想がアプリとDBの独立デプロイを可能にし、各フェーズを個別にリリースできるため、問題が起きたときの切り分けも容易になります。

新旧カラムの並走期間をどう設計するか

新旧カラムを並走させる期間は、デプロイのサイクルや検証に必要な時間を基準に決めます。週次リリースのプロジェクトであれば最低2〜3リリース、つまり2〜3週間の並走期間を設けるのが現実的な目安です。並走中はアプリ側で書き込み時に両カラムへ同時に値を入れ、読み込みは新カラムを優先しつつ値がnullの場合は旧カラムにフォールバックするロジックを組みます。この期間中にバックフィル(既存データの旧カラムから新カラムへのデータ移行)も並行して完了させます。並走期間を短くしすぎると、古いバージョンのアプリが残ったまま旧カラムを参照し続けるリスクが残るため、全デプロイが新バージョンに切り替わったことを確認してから収縮フェーズに進む判断が必要です。

データ型変更・NOT NULL制約追加で踏む地雷

Expand-Contractパターンが機能しにくいケースとして、データ型変更とNOT NULL制約の追加があります。既存のVARCHARカラムをINTに変えたい場合、型変換が全行に走るためロックを避けにくくなります。このときは「新しいINT型カラムを追加→バックフィル→アプリ切り替え→旧カラム削除」という4段階に分割し、型変換そのものをALTERで実施しない設計を選びます。NOT NULL制約の追加は既存のnull値をすべて解消してから行わないと制約違反でALTERが失敗します。まずDEFAULT値を設定してnullを埋め、アプリ側でも常に値を入れるようにした上で、nullがゼロになったことを確認してから制約を追加する順序が必要です。これを省略して直接NOT NULLを追加しようとし、本番でALTERが途中失敗するケースは現場でよく見られます。

比較: 一括変更/オンライン変更/デュアルライトの向き不向き

手法 向いているケース リスク・向いていないケース 目安となる規模感
一括DDL変更(ALTER直接) 開発・ステージング環境、停止ウィンドウがある小規模サービス、行数が少ないテーブル 大規模テーブルでは長時間ロック。24時間サービスには使えない 〜100万行、ロック許容1分以内
オンラインスキーマ変更(pt-osc / gh-ost等) MySQL系の大規模本番テーブル。ロックをほぼ回避して変更可能 レプリケーション遅延が増大しやすい。ツール依存のため設計・検証コストが高い 数千万〜数億行規模で実績あり
デュアルライト+段階切り替え カラム名変更・テーブル分割など構造の大きな変更。DBMS非依存で使える アプリ側の実装コストが高い。並走期間中のデータ整合性管理が複雑になる 規模を問わず、変更の複雑さで選ぶ

一括DDL変更が通用する条件

開発環境やデータ量が少ないテーブルでは、一括DDLが最もシンプルで確実な手段です。ただし、本番で同じ手法を選べるのは「テーブル行数が100万行以下かつ停止許容時間が存在する」という2条件が同時に満たされる場合に限定されます。ステージング環境で計測したALTER所要時間に本番データ量の比率を掛けた推計値と、許容停止時間を照合する作業を設計の初期に実施しておくと、後から手法を変更する手戻りを防げます。この推計をスキップして本番で初めて所要時間を知る、というパターンが停止事故の典型的な経緯です。

pt-online-schema-change / gh-ostの設計思想

pt-online-schema-change(Percona Toolkit)やGitHub製のgh-ostは、コピーテーブルを作成してデータを少量ずつ移行し、トリガーまたはbinlogを使って並走書き込みをキャッチアップさせることでロックを最小化します。gh-ostはMySQLのbinlogを直接読むアーキテクチャのため、トリガーによる書き込みオーバーヘッドが発生しない点が特徴です。ただし、どちらのツールもレプリケーション構成への影響を考慮する必要があり、スロットリング設定を適切に行わないとレプリカの遅延が数十秒から数分に膨らむケースがあります。ツールを選ぶ判断は「コマンドが手元で使えるかどうか」ではなく「レプリケーション遅延を許容できる設計になっているか」に置くことが重要です。手順の詳細は公式ドキュメントや実地演習で確認するとして、設計フェーズではツールの動作原理と制約を把握することを優先します。

デュアルライトをどこに組み込むか

デュアルライトはアプリ層で新旧両方のカラム・テーブルに同時に書き込む手法で、ツールに依存せずDBMS非依存で実装できます。組み込み位置はリポジトリパターンやDAL(データアクセス層)のような抽象境界が推奨されます。ビジネスロジック層に散在させると、書き込みポイントの漏れが生じやすく、新旧データの不整合が後から発覚するリスクが高まります。PostgreSQLのようにオンラインALTERの制約が少ない環境でも、テーブル分割やカラムの意味の再定義のような大きな構造変更にはデュアルライトが最も安全な選択肢になります。

アプリ側の切り替え順序をどう設計するか

「DBを先に変えてからアプリを変える」がデフォルトになる理由

アプリとDBの変更順序について、原則は「DBへの追加を先に行い、アプリの参照切り替えを後にする」です。新しいカラムを先にDBに追加しておけば、古いアプリはそのカラムを無視するため互換性が保たれます。逆の順番、つまりアプリが新カラムを参照するコードを先にデプロイすると、カラムが存在しないためSQLエラーが即座に発生します。この原則はRails、Django、Laravelなどのマイグレーション機能を使う場合でも変わらず、「マイグレーション実行→アプリデプロイ」の順序をCIパイプラインに組み込んで自動化しておくことが重要です。順序が属人的な手順書に依存している構成では、リリース作業者が変わるたびに逆順が発生するリスクがあります。

フィーチャーフラグとデプロイ順序の組み合わせ

新しいカラムへの読み込み切り替えをフィーチャーフラグで制御すると、デプロイと機能有効化のタイミングを切り離せます。先に新コードをデプロイしつつフラグはオフのまま運用し、バックフィルが完了してデータが揃った段階でフラグをオンに切り替える設計です。フラグはDBのコンフィグテーブルや環境変数で管理するケースが多く、アプリの再デプロイなしに有効化できる点がメリットです。フラグの有効化後は旧ロジックへのフォールバックが発動していないかモニタリングし、問題なければフラグを廃止して旧コードを削除するというサイクルを回します。フラグが長期間残留すると管理コストが上がるため、有効化から2リリース以内を廃止の目安として設計時に定めておきます。

複数サービスが同一DBを参照する場合の複雑性

マイクロサービス構成や複数のバッチジョブが同一のDBを参照している場合、全サービスの切り替えタイムラインを揃える必要があります。あるサービスが新カラムに切り替え済みでも、別のサービスが旧カラムに書き続けていると、デュアルライトの終了判断が難しくなります。このような環境では、全サービスの旧カラム参照が完全になくなったことをSQLのアクセスログやAPMツールで確認してからカラム削除に進む手順が必要です。サービス数が10を超えるような環境では、カラム削除フェーズだけで1〜2ヶ月の観察期間を設けているチームもあります。削除の判断を担当者の記憶に依存させず、「旧カラムへのクエリゼロ」という定量的な条件をモニタリングで確認できる仕組みを設計に組み込みます。

切り戻し(ロールバック)を用意する判断

どこまで切り戻せるかを設計段階で確認する

マイグレーション設計の段階で「この変更は切り戻せるか」を明示的に確認しておくことが重要です。カラムの追加はDROPで戻せますが、カラムの削除はデータを失うため切り戻せません。インデックスの追加は比較的容易に戻せますが、NOT NULL制約の追加は既存データを変更しているため戻すと再びnull値が混入するリスクがあります。切り戻せる変更と切り戻せない変更を変更計画に一覧化し、切り戻し不能な変更の前後には必ずバックアップの取得タイミングを設計に組み込みます。この確認を省略すると、問題発生時に「戻せない」と気づいて初めて対応策がなくなるという事態に陥ります。

データ型変更・カラム削除はロールバックできないと判断する基準

データ型を変えたカラムは、元の型に戻す際にデータの損失や変換エラーが発生するため、実質的にロールバック不能と判断します。たとえばTEXTをJSONBに変えた後に元のTEXTに戻す場合、再変換できても構造が崩れることがあります。カラム削除も同様で、削除後に旧データを復元するにはバックアップからのリストアが必要になり、その間のデータ差分は失われます。これらの変更は「ロールバックできない変更として扱う」と設計書に明記し、関係者の認識を合わせておくことが後のトラブルを防ぎます。「たぶん戻せる」という曖昧な前提でリリースを承認する判断は避けるべきです。

切り戻し不能な変更の前に置くべきバックアップ設計

切り戻し不能な変更を実施する前には、物理バックアップまたはスナップショットを取得し、整合性を確認してからマイグレーションを開始する手順を設計に組み込みます。RDSやCloud SQLのようなマネージドDBであれば、スナップショット取得は自動化できますが、取得完了と整合性チェックが終わるまで次のステップに進まないゲートを設けることが大切です。また、切り戻しが必要になった場合のリストア手順と所要時間の目安(例として1TBのDBで約40〜60分)を事前に確認し、障害発生時に判断が遅れないようにしておきます。リストア手順が机上の想定のみで実際に試していないケースは現場で散見されるため、ステージングでのリストア検証を手順に含めることを推奨します。

大規模テーブルで行き詰まる境界の見極め

行数・レプリケーション遅延・ロック待機タイムアウトが交差する限界

テーブル行数が1億行を超えてくると、オンラインスキーマ変更ツールを使っても問題が複合的に絡み合い始めます。コピー処理中のI/Oが増大してレプリカが遅延し、遅延が一定を超えるとツールが自動スロットリングを掛けて処理が長期化します。また、アプリ側のロック待機タイムアウト(InnoDBのデフォルトは50秒)に引っかかってリクエストが落ち始めることもあります。この3要素が同時に発生する境界を事前にステージングで計測し、「この行数・このスキーマ変更はツールXでは対応できない」という判断基準を数字で持っておくことが設計の起点になります。計測なしに本番で初めて限界に気づく、というパターンが長期停止事故につながります。

バッチ処理でデータ移行する際の分割設計

大規模テーブルで新カラムへのバックフィルを行う場合、全行を一度に更新するUPDATEは使えません。主キーの範囲でバッチを分割し、1バッチあたり1,000〜5,000行を目安に更新するループ設計が基本です。1バッチ処理後に数十〜数百ミリ秒のsleepを挟むことでI/Oの山を均し、レプリカへの負荷を抑えます。バッチの進捗はカウンターテーブルや進捗ログに記録し、中断・再開ができるように設計します。現場の経験では、5,000万行のバックフィルを1バッチ2,000行・sleep 100msで実施した場合、完了まで約8〜12時間かかることが多く、この所要時間を事前に見積もってデプロイスケジュールに反映させることが必要です。

クラウド環境でのスケールアウトとマイグレーション競合

オートスケールが有効なクラウド環境では、マイグレーション実行中にアプリインスタンスが増減することで予期しない競合が発生するケースがあります。複数のアプリインスタンスがそれぞれ起動時にマイグレーションを実行しようとする設計になっていると、同じALTERが同時に走って重複エラーになることがあります。これを防ぐために、マイグレーション実行はデプロイパイプラインの中で単一のジョブとして行い、アプリ起動時の自動マイグレーションは本番環境では無効化しておく設計が安全です。ECSやKubernetesのJobリソースでマイグレーションを実行し、完了後にアプリのローリングアップデートを開始する順序を明示的に制御します。この順序制御がパイプラインに存在しない構成は、スケールアウト時に初めて問題が露見します。

無停止マイグレーション設計チェックリスト

以下のチェックリストは、設計フェーズで確認すべき判断ポイントを整理したものです。本番適用前にチームで一通り照合することで、見落としによる停止リスクを下げられます。

  1. 変更の分類確認: 今回のスキーマ変更はカラム追加・削除・型変更・制約追加のどれに該当するかを分類し、それぞれの排他ロック発生リスクを確認した。
  2. 後方互換性の確認: 変更後のスキーマで既存のアプリコードが動作するか(旧コードが新スキーマで壊れないか)を確認した。
  3. Expand-Contract設計の採用: カラム名変更・テーブル構造の変更にはExpand-Contractパターンを適用し、一括変更を回避する設計としている。
  4. デプロイ順序の明示: DBマイグレーション実行→アプリデプロイの順序をパイプラインに明示的に定義し、自動化している。
  5. バックフィル計画の策定: 新カラムへのデータ移行(バックフィル)のバッチサイズ・sleep間隔・所要時間の目安を見積もっている。
  6. ロールバック可否の確認: 今回の変更はロールバック可能かどうかを判断し、切り戻し不能な変更の前にバックアップ取得のゲートを置いている。
  7. 並走期間の設定: 新旧カラム・テーブルの並走期間をリリースサイクルに基づいて定義し、旧構造の削除タイミングを決めている。
  8. 大規模テーブルの計測: 対象テーブルの行数・サイズをステージングで計測し、ツール選択(直接ALTERかオンライン変更ツールか)の根拠を数字で持っている。
  9. レプリカへの影響確認: マイグレーション実行中のレプリケーション遅延の許容値を定め、遅延が超えた場合の中断基準を決めている。
  10. 複数サービス参照の確認: 同一DBを参照する他サービス・バッチの有無を確認し、全サービスの切り替えタイムラインを揃えている。
  11. マイグレーション実行の単一化: 本番環境でアプリ起動時の自動マイグレーションを無効化し、パイプラインの単一ジョブで実行する設計にしている。
  12. モニタリング設計: マイグレーション実行中のロック待機・レプリカ遅延・エラーレートを監視するアラートを事前に設定している。

▶ 手を動かして本番まで到達する

その設計判断を、RHEL10の本番環境で手を動かして固めませんか

ここまでの設計判断は、読めば腹に落ちます。ですが「自分の手で本番まで通しきる自信」は、それとは別の話になります。

最大6名の少人数で、構築実務者の到達点まで引き上げます。座学で終わらせず、本番相当の環境で判断を検証する2日間です。

無停止マイグレーションをRHEL10本番で検証する上級講座へ →

Linuxセミナー上級編(RHEL10)|最大6名の少人数ハンズオン