はじめに — 金曜の夜のALTER TABLE
多くの障害の根本原因をたどると、「誰かが本番DBに手でSQLを流した」という一行に行き着きます。アプリケーションコードはPRレビューを経て、テストを通過し、自動デプロイされるのに、そのコードが依存するデータベーススキーマは、誰かがSSHで接続してALTER TABLEを直接実行する方法で変えられます。金曜の夜、眠い目で打った一行のSQLがロックを握ってテーブルを止め、翌朝には障害会議が開かれます。
この記事の主張はシンプルです。スキーマ変更をアプリケーションコードとまったく同じに扱おう、ということです。バージョン管理し、PRでレビューし、CIで検証し、CDで自動適用し、問題が起きたらロールバックします。これが「マイグレーションをコードのように」扱うGitOpsのアプローチです。
本稿では、マイグレーションツールの動作原理から、パイプライン統合、環境ごとの承認ゲート、Kubernetesでの適用、ドリフト検知、シークレット管理、ロールバック自動化、観測まで、実務の観点で整理します。
マイグレーションツールの動作原理
マイグレーションツールは共通して「どの変更がどこまで適用されたか」を追跡します。核心はメタデータテーブルです。
- Flyway: `flyway_schema_history` テーブルに、適用されたマイグレーションのバージョン、チェックサム、実行時刻を記録します。
- Liquibase: `DATABASECHANGELOG` テーブルにchangeset単位で記録し、`DATABASECHANGELOGLOCK` で同時実行を防ぎます。
- Atlas: 望む最終スキーマを宣言すると、現在の状態と比較して差分を自動的にSQLとして生成します。
方式は大きく二つに分かれます。
+---------------------------+-------------------------------------------+
| 命令型 (versioned) | 宣言型 (declarative) |
+---------------------------+-------------------------------------------+
| 「この手順を順に実行」 | 「最終状態はこれ」 |
| V1__init.sql | schema.hcl / schema.sql |
| V2__add_email.sql | ツールが差分を計算してSQLを生成 |
| Flyway, golang-migrate | Atlas, (Liquibaseの一部) |
| 変更履歴が明確 | 意図が明確、ドリフト修正が容易 |
+---------------------------+-------------------------------------------+
命令型は変更の順序と履歴が明確でデバッグが容易、宣言型は「望む状態」がそのままコードなのでドリフト修正が容易です。実務では命令型を基本に使いつつ、Atlasのような宣言型ツールでドリフトを検知するハイブリッドがよく使われます。
Flywayマイグレーションの例
-- V2__add_user_status.sql
ALTER TABLE users
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active';
CREATE INDEX CONCURRENTLY idx_users_status ON users (status);
ここでの核心は `CREATE INDEX CONCURRENTLY` です。通常の `CREATE INDEX` はテーブルに書き込みロックをかけ、インデックスが作られる間INSERT/UPDATEを止めます。大きなテーブルでは数分間サービスが止まりえます。CONCURRENTLYはロックなしでインデックスを作りますが、トランザクション内では実行できないという制約があり、マイグレーションツールの設定でそのステップのトランザクションを無効にする必要があります。
PRでの検証 — リントとドライラン
マイグレーションをコードのように扱うとは、PR段階で自動的に検証するということです。人がレビューする前に、機械が先にリスクを捕まえます。
マイグレーションリント
危険なパターンを静的に捕まえるリンターがあります。代表的なのはAtlasの `migrate lint`、squawk(PostgreSQL用)です。これらが捕まえる代表的なリスクは次のとおりです。
- デフォルトなしの `NOT NULL` カラム追加(テーブル全体の再書き込みを誘発)
- 通常の `CREATE INDEX`(書き込みロック)
- カラム型の変更(テーブル再書き込み)
- カラム/テーブルの削除(旧バージョンのアプリが壊れうる)
- `ALTER TABLE ... ADD COLUMN ... DEFAULT`(旧DBで全体再書き込み)
.github/workflows/migration-lint.yml
name: migration-lint
on:
pull_request:
paths:
- 'migrations/**'
jobs:
lint:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ariga/setup-atlas@v0
- name: Lint new migrations against main
run: |
atlas migrate lint \
--dir "file://migrations" \
--dev-url "postgres://postgres:postgres@localhost:5432/dev?sslmode=disable" \
--git-base "origin/main"
このワークフローはmainブランチに対して新しく追加されたマイグレーションだけを検査します。危険なパターンが見つかればPRが赤くなり止まります。
ドライラン — 実際に適用してみる
リントが静的解析なら、ドライランは本当に適用してみる動的検証です。CIで本番DBのスキーマを複製した一時DBを立て、マイグレーションを実際に実行してみます。
CIで一時Postgresに本番スキーマをダンプして適用し、マイグレーション実行
pg_dump --schema-only "$PROD_RO_URL" > schema.sql
psql "$CI_DB_URL" -f schema.sql
flyway -url="$CI_DB_URL" -locations="filesystem:./migrations" migrate
ドライランでマイグレーションが壊れたり、予想より長くかかったり、ロック競合が起きたりすれば、PR段階で捕まります。本番に適用する前の最後の安全網です。
マイグレーション衝突の検知
複数の開発者が同時にマイグレーションを追加すると、バージョン番号が衝突します。二つのPRがそれぞれ `V5__...sql` を作ると、両方マージされた後どちらが先に実行されるか曖昧になります。
main: V1 V2 V3 V4
|
PR-A ----------+--- V5__add_phone.sql
PR-B ----------+--- V5__add_avatar.sql <- 同じバージョン! 衝突
解決策は三つです。
- タイムスタンプバージョン: `V20260616103000__...` のようにミリ秒まで書けば衝突確率はほぼゼロです。
- CI衝突検査: 同じバージョン番号が二つ以上あればビルドを失敗させます。
- 順序非依存の設計: LiquibaseのchangesetのようにIDで追跡すれば順序依存が減ります。
重複バージョン番号の検知 (CIステップ)
dupes=$(ls migrations/ | grep -oE '^V[0-9]+' | sort | uniq -d)
if [ -n "$dupes" ]; then
echo "重複マイグレーションバージョンを発見: $dupes"
exit 1
fi
環境ごとの自動適用と承認ゲート
マイグレーションは段階的に環境を通過すべきです。devには自動、stagingには自動、productionには人の承認を経て適用するのが一般的です。
[PRマージ]
|
v
+--------+ +-----------+ +--------------+
| dev | ---> | staging | ---> | production |
| 自動 | | 自動 | | 手動承認ゲート |
+--------+ +-----------+ +--------------+
|
(Slack通知 + 承認者2名)
GitHub Actionsではenvironment protection ruleで承認ゲートを作ります。
.github/workflows/migrate-deploy.yml
name: migrate-deploy
on:
push:
branches: [main]
paths:
- 'migrations/**'
jobs:
migrate-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: ariga/setup-atlas@v0
- name: Apply to staging
env:
DB_URL: ${{ secrets.STAGING_DB_URL }}
run: atlas migrate apply --dir "file://migrations" --url "$DB_URL"
migrate-production:
needs: migrate-staging
runs-on: ubuntu-latest
environment: production # 承認ゲートのかかった環境
steps:
- uses: actions/checkout@v4
- uses: ariga/setup-atlas@v0
- name: Apply to production
env:
DB_URL: ${{ secrets.PROD_DB_URL }}
run: atlas migrate apply --dir "file://migrations" --url "$DB_URL"
`environment: production` を指定したジョブは、GitHub設定でrequired reviewersを設定すると、承認まで止まったままになります。承認者がボタンを押して初めて本番に適用されます。
Kubernetesでの適用 — Jobとinitコンテナ
Kubernetes環境では、マイグレーションをどう実行するかが別の問題になります。二つのパターンがあります。
パターン1: デプロイ前のJob
アプリをデプロイする前に、マイグレーションだけを実行するJobを回し、成功したら次の段階へ進みます。
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
spec:
backoffLimit: 0 # 失敗時の再試行禁止 (重複実行リスク)
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: myorg/migrations:1.4.2
command: ["flyway", "migrate"]
env:
- name: FLYWAY_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
Helmのhook(`helm.sh/hook: pre-install,pre-upgrade`)やArgo CDのPreSync hookで、このJobをデプロイ直前に実行するよう束ねます。
パターン2: initコンテナ
各Podが起動するときにinitコンテナでマイグレーションを実行する方式です。シンプルですが、Podが複数だと同時にマイグレーションを試みる問題があります。マイグレーションツールのロック(Flyway、Liquibaseともにロック機構があります)がこれを防ぎますが、推奨はJobパターンです。
推奨: Job(またはhook)で単一実行 -> アプリデプロイ
回避: 全Podのinitコンテナを同時実行 -> ロック競合
ドリフト検知
ドリフトとは「コード(マイグレーション)が期待するスキーマ」と「実際の本番DBのスキーマ」がずれた状態です。誰かが手でカラムを追加したり、ホットフィックスでインデックスを作ったのにマイグレーションに反映しなかったときに起きます。
Atlasで本番DBとマイグレーションディレクトリの差分を検査 (定期cron)
atlas migrate diff --dir "file://migrations" \
--to "postgres://...prod..." \
--dev-url "postgres://...dev..." \
--format '{{ sql . }}'
出力が空でなければドリフト存在 -> アラート
ドリフトが検知されたら二つの対応が可能です。本番をコードに合わせる(追加マイグレーションを書く)か、コードを本番に合わせる(予期しない変更をマイグレーションに吸収)。どちらにせよ、ドリフトを放置すると次のマイグレーションが予測不能に壊れます。定期的にcronで検査してSlackに通知するのが良いです。
シークレットと認証情報
マイグレーションはDB認証情報を扱うので、シークレット管理がセキュリティの核心です。
- CI/CDシークレットストア: GitHub Actions Secrets、GitLab CI variablesにDB URLを保存し、環境ごとに分離します。
- 動的認証情報: HashiCorp Vaultのdatabase secrets engineでマイグレーション実行時点に短命の認証情報を発行すれば、認証情報が漏れてもすぐ失効します。
- 最小権限: マイグレーションアカウントにはDDL権限だけを与え、アプリアカウントと分離します。本番マイグレーションアカウントを普段は無効にし、パイプラインでだけ有効にするパターンもあります。
[アンチパターン] マイグレーションSQLファイルにパスワードをハードコード
[アンチパターン] 全環境が同じDBアカウントを共有
[推奨] 環境ごとのシークレット分離 + 最小権限 + (可能なら) 動的認証情報
ロールバック自動化
マイグレーションが間違っていたときどう戻すかは、最も難しいテーマです。核心原則は「ロールバック可能なマイグレーションを設計せよ」です。
undoスクリプト
Flyway TeamsやLiquibaseは、各マイグレーションに対応するundo/rollbackスクリプトを書けます。
-- V5__add_status.sql (forward)
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
-- U5__add_status.sql (undo)
ALTER TABLE users DROP COLUMN status;
しかしundoが常に安全とは限りません。カラムをDROPするとその間に溜まったデータが消えます。そこでより安全なアプローチがexpand-contract(拡張-縮小)パターンです。
Expand-Contractパターン
スキーマ変更を可逆な小さなステップに分割します。
1. Expand: 新カラム/テーブルを追加 (旧アプリと互換、戻しやすい)
2. Migrate: アプリが新旧両方の構造に書き込み (二重書き込み)
3. Backfill: 既存データを新構造に埋める
4. Switch: アプリが新構造だけを読むよう切り替え
5. Contract: 旧カラム/テーブルを削除 (十分安定した後)
各ステップが小さく可逆なので、問題が起きたらそのステップだけ戻せばよいのです。「カラム名変更」のように見える作業も(新カラム追加 → 二重書き込み → バックフィル → 読み取り切り替え → 旧カラム削除)に分ければ無停止で安全にできます。
観測とアラート
マイグレーションは見えないところで起きやすいので、明示的に観測すべきです。
- 実行ログ: どのマイグレーションがいつ、どれだけかかって適用されたかを構造化ログで残します。
- 所要時間メトリクス: マイグレーション実行時間をメトリクスとして出力し、普段より長くかかったら警告します。
- アラート: マイグレーションの成功/失敗をSlackなどに通知します。特に本番適用は必ず通知します。
マイグレーション結果をSlackに通知するステップ
- name: Notify Slack
if: always()
run: |
status="${{ job.status }}"
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"DBマイグレーション [production]: $status\"}"
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
全体パイプライン事例 — GitHub Actions
これまでの断片を一つにまとめると次の流れになります。
開発者: migrations/ に新SQLを追加 -> PR
|
v
[CI on PR] リント(squawk/atlas) + 一時DBドライラン + 重複バージョン検査
|
v (マージ)
[CD] dev自動適用 -> staging自動適用 -> production承認ゲート
|
v
Kubernetes: pre-sync Jobでマイグレーション -> アプリロールアウト
|
v
定期cron: ドリフト検知 -> 異常時Slackアラート
この流れの核心は、人が本番DBに直接触れる経路がないことです。すべての変更はPRを通過し、自動的に検証され、追跡されます。
マイグレーションのテスト — コードのようにテストする
マイグレーションをコードのように扱うなら、コードのようにテストすべきです。ユニットテストのレベルでマイグレーションの正確性を検証する方法があります。
forwardとbackwardの往復テスト
マイグレーションを適用し(forward)、戻した(backward)後、スキーマが元どおりに戻るか確認します。この往復テストはundoスクリプトの正確性を保証します。
マイグレーション往復テスト (CI)
flyway -url="$CI_DB_URL" migrate # 最新まで適用
flyway -url="$CI_DB_URL" undo # 最後のマイグレーションを戻す
flyway -url="$CI_DB_URL" migrate # 再度適用
スキーマダンプを比較してidempotentか確認
pg_dump --schema-only "$CI_DB_URL" > after.sql
diff expected.sql after.sql
データ保存テスト
スキーマを変えるマイグレーションが既存データを壊さないか確認します。シードデータを入れた後マイグレーションを適用し、データが正しく変換されたか検証します。
-- テストシナリオ: マイグレーション前のシードデータ
INSERT INTO users (id, name, email) VALUES
(1, 'alice', 'alice@example.com'),
(2, 'bob', 'bob@example.com');
-- マイグレーション適用後、変換結果を検証するアサーション
-- (例: statusカラムがすべて'active'で埋まったか)
SELECT count(*) FROM users WHERE status IS NULL; -- 0であるべき
こうしたテストをCIパイプラインに入れれば、マイグレーションの回帰(regression)を自動で捕まえます。特にデータ変換ロジックの入ったマイグレーションは必ずテストすべきです。
レガシーDBへの自動化導入 — ベースライン
すでに本番稼働中で、マイグレーションツールを使っていなかったデータベースに自動化を導入するにはベースライン(baseline)が必要です。現在の本番スキーマを「出発点0」と宣言し、それ以降の変更だけをマイグレーションで管理します。
1. 現在の本番スキーマをダンプ -> V1__baseline.sql (既存状態)
2. マイグレーションツールにベースラインを登録 (flyway baseline)
- メタデータテーブルが「V1まで適用済み」と記録される
3. 以降すべての変更はV2__...からマイグレーションで
4. 既存のデータ/スキーマには触れず、漸進的に自動化領域を拡大
ベースラインの核心は「既存状態を再現しようとしない」ことです。本番にすでに存在するスキーマをマイグレーションで作り直そうとすると衝突します。代わりに現在の状態を認め、その上で今後の変更だけを管理します。こうすればリスクなく漸進的にGitOpsワークフローへ移行できます。
複数サービスが共有するDB
マイクロサービス環境で複数サービスが一つのDBを共有すると、誰がスキーマを所有するか曖昧になります。推奨は「スキーマの所有権を一つのサービスに明確に置く」です。マイグレーションディレクトリもそのサービスのリポジトリに置き、他のサービスは読み取りだけにします。長期的にはサービスごとにスキーマ(またはDB)を分離して所有権を明確にするのが良いです。
よくある落とし穴
- 大きなテーブルにロックをかけるマイグレーション: `ALTER TABLE` がACCESS EXCLUSIVEロックを握るとサービスが止まります。CONCURRENTLY、バッチバックフィル、expand-contractで回避します。
- マイグレーションとアプリデプロイの順序: カラムを先に消してアプリをデプロイすると、その間旧アプリがないカラムを参照して壊れます。常にexpandが先、contractが後です。
- トランザクション内のDDL想定: 一部のDDL(CONCURRENTLYなど)はトランザクション外で実行する必要があります。ツール設定を確認します。
- ロールバックをデータ復旧と混同: DROPされたカラムのデータはundoで戻りません。バックアップとPITR(point-in-time recovery)が別途必要です。
- チェックサム変更: すでに適用されたマイグレーションファイルを後で修正するとチェックサムがずれてツールが拒否します。適用済みマイグレーションは絶対に修正しません。
チェックリスト
デプロイ前に次を確認してください。
- [ ] マイグレーションがバージョン管理されPRレビューを経たか
- [ ] CIでリントとドライランを通過したか
- [ ] 重複バージョン番号の衝突がないか
- [ ] 大きなテーブル変更にCONCURRENTLY/バッチバックフィルを適用したか
- [ ] expand-contractで可逆に設計したか
- [ ] 本番適用に承認ゲートがかかっているか
- [ ] シークレットが環境ごとに分離され最小権限か
- [ ] ドリフト検知cronが動作するか
- [ ] マイグレーションの成功/失敗がアラートに行くか
- [ ] バックアップ/PITRが最新で復旧手順が検証済みか
おわりに
マイグレーション自動化の本質はツールではなく原則です。スキーマをコードのように扱い、変更を小さく可逆に分割し、人の直接介入をパイプラインに置き換えることです。FlywayでもLiquibaseでもAtlasでも、どのツールを使ってもこの原則は同じです。金曜の夜に手で打ったALTER TABLEが消える日、あなたのデータベースはようやくコードと同じくらい信頼できる対象になります。
参考資料
- Flyway公式ドキュメント: https://flywaydb.org/documentation/
- Liquibase公式ドキュメント: https://docs.liquibase.com/
- Atlas公式ドキュメント: https://atlasgo.io/getting-started
- golang-migrate: https://github.com/golang-migrate/migrate
- squawk (PostgreSQLマイグレーションリンター): https://squawkhq.com/
- PostgreSQL ALTER TABLEドキュメント: https://www.postgresql.org/docs/current/sql-altertable.html
- AWS DMSドキュメント: https://docs.aws.amazon.com/dms/
- Argo CD Sync Phases and Hooks: https://argo-cd.readthedocs.io/en/stable/user-guide/resource_hooks/
현재 단락 (1/250)
多くの障害の根本原因をたどると、「誰かが本番DBに手でSQLを流した」という一行に行き着きます。アプリケーションコードはPRレビューを経て、テストを通過し、自動デプロイされるのに、そのコードが依存する...