💡 Tips

Cloudflare D1 × Drizzle ORM マイグレーション運用手順|本番・プレビュー分離とロールバック

結論:D1のマイグレーション運用は「環境分離」と「前方互換な変更」で9割決まる

Cloudflare D1 と Drizzle ORM の組み合わせでハマるポイントは、ほぼ次の3つに集約される。

  1. 本番とプレビューで別のDBを向いていない(ローカル実行と --remote の取り違えを含む)
  2. SQLite の ALTER TABLE 制約を知らずに破壊的マイグレーションを流してしまう
  3. ロールバック手段を用意していない(D1 には「1つ戻す」コマンドがない前提で設計する必要がある)

先に運用の型を決めておけば、ここは事故らない。この記事では実際の drizzle.config.ts・wrangler.toml・コマンド手順まで含めて、運用手順を組み立てる。

なお D1 の無料枠・上限・コマンド仕様は変更されうるため、数値や CLI オプションは必ず公式ドキュメントの最新情報を確認してほしい。

前提:Drizzle Kit は「SQL生成」、適用は wrangler が担当する

D1 は通常の Postgres/MySQL のようにローカルから直接接続できない。そのため役割分担はこうなる。

工程担当コマンド
スキーマ定義Drizzle(TypeScript)schema.ts を書く
差分SQL生成Drizzle Kitdrizzle-kit generate
SQL適用Wranglerwrangler d1 migrations apply
適用履歴管理D1側のテーブル自動記録される

ここを混同して「drizzle-kit migrate が本番に効かない」と悩むケースが多い。D1 では適用は wrangler 側の仕事と覚えておくと迷わない。

Step 1:スキーマとDrizzle設定の実コード

schema.ts

// src/db/schema.ts
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';

export const notes = sqliteTable(
  'notes',
  {
    id: text('id').primaryKey(),
    userId: text('user_id').notNull(),
    title: text('title').notNull(),
    body: text('body').notNull().default(''),
    createdAt: integer('created_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
  },
  (t) => ({
    userIdx: index('notes_user_id_idx').on(t.userId),
  }),
);

drizzle.config.ts

// drizzle.config.ts
import type { Config } from 'drizzle-kit';

export default {
  schema: './src/db/schema.ts',
  out: './migrations',        // wrangler が読むディレクトリと揃える
  dialect: 'sqlite',
  driver: 'd1-http',          // D1 向けドライバ
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    token: process.env.CLOUDFLARE_D1_TOKEN!,
  },
} satisfies Config;

ポイントは out を wrangler.toml の migrations_dir と一致させること。ここがズレると「生成したのに適用されない」が起きる。

Drizzle Kit はバージョンによって設定キー名(driver / dialect 周り)が変わってきた経緯がある。手元のバージョンの公式ドキュメントを確認したうえで書くのが安全だ。

Step 2:本番/プレビュー環境の分離設定

事故の温床がここ。環境ごとに別の D1 データベースを作り、wrangler.toml の environment で明示的に分ける。

# wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2025-01-01"
migrations_dir = "migrations"

# デフォルト(開発者のローカル・プレビュー用)
[[d1_databases]]
binding = "DB"
database_name = "my-app-preview"
database_id = "xxxxxxxx-preview-uuid"

[env.production]
[[env.production.d1_databases]]
binding = "DB"
database_name = "my-app-prod"
database_id = "yyyyyyyy-prod-uuid"

適用コマンドは環境ごとにこうなる。

# ローカル(miniflare のローカルSQLite)
npx wrangler d1 migrations apply my-app-preview --local

# プレビュー(リモート実DB)
npx wrangler d1 migrations apply my-app-preview --remote

# 本番
npx wrangler d1 migrations apply my-app-prod --remote --env production

つまずきポイント3つ

  • --local と --remote の取り違え:--local はローカルのファイルにしか効かない。「適用したのに本番が変わらない」の典型原因
  • --env production の付け忘れ:environment を切っている場合、付け忘れるとデフォルト(プレビュー)側のバインディングが使われる
  • Pages のプレビューデプロイ:Pages 側は Production/Preview のバインディングを別々に設定できる。ダッシュボードで両方に同じ DB を刺していないか必ず確認する

package.json にスクリプト化して、手打ちの余地を消しておくのが実務的だ。

{
  "scripts": {
    "db:gen": "drizzle-kit generate",
    "db:local": "wrangler d1 migrations apply my-app-preview --local",
    "db:preview": "wrangler d1 migrations apply my-app-preview --remote",
    "db:prod": "wrangler d1 migrations apply my-app-prod --remote --env production"
  }
}

Step 3:日常のマイグレーション手順

  1. schema.ts を編集する
  2. npm run db:gen で migrations/0003_xxx.sql を生成
  3. 生成されたSQLを必ず目で読む(後述の理由で最重要)
  4. npm run db:local でローカル適用 → テスト実行
  5. npm run db:preview でプレビューに適用 → 実環境で動作確認
  6. コードをデプロイ
  7. 本番バックアップ(エクスポート)を取ってから npm run db:prod

