データベースマイグレーション戦略|安全にスキーマ変更するための実践ガイド

kento_morota 19分で読めます
社内のAI・IT活用、技術がわかる相手に無料相談。 相談 →
目次

「本番環境のテーブルにカラムを追加したいけれど、サービスを止めずにできるのか?」「マイグレーションを実行したら戻せなくなった」「チーム開発でマイグレーションの競合が起きる」――データベースのスキーマ変更は、アプリケーション開発で避けて通れない作業ですが、失敗すると深刻な障害につながります。

本記事では、データベースマイグレーションの基本概念から、安全に実行するための戦略、ゼロダウンタイムマイグレーションの手法、主要なツールの使い方まで、実践的なガイドとして解説します。

データベースマイグレーションの基本

データベースマイグレーションとは、データベースのスキーマ(テーブル構造、カラム定義、インデックスなど)をバージョン管理しながら段階的に変更していくプロセスです。

なぜマイグレーションが必要なのか

アプリケーションの機能追加や改善に伴い、データベースのスキーマも変化します。手動でSQL文を実行する方法では、以下の問題が発生します。

  • 再現性がない:誰がいつ何を変更したか追跡できない
  • 環境差異:開発環境とステージング環境、本番環境でスキーマが異なる
  • チーム開発:複数の開発者が同時にスキーマを変更する際に競合が起きる
  • ロールバック困難:問題が起きた際に元の状態に戻せない

マイグレーションツールを使えば、スキーマの変更をコードとしてバージョン管理し、どの環境でも同じ手順で適用・ロールバックできます。

マイグレーションの基本的な流れ

# 一般的なマイグレーションの流れ
1. マイグレーションファイルを作成(up/down の SQL を記述)
2. ローカル環境で適用してテスト
3. コードレビュー(PR に含める)
4. ステージング環境で適用してテスト
5. 本番環境で適用
6. 問題があればロールバック(down を実行)

マイグレーションツールの比較と使い方

主要なマイグレーションツールを紹介します。プロジェクトの技術スタックに合わせて選択しましょう。

Prisma Migrate(TypeScript/JavaScript)

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id          Int      @id @default(autoincrement())
  title       String
  content     String?
  published   Boolean  @default(false)
  author      User     @relation(fields: [authorId], references: [id])
  authorId    Int
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}
# マイグレーションの作成と適用
npx prisma migrate dev --name add_user_and_post_tables

# 本番環境への適用
npx prisma migrate deploy

# マイグレーション状態の確認
npx prisma migrate status

# スキーマのリセット(開発環境のみ!)
npx prisma migrate reset

Drizzle Kit(TypeScript)

// src/db/schema.ts
import { pgTable, serial, varchar, text, boolean, timestamp, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).unique().notNull(),
  name: varchar('name', { length: 100 }).notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content'),
  published: boolean('published').default(false),
  authorId: integer('author_id').references(() => users.id).notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});
# マイグレーションファイルの生成
npx drizzle-kit generate

# マイグレーションの適用
npx drizzle-kit migrate

# スキーマの差分確認
npx drizzle-kit check

golang-migrate(Go / 汎用CLIツール)

言語に依存しない汎用的なマイグレーションツールで、生SQLファイルを管理します。

# インストール
brew install golang-migrate

# マイグレーションファイルの作成
migrate create -ext sql -dir db/migrations -seq create_users_table
-- db/migrations/000001_create_users_table.up.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
-- db/migrations/000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;
# マイグレーションの適用
migrate -path db/migrations -database "postgresql://user:pass@localhost:5432/mydb?sslmode=disable" up

# 1つ戻す
migrate -path db/migrations -database "..." down 1

# 特定バージョンまで適用
migrate -path db/migrations -database "..." goto 3

# 現在のバージョン確認
migrate -path db/migrations -database "..." version

安全なマイグレーションの原則

本番環境でマイグレーションを安全に実行するために、守るべき原則があります。

原則1:前方互換性を保つ

マイグレーションとアプリケーションのデプロイは同時に行われるとは限りません。古いコードと新しいスキーマ、新しいコードと古いスキーマ、どちらでも動作するように設計しましょう。

-- 悪い例:カラム名の変更を1ステップで行う
ALTER TABLE users RENAME COLUMN name TO display_name;
-- → 古いコードが「name」カラムを参照してエラーになる

-- 良い例:3ステップに分ける
-- Step 1: 新カラムを追加
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);
UPDATE users SET display_name = name;

-- Step 2: アプリケーションを更新(両方のカラムに書き込む)
-- ... デプロイ ...

-- Step 3: 古いカラムを削除(すべてのサーバーが新コードに切り替わった後)
ALTER TABLE users DROP COLUMN name;

