Zero-Downtime Database Migrations with Drizzle ORM

Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分で読める1.3k 語数|

Drizzle ORM を使用したダウンタイムゼロのデータベース移行

データベースの移行は、運用環境の展開において最も危険な操作です。テーブルをロックする移行は、たとえ 200 ミリ秒であっても、接続タイムアウトを引き起こし、500 件のエラーに連鎖し、午前 2 時にアラート ストームにつながります。ほとんどの ORM 移行チュートリアルでは、アプリをシャットダウンし、移行を実行し、再起動するという「簡単な」パスが示されています。それはダウンタイムゼロではありません。それは定期メンテナンスです。

実際のゼロダウンタイム移行には、別のメンタル モデルが必要です。スキーマは、展開プロセス全体を通じて、現在実行中のアプリケーション コードと下位互換性がなければなりません。これは、古いコードと新しいコードの両方がロールアウト ウィンドウ中に同時に動作する必要があることを意味します。エキスパンド/コントラクト パターンによりこれが可能になります。 Drizzle ORM の SQL ファーストのアプローチにより、それを正しく実装するための制御が可能になります。

重要なポイント

  • 運用環境では drizzle-kit push を決して実行しないでください。SQL を生成してレビューし、制御して適用します。
  • 拡張-契約パターン: 追加 (拡張) → 新しいコードをデプロイ → 古いコードを削除 (契約)
  • Null 許容列の追加は安全です。デフォルトなしで NOT NULL を追加するのは危険です
  • 列の名前を変更するには、次の手順が必要です: 新しい列の追加 → バックフィル → コードの更新 → 古い列の削除 (4 ステップ)
  • PostgreSQL CREATE INDEX CONCURRENTLY を使用してインデックス作成時のテーブル ロックを回避する
  • 運用環境に適用する前に、運用環境サイズのデータベースのコピーで移行をテストします。
  • 常にロールバック計画を立てます。移行ごとに、ロールバック SQL を作成してテストします。
  • テーブルの書き換えを必要とするスキーマ変更 (大きなテーブルの ALTER TYPE) にはメンテナンス期間が必要です

Drizzle ORM スキーマのセットアップ

// packages/db/src/schema/contacts.ts
import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';

export const contactStatusEnum = pgEnum('contact_status', ['active', 'inactive', 'archived']);

export const contacts = pgTable('contacts', {
  id:             uuid('id').primaryKey().defaultRandom(),
  organizationId: uuid('organization_id').notNull(),
  name:           varchar('name', { length: 255 }).notNull(),
  email:          varchar('email', { length: 255 }),
  phone:          varchar('phone', { length: 50 }),
  status:         contactStatusEnum('status').default('active').notNull(),
  notes:          text('notes'),
  createdAt:      timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt:      timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
  orgIdIdx:  index('contacts_org_id_idx').on(table.organizationId),
  emailIdx:  index('contacts_email_idx').on(table.email),
  statusIdx: index('contacts_status_idx').on(table.status),
}));

移行を生成します。

# Generate SQL migration (never auto-apply to production)
npx drizzle-kit generate

# Review the generated SQL before applying
cat drizzle/0001_add_contact_status.sql

# Apply to development database
npx drizzle-kit migrate

# For production: apply via your deployment pipeline

エキスパンド・コントラクト・パターン

基本原則: すべてのスキーマ変更は、複数の下位互換性のあるデプロイメントに分割されます。

フェーズ 1: 拡張 (分割せずに追加)

追加のみのスキーマ変更をデプロイします。つまり、デフォルトを使用した新しい列、新しいテーブル、新しいインデックスです。

-- Migration: 0010_expand_add_company.sql
-- Safe to apply while old code is running: nullable column with a default

ALTER TABLE contacts
  ADD COLUMN IF NOT EXISTS company_id UUID,
  ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);

-- Old code ignores these columns; new code uses them
-- Both versions coexist during rolling deployment
// Drizzle schema after expansion
export const contacts = pgTable('contacts', {
  // ... existing columns ...
  companyId:   uuid('company_id'),           // nullable — old code ignores it
  companyName: varchar('company_name', { length: 255 }), // nullable — safe
});

フェーズ 2: 新しいアプリケーション コードをデプロイする

古い列と新しい列の両方を読み書きする新しいアプリ バージョンをデプロイします。ローリング デプロイメント中は、古いインスタンス (企業サポートなし) と新しいインスタンス (企業サポート) が同時に実行されます。列が NULL 可能であるため、両方とも機能します。

フェーズ 3: 埋め戻し

テーブルのロックを避けるために、既存の行の新しい列を小さなバッチで作成します。

-- Migration: 0011_backfill_company_name.sql
-- Run in small batches to avoid locking

DO $$
DECLARE
  batch_size INTEGER := 1000;
  offset_val INTEGER := 0;
  rows_updated INTEGER;