手順3を飛ばさないこと。Drizzle Kit はカラム名の変更を「削除+追加」と解釈することがあり、対話プロンプトで rename か確認してくる。ここを雑に流すとデータが消える。

Step 4:SQLite / D1 の制約を踏まえた「壊れない変更」

D1 は SQLite ベースなので、ALTER TABLE でできることが限られる。列の型変更や制約変更は、実際には新テーブル作成 → データコピー → 旧テーブル削除 → リネームという手順の SQL が生成される。テーブルが大きいと重く、途中失敗のリスクもある。

安全側に倒すなら、次の順で運用する(Expand and Contract パターン)。

フェーズやること特徴
ExpandNULL許容の新カラム追加旧コードも動く(前方互換)
Migrate新旧両方に書き込むコードをデプロイ/既存行をバックフィルダウンタイムなし
Contract旧カラムを参照しないコードをデプロイ後、旧カラム削除十分に間を空ける

つまり 1回のデプロイで「カラム追加+NOT NULL+旧カラム削除」を同時にやらない。3回に分ければ、どの段階でも切り戻しできる状態を保てる。

注意点として、大量行のバックフィルは1回のクエリでやらず、LIMIT 付きのバッチで分割するほうがよい。D1 にはクエリ時間やレスポンスサイズの制限があり、上限値は変更されうるので公式の最新情報を確認してほしい。

Step 5:ロールバック手順の現実解

正直に書くと、D1 には「直前のマイグレーションだけを自動で戻す」仕組みは用意されていない(少なくとも執筆時点の CLI にはない)。Drizzle Kit も down マイグレーションを標準生成しない。したがって、ロールバックは自前で設計する必要がある。

実務で機能する順に3つ。

A. コードだけ切り戻す(第一選択)

スキーマを前方互換に保っていれば、DBは触らずコードを前のバージョンに戻すだけで復旧できる。Expand and Contract を守る最大の理由がこれ。Workers のデプロイはロールバックが速い。

B. 打ち消しSQLを手で当てる

事前に migrations/down/0003_down.sql のような打ち消しSQLをセットで書いておき、必要時に実行する。

npx wrangler d1 execute my-app-prod --remote --env production \
  --file=./migrations/down/0003_down.sql

ただし DROP COLUMN した後にこれをやってもデータは戻らない。構造は戻せてもデータは戻らない点は正しく理解しておく。

C. バックアップから復元(最終手段)

本番適用の直前に必ずエクスポートを取る。

npx wrangler d1 export my-app-prod --remote --env production \
  --output=./backup/prod-$(date +%Y%m%d-%H%M).sql

D1 には Time Travel(過去の時点への復元)機能もあるが、保持期間やプランごとの扱いは変わりうる。頼る前に公式ドキュメントで現在の仕様を確認しておくこと。

これに加えて、CI で「本番適用前に必ずエクスポートを取る」ステップを挟んでおくと、人間の判断に依存しなくなる。個人開発でも、この1ステップの有無が事故の被害を大きく分ける。

デメリット・注意点

公平のために、この構成の弱点も挙げておく。

  • ロールバックが手動前提:RDBのマイグレーションツールに慣れていると面倒に感じる
  • プレビュー環境のデータ差:プレビューDBのデータ量が本番と違うと、重いマイグレーションの所要時間を見誤る
  • Drizzle Kit の設定仕様が変化しやすい:バージョンアップ時に設定キーの移行が必要になる場合がある
  • SQLite方言の制約:Postgres前提の設計をそのまま持ち込むと、後から型変更で苦しむ

それでも、Workers との統合の手軽さと運用コストの低さは大きい。Cloudflare スタックで作るなら、Cloudflare Queues × Workers TypeScript で作る最小ジョブキューのような非同期処理と組み合わせて、バックフィルをキュー経由で流す設計も相性がよい。

SQLite の内部仕様やインデックス設計まで踏み込むなら、

SQLアンチパターン 第2版 ―データベースプログラミングで陥りがちな失敗とその対策
📚 おすすめ書籍

SQLアンチパターン 第2版 ―データベースプログラミングで陥りがちな失敗とその対策

参考価格¥4,290(税込)

スキーマ設計の失敗パターンを先に知っておくと、D1の制約下でも判断を誤りにくい

Amazonで最新価格をチェック

のような一冊を手元に置いておくと判断が速くなる。

まとめ:チェックリスト

本番適用の前に、この6項目を確認する。

  • 生成SQLを目視した(意図しない DROP がないか)
  • --env と --remote が正しい
  • ローカル → プレビューの順で検証済み
  • 前方互換な変更になっている(コードだけ切り戻せる)
  • 本番のエクスポートを取得済み
  • 打ち消しSQLを用意した

手順をスクリプト化し、環境を物理的に分け、変更を小さく刻む。この3つだけで D1 のマイグレーション運用はかなり安定する。