原則2:小さな変更を頻繁に

大きなマイグレーションは失敗リスクが高く、ロールバックも困難です。変更を小さく分割しましょう。

-- 悪い例:1つのマイグレーションに全部詰め込む
-- migration_20260327_big_refactor.sql
CREATE TABLE new_users (...);
INSERT INTO new_users SELECT ... FROM old_users;
DROP TABLE old_users;
ALTER TABLE new_users RENAME TO users;
CREATE TABLE orders (...);
CREATE TABLE products (...);
-- ... 数百行のSQL

-- 良い例:小さなマイグレーションに分割
-- 001_create_new_users.sql
-- 002_migrate_user_data.sql
-- 003_create_orders.sql
-- 004_create_products.sql
-- 005_drop_old_users.sql

原則3:必ずロールバック手順を用意する

-- up.sql
ALTER TABLE products ADD COLUMN sku VARCHAR(50);
CREATE UNIQUE INDEX idx_products_sku ON products(sku);

-- down.sql(ロールバック)
DROP INDEX IF EXISTS idx_products_sku;
ALTER TABLE products DROP COLUMN IF EXISTS sku;

ただし、データの削除を伴うマイグレーションは完全なロールバックが不可能です。カラムやテーブルの削除は慎重に行い、事前にバックアップを取得しましょう。

ゼロダウンタイムマイグレーションの手法

サービスを停止せずにスキーマ変更を行う手法を解説します。

カラムの追加(安全)

-- PostgreSQLでのカラム追加はロックが軽い(AccessExclusiveLockは一瞬)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- デフォルト値を持つカラムの追加
-- PostgreSQL 11以降は即座に完了(テーブルの書き換えが発生しない)
ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT false;

NOT NULL制約の安全な追加

-- 悪い例:直接NOT NULLを追加(テーブル全体のスキャンが発生)
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

-- 良い例:段階的にNOT NULLを追加
-- Step 1: CHECK制約をNOT VALIDで追加(即座に完了、既存行はチェックしない)
ALTER TABLE users ADD CONSTRAINT users_phone_not_null
  CHECK (phone IS NOT NULL) NOT VALID;

-- Step 2: 既存のNULLデータを埋める
UPDATE users SET phone = '未設定' WHERE phone IS NULL;

-- Step 3: 制約をVALIDATEする(Share Update Exclusive Lock、読み書きは可能)
ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;

-- Step 4: NOT NULL制約を追加しCHECK制約を削除
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_phone_not_null;

インデックスの安全な作成

-- 悪い例:通常のCREATE INDEX(テーブルへの書き込みがブロックされる)
CREATE INDEX idx_posts_title ON posts(title);

-- 良い例:CONCURRENTLYオプション(書き込みをブロックしない)
CREATE INDEX CONCURRENTLY idx_posts_title ON posts(title);

-- CONCURRENTLYの注意点
-- ・トランザクション内では使用できない
-- ・失敗した場合はINVALIDなインデックスが残る → 手動で削除が必要
-- ・通常のCREATE INDEXより時間がかかる

テーブル名・カラム名の変更(Expand/Contract パターン)

-- Expand/Contractパターンでカラム名を安全に変更する

-- Phase 1: Expand(拡張)
-- 新しいカラムを追加し、トリガーで同期
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);

CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
    NEW.display_name = COALESCE(NEW.display_name, NEW.name);
    NEW.name = COALESCE(NEW.name, NEW.display_name);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_user_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();

-- 既存データの同期
UPDATE users SET display_name = name WHERE display_name IS NULL;

-- Phase 2: Migrate(移行)
-- アプリケーションを新しいカラム名に切り替え

-- Phase 3: Contract(収縮)
-- 古いカラムとトリガーを削除
DROP TRIGGER trg_sync_user_name ON users;
DROP FUNCTION sync_user_name;
ALTER TABLE users DROP COLUMN name;

大量データの安全なマイグレーション

数百万行以上のテーブルでデータ変換を行う場合、一括処理はロックやディスクI/Oの問題を引き起こします。

バッチ処理でのデータ移行

// Node.jsでのバッチ処理例
async function migrateData() {
  const BATCH_SIZE = 1000;
  let lastId = 0;
  let processedCount = 0;

  while (true) {
    const rows = await db.query(`
      SELECT id, name
      FROM users
      WHERE id > $1 AND display_name IS NULL
      ORDER BY id
      LIMIT $2
    `, [lastId, BATCH_SIZE]);

    if (rows.length === 0) break;

    // バッチ単位でUPDATE
    for (const row of rows) {
      await db.query(`
        UPDATE users
        SET display_name = $1
        WHERE id = $2
      `, [row.name, row.id]);
    }

    lastId = rows[rows.length - 1].id;
    processedCount += rows.length;
    console.log(`Processed ${processedCount} rows (last id: ${lastId})`);

    // 負荷を抑えるため少し待機
    await new Promise(r => setTimeout(r, 100));
  }

  console.log(`Migration complete: ${processedCount} rows processed`);
}