BEGIN
  LOOP
    UPDATE contacts
    SET company_name = 'Unknown'
    WHERE company_name IS NULL
      AND id IN (
        SELECT id FROM contacts
        WHERE company_name IS NULL
        ORDER BY id
        LIMIT batch_size
        OFFSET offset_val
      );

    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;

    offset_val := offset_val + batch_size;
    -- Brief pause between batches to reduce I/O pressure
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

フェーズ 4: 契約 (古い列を削除)

すべてのアプリケーション コードが更新され、バックフィルが完了したら、古い列または制約を削除します。


安全な移行パターン

NOT NULL 列の追加

-- WRONG — will fail if table has rows (no default, no nullable)
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL;

-- WRONG — locks table while it writes the default to every row
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL DEFAULT 'free';

-- CORRECT — three-step approach
-- Step 1: Add nullable column
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20);

-- Step 2: Backfill existing rows
UPDATE contacts SET tier = 'free' WHERE tier IS NULL;

-- Step 3 (next deployment): Add NOT NULL constraint (instant if no NULLs exist)
ALTER TABLE contacts ALTER COLUMN tier SET NOT NULL;
ALTER TABLE contacts ALTER COLUMN tier SET DEFAULT 'free';

列の名前を変更する (4 ステップのプロセス)

-- Step 1: Add the new column
ALTER TABLE contacts ADD COLUMN full_name VARCHAR(255);

-- Step 2: Backfill + keep in sync with a trigger
UPDATE contacts SET full_name = name;

CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
  NEW.full_name := NEW.name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER contacts_sync_full_name
  BEFORE INSERT OR UPDATE OF name ON contacts
  FOR EACH ROW EXECUTE FUNCTION sync_full_name();

-- Step 3: Deploy new code that writes to full_name, reads from both
-- Step 4 (next deployment, after all instances updated):
DROP TRIGGER contacts_sync_full_name ON contacts;
DROP FUNCTION sync_full_name;
ALTER TABLE contacts DROP COLUMN name;

列挙型の変更

PostgreSQL では、テーブル全体を書き換えずに列挙値を削除することはできません。列挙型を安全に変更するには:

-- Adding a new enum value is safe (instant, no lock)
ALTER TYPE contact_status ADD VALUE IF NOT EXISTS 'pending';

-- Removing an enum value requires a full table rewrite
-- Use the expand-contract pattern: add a new enum type, migrate, drop old
CREATE TYPE contact_status_new AS ENUM ('active', 'inactive', 'archived', 'pending');

-- Migrate data
ALTER TABLE contacts
  ALTER COLUMN status TYPE contact_status_new
  USING status::text::contact_status_new;

DROP TYPE contact_status;
ALTER TYPE contact_status_new RENAME TO contact_status;

ロックを使用しないインデックスの作成

通常の CREATE INDEX は、一定期間すべての書き込みをブロックする ShareLock を取得します。大きなテーブルでは、これには数分かかる場合があります。

-- WRONG — locks writes during index creation
CREATE INDEX contacts_email_idx ON contacts(email);

-- CORRECT — concurrent index creation; no write lock
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts(email)
  WHERE email IS NOT NULL; -- Partial index for better performance

-- Drizzle note: drizzle-kit does not generate CONCURRENTLY by default
-- Edit the generated SQL migration to add CONCURRENTLY before applying

Drizzle 移行ファイルで、生成後に CONCURRENTLY を手動で追加します。

