データベースマイグレーションに専用パイプラインが必要な理由
アプリケーションのCI/CDパイプラインがしっかり機能しているとします。コードはビルドされ、テストが実行され、デプロイは自動で行われます。そんな中、誰かがプルリクエストを開き、usersテーブルに新しいカラムを追加しようとします。すると、これまでスムーズだったパイプラインが突然、違和感を覚えるものになります。データベースマイグレーションをアプリケーションコードと同じフローで実行すると、マイグレーションが完了するまでデプロイをブロックするか、マイグレーションを別途実行して何も壊れないことを祈るかの、どちらかを選ばざるを得なくなります。
問題は、データベースの変更はアプリケーションコードとは動作が異なるという点です。ビルドが失敗しても、新しいデプロイが行われないだけです。しかし、マイグレーションが失敗すると、データが破損したり、テーブルがロックされたり、データベースが不整合な状態のまま放置される可能性があります。スキーマ変更、データバックフィル、検証という固有のリスクに対応するために設計されたパイプラインが必要なのです。
アプリケーションパイプラインが不十分な理由
アプリケーションパイプラインは、ビルド、テスト、デプロイという単純なパターンに従います。テストが失敗すればパイプラインは停止します。デプロイが失敗すれば、問題を修正して再デプロイします。ロールバックは通常、以前のバージョンをデプロイするだけです。
データベースマイグレーションはこのモデルを破綻させます。マイグレーションはデータの構造や内容を変更します。マイグレーションのロールバックは、コード変更を元に戻すのと同じではありません。スキーマ変更を元に戻すには別のスクリプトを実行する必要があり、そのスクリプトはデータがすでに変換されていると失敗する可能性があります。また、古いレコードに新しい値をバックフィルし、マイグレーション後にデータが整合していることを検証する必要もあります。
データベースの変更をアプリケーションコードと同じパイプラインに無理に押し込もうとすると、妥協が生じます。マイグレーションの自動テストをスキップするか、メンテナンスウィンドウ中に手動で実行するか、本番環境で未テストのスクリプトを実行するリスクを受け入れるかのいずれかになります。
データベース変更のための専用パイプライン
解決策は、アプリケーションパイプラインから完全に分離した、データベース変更専用のパイプラインを作成することです。このパイプラインには、独自のステージ、承認ゲート、および監視機能があります。データベースの変更をアプリケーションリリースの副作用としてではなく、第一級のデプロイとして扱います。
以下は、ステージが順番にどのように動作するかを示したものです。
次のYAMLスニペットは、GitHub Actionsワークフローでこれらのステージを定義する方法を示しています。
name: Database Migration Pipeline
on:
pull_request:
paths:
- 'migrations/**'
jobs:
dry-run:
runs-on: ubuntu-latest
steps:
- run: ./scripts/dry-run.sh
migration:
needs: dry-run
runs-on: ubuntu-latest
steps:
- run: ./scripts/migrate.sh
backfill:
needs: migration
runs-on: ubuntu-latest
steps:
- run: ./scripts/backfill.sh
reconciliation:
needs: backfill
runs-on: ubuntu-latest
steps:
- run: ./scripts/reconcile.sh
rollback-test:
needs: reconciliation
runs-on: ubuntu-latest
steps:
- run: ./scripts/rollback.sh
- run: ./scripts/reconcile.sh
各ジョブは、前のジョブが成功した場合にのみ実行され、上記のパイプラインフローを反映しています。
次のフローチャートは、5つのステージとその進行を示しています。
ステージ1: ドライラン
新しいマイグレーションスクリプトがリポジトリに追加されるたびに、パイプラインはステージングデータベースに対してドライランを実行します。スクリプトは実行されますが、実際には何も変更しません。目的は、実際のデータに影響を与える前に、構文エラー、依存関係の欠落、またはロジックの問題を検出することです。
ドライランが失敗した場合、パイプラインは即座に停止します。チームに通知が送られ、それ以降のステージは実行されません。これにより、修正が容易な段階で、ほとんどの一般的なミスを早期に発見できます。
ステージ2: マイグレーション
ドライランが成功した後、パイプラインはステージングデータベースに対して実際のマイグレーションを実行します。これによりスキーマが変更されたりデータが変換されたりしますが、依然として安全な環境内です。パイプラインはすべてのステップ(開始時間、終了時間、影響を受けた行数、および警告)をログに記録します。
これらのログは監査証跡として機能します。後で問題が発生した場合、マイグレーション中に何が起こったかを正確に追跡できます。また、各ステップにかかった時間の記録も残るため、本番環境での実行時間を見積もるのに役立ちます。
ステージ3: バックフィル
一部のマイグレーションでは、既存のレコードにデータを入力する必要があります。たとえば、デフォルト値を持つ新しいカラムを追加する場合、数百万の既存行を更新する必要があるかもしれません。これを1回の大規模な更新として実行すると、テーブルが数分から数時間ロックされる可能性があります。
パイプラインはバックフィルを小さなバッチ(通常は1回の反復あたり1000行)で処理し、バッチ間に短い休止を入れます。これにより、データベースの応答性が維持され、長時間実行されるロックのリスクが軽減されます。パイプラインは各バッチの実行時間とエラー率を監視します。バッチが失敗した場合、パイプラインは停止し、アラートを送信します。自動再試行は行われません。失敗は調査が必要なより深い問題を示している可能性があるためです。
ステージ4: リコンシリエーション
マイグレーションとバックフィルが完了した後、パイプラインはリコンシリエーションスクリプトを実行します。これにより、マイグレーション前後のデータが比較されます。比較では、行数、特定のカラムのチェックサム、またはトランザクションテーブルの合計残高などの集計値を確認できます。
リコンシリエーションで予期しない差異が見つかった場合、パイプラインは失敗します。チームは先に進む前に調査する必要があります。このステージは、クラッシュを引き起こさなかったものの、誤った結果を生み出した、サイレントなデータ破損、部分的な更新、またはロジックエラーを検出します。
ステージ5: ロールバックテスト
パイプラインはロールバックスクリプトを実行し、マイグレーションがクリーンに元に戻せることを検証します。ロールバック後、リコンシリエーションを再度実行して、データが元の状態に戻ったことを確認します。
これは、自信を構築する上で最も重要なステージです。ステージングでロールバックテストが成功すれば、本番環境で問題が発生した場合に安全にマイグレーションを元に戻せることがわかります。テストが失敗した場合、パイプラインは停止し、マイグレーションは本番環境に進むことが許可されません。
本番環境での実行
ステージングで5つのステージすべてが成功した後、パイプラインは本番環境の準備が整います。ただし、プロセスは自動ではありません。ステージングと本番環境の間には手動承認ステップがあります。データベースに関する知識を持つ誰かが結果を確認し、本番実行を承認します。
本番環境では、パイプラインは同じシーケンス(ドライラン、マイグレーション、バックフィル、リコンシリエーション、ロールバックテスト)を実行します。違いは、監視がより厳格になり、異常が現れた場合にステージの途中でパイプラインを停止できることです。本番環境の各ステージには独自のロールバック機能もあるため、データベースを壊れた状態のままにすることなく、任意の時点で中断できます。
データベースパイプラインのための実践的チェックリスト
- データベースパイプラインをアプリケーションパイプラインから分離する
- 実際のマイグレーションの前に必ずドライランを実行する
- すべてのステップをタイムスタンプと行数とともにログに記録する
- バックフィルは監視付きの小さなバッチで実行する
- マイグレーションとバックフィルの後にリコンシリエーションチェックを追加する
- 本番環境の前にステージングでロールバックスクリプトをテストする
- 本番実行には手動承認を必須とする
- ステージの途中でパイプラインを停止できる機能を維持する
まとめ
データベースマイグレーションには、アプリケーションデプロイと同様の厳格さが求められます。ドライラン、バックフィル、リコンシリエーション、ロールバックテストの各ステージを備えた専用パイプラインにより、スキーマ変更がデータを静かに破壊しないという確信が得られます。追加のステージは各マイグレーションに時間を追加しますが、本番環境のインシデントとそれに続く必死の復旧作業を防ぐことで、はるかに多くの時間を節約します。データベースの変更を本番デプロイと同様に扱えば、すべての更新を通じてデータの一貫性が維持されます。