PostgreSQLのバッチUPDATE

-- CTEを使ったバッチUPDATE(1000件ずつ処理)
WITH batch AS (
  SELECT id
  FROM users
  WHERE display_name IS NULL
  ORDER BY id
  LIMIT 1000
  FOR UPDATE SKIP LOCKED  -- ロック競合を回避
)
UPDATE users
SET display_name = name
WHERE id IN (SELECT id FROM batch);

-- このクエリを影響行数が0になるまで繰り返す

マイグレーションのベストプラクティス

CI/CDパイプラインでのマイグレーション

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run migrations on staging
        run: |
          npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}

      - name: Run integration tests
        run: npm test

      - name: Run migrations on production
        run: |
          npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}

      - name: Deploy application
        run: |
          # アプリケーションのデプロイ

マイグレーション前のチェックリスト

  • バックアップの取得:マイグレーション前に必ずバックアップを取る
  • ステージング環境でのテスト:本番と同じデータ量でテスト
  • ロールバック手順の確認:down マイグレーションが正しく動作するか確認
  • 実行時間の見積もり:大きなテーブルの場合、ALTERの所要時間を事前に把握
  • ロックの確認:どのレベルのロックが取得されるか理解しておく
  • アプリケーションの互換性:古いバージョンのコードでも動作するか確認
-- バックアップの取得(pg_dump)
pg_dump -h localhost -U postgres -d mydb -F c -f backup_20260327.dump

-- リストア(問題が発生した場合)
pg_restore -h localhost -U postgres -d mydb backup_20260327.dump

マイグレーションファイルの命名規則

# タイムスタンプベース(推奨:チーム開発での競合が少ない)
20260327120000_create_users_table.sql
20260327120100_add_email_to_users.sql
20260327120200_create_posts_table.sql

# シーケンス番号ベース
001_create_users_table.sql
002_add_email_to_users.sql
003_create_posts_table.sql

まとめ

データベースマイグレーションを安全に行うためのポイントを整理します。

  • マイグレーションツールを使う:手動SQL実行ではなく、ツールでバージョン管理する
  • 前方互換性を保つ:古いコードと新しいスキーマが共存できるように設計する
  • 小さく分割する:大きなマイグレーションを小さなステップに分ける
  • ロールバック手順を用意する:down マイグレーションを必ず書く
  • ゼロダウンタイム:CONCURRENTLY、NOT VALID、Expand/Contractパターンを活用
  • 大量データは段階的に:バッチ処理で負荷を分散する
  • CI/CDに組み込む:自動化してヒューマンエラーを防ぐ

データベースマイグレーションは「やり直しが効かない」作業だからこそ、慎重な計画と事前テストが重要です。本記事で紹介した原則とテクニックを活用し、安全なスキーマ変更を実践してください。

#マイグレーション#データベース#運用
ManageAI 先行モニター企業 募集中|先着5社・2026年11月末まで

生成AIの利用費、「誰が・何のために・いくら」まで見えていますか?

株式会社カイダンと共同開発したAI利用ガバナンスSaaS「ManageAI」は、OpenAI・Azure OpenAI・Claude・AWS Bedrock・Google Vertex AIの費用と利用状況を、部署・利用者・プロジェクト別に可視化します。導入は読み取り専用キーの登録だけ。初期費用0円・月額5万円(税別)で先行モニター企業を募集しています。

ニュースリリースを読む
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

※ プライバシーポリシーに基づき管理します

関連記事

Related / 9 articles

  1. プログラミング

    DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用

    DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。

  2. プログラミング

    WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド

    WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。

  3. プログラミング

    セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説

    CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。

  4. プログラミング

    クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計

    クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。

  5. プログラミング

    秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除

    APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。

  6. プログラミング

    開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御

    開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。

  7. プログラミング

    ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方

    NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。

  8. プログラミング

    ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方

    ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。

  9. プログラミング

    localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決

    localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。

ManageAI

先行モニター企業 募集中

生成AIのコストを「部署別」に、請求書より先に。

先着5社限定・2026年11月末まで。初期費用0円・月額5万円(税別)で、正式版と同等の機能に加え、開発者と伴走者による導入支援を追加費用なしで提供します。プロンプト原文は一切取得しません。

株式会社カイダン × Harmonic Society株式会社の共同事業|読み取り専用キーの登録だけで導入できます