-- drizzle/0012_add_email_index.sql (edited after generation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts (email)
  WHERE email IS NOT NULL;

本番環境 CI/CD での霧雨移行

実稼働環境では drizzle-kit push を決して使用しないでください。レビュー手順なしで移行が適用されます。バージョン管理されている生成された SQL ファイルで drizzle-kit migrate を使用します。

# scripts/deploy-production.sh

echo "=== Running database migrations ==="

# Check if there are pending migrations
PENDING=$(npx drizzle-kit migrate --dry-run 2>&1 | grep "pending")
if [ -n "$PENDING" ]; then
  echo "Pending migrations detected:"
  echo "$PENDING"

  # Apply migrations
  npx drizzle-kit migrate
  echo "Migrations applied successfully"
else
  echo "No pending migrations"
fi
// Programmatic migration in main.ts (NestJS)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';

async function runMigrations() {
  const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(migrationClient);

  await migrate(db, {
    migrationsFolder: join(__dirname, '..', '..', '..', 'drizzle'),
  });

  await migrationClient.end();
}

// In bootstrap(), before app.listen():
if (process.env.RUN_MIGRATIONS === 'true') {
  await runMigrations();
}

ロールバック戦略

すべての移行には、展開前にコンパニオン ロールバック移行を作成し、テストする必要があります。

// drizzle/0013_add_company_id.sql (forward migration)
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id UUID;
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_company_id_idx
  ON contacts(company_id);

// drizzle/rollbacks/0013_rollback_add_company_id.sql
DROP INDEX CONCURRENTLY IF EXISTS contacts_company_id_idx;
ALTER TABLE contacts DROP COLUMN IF EXISTS company_id;

緊急ロールバックの場合:

# Emergency rollback script
# Run the rollback SQL, then redeploy the previous app version

psql "$DATABASE_URL" < drizzle/rollbacks/0013_rollback_add_company_id.sql
git checkout HEAD~1
pnpm build && pm2 restart ecosystem.config.cjs --update-env

移行テスト戦略

// packages/db/src/tests/migration.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';

describe('Database Migrations', () => {
  let sql: ReturnType<typeof postgres>;

  beforeAll(async () => {
    sql = postgres(process.env.TEST_DATABASE_URL!);
  });

  it('all migrations apply cleanly on a fresh database', async () => {
    const db = drizzle(sql);
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });

  it('migrations are idempotent (can be applied twice safely)', async () => {
    const db = drizzle(sql);
    await migrate(db, { migrationsFolder: './drizzle' });
    // Running again should be a no-op, not an error
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });
});

メンテナンス時間を必要とする危険な操作

非常に大きなテーブル (数億行) では、一部の操作をゼロダウンタイムにすることはできません。

操作リスク緩和
大きな列の ALTER TYPE USINGテーブル全体の書き換え、長いロックメンテナンス期間のスケジュール
ADD COLUMN NOT NULL デフォルトなし行が存在する場合は即時に失敗します。 3 段階の展開と契約を使用する
CREATE INDEX (CONCURRENTLY なし)期間中の書き込みロック常に CONCURRENTLY を使用してください。
コード0即時ですが、古いコードは壊れますコード更新後の展開フェーズのみ
コード0不可逆的常にバックアップを用意してください。 30 日間の論理的な削除ポリシー
コード0期間中のテーブルロックメンテナンス中は通常の VACUUM + スケジュール FULL を使用します。

よくある質問

drizzle-kit pushdrizzle-kit migrate の違いは何ですか?

push は、移行ファイルを生成せずに、現在のスキーマをデータベースに直接適用します。これは、移行履歴を気にしない開発に役立ちます。 migrate は、drizzle/ ディレクトリ内の SQL ファイルを順番に適用し、適用されたものを追跡します。運用環境では常に migrate を使用して、すべてのスキーマ変更の履歴をバージョン管理して確認し、管理できるようにします。

Drizzle は drizzle_migrations 追跡テーブルをどのように処理しますか?

Drizzle は、どの移行ファイルがいつ適用されたかを記録する drizzle_migrations (または __drizzle_migrations) テーブルを作成します。移行を適用する前に、このテーブルがチェックされます。このテーブルの行を手動で挿入または削除しないでください。これは Drizzle の状態追跡メカニズムです。以前の移行状態に戻す必要がある場合は、ロールバック SQL を実行し、対応する行を手動で削除します。

実稼働サイズのデータベースに対して移行をテストするにはどうすればよいですか?

運用データベースのサニタイズされた (PII が削除された) コピーをステージング サーバーに復元します。そこに移行を適用し、実時間、ロック待機時間 (pg_stat_activity から)、テーブルの肥大化 (pg_stat_user_tables から)、クエリ パフォーマンスへの影響 (EXPLAIN ANALYZE から) を測定します。ステージングで移行に 5 秒以上かかる場合は、再設計してください。

データベースの移行にトランザクションを使用できますか?

PostgreSQL のほとんどの DDL はトランザクション対応です。ALTER TABLECREATE TABLECREATE INDEX (CREATE INDEX CONCURRENTLY ではありません) をトランザクション内でラップし、いずれかのステップが失敗した場合はロールバックできます。 Drizzle は、デフォルトで各移行ファイルをトランザクション内にラップします。 CONCURRENTLY を使用する移行の場合は、トランザクションの外部で実行される別の移行ファイルに分割する必要があります。

TypeScript で Drizzle 列挙型の比較を処理するにはどうすればよいですか?

Drizzle の列挙型列は、データベースから文字列値を返します。比較する場合、TypeScript は型を正しく絞り込めない場合があります。明示的にキャストする: if ((contact.status as ContactStatus) === 'active')、または Drizzle where 句で型アサーションを使用する: where(eq(contacts.status, 'active' as ContactStatus))。これにより、TypeScript の動作を維持しながら実行時エラーが回避されます。


次のステップ

ダウンタイムゼロのデータベース移行には規律が必要ですが、代替策 (ダウンタイム、データ破損、午前 3 時の緊急ロールバック) ははるかに悪いです。展開コントラクト パターン CREATE INDEX CONCURRENTLY と適切なロールバックの準備により、スキーマの変更は日常的な安全な操作になります。

ECOSIRE は、Drizzle ORM、PostgreSQL 17、および 65 以上のスキーマ ファイルと 300 以上の移行でテストされたダウンタイムなしの展開パイプラインを使用して、NestJS アプリケーションのデータベース スキーマを管理します。 バックエンド エンジニアリング サービスを探索 して、大規模なデータベース操作を安全に処理する方法を学びます。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット