Architecture2026.08.10 · 10 min read

PostgreSQL から Cloudflare D1 へデータを移す専用ツールを書いた — 型の対応と、間違えたときに止まる仕組み

NCP認証基盤・クラウドインフラ

本番の PostgreSQL に入っているデータを Cloudflare D1(SQLite)へ移すために、専用の移行ツールを書いた。汎用ツールを探すのをやめた理由、型の対応をどう決めたか、間違ったデータベースに向けて実行しないための安全装置、移行後に件数を機械的に照合する仕組みを書く。最後に、本番データでリハーサルしたときに preflight が実データの制約違反を検出して止まった話をする。これが本番投入の窓の中ではなくリハーサルで起きたことに、このツールを書いた価値のほとんどがある。

汎用の移行ツールを探すのをやめた

PostgreSQL から SQLite へデータを移すツールは世の中にいくつもある。それでも自分で書いた理由は、この移行が「PG のデータを SQLite の形にする」作業ではなく、「新しいアプリケーションが読める形にする」作業だったからだ。

移行先のスキーマは、移行のために作られたものではない。新しい実装が最初から使うために書かれた0001_init.sqlがあり、そこにはアプリケーションの都合による型の選択が入っている。タイムスタンプは TEXT で、真偽値は INTEGER で、配列は JSON 文字列で持つ。どの列をどう表現するかはアプリケーション側がすでに決めていて、移行ツールの仕事はその決定に合わせることだった。汎用ツールは PG の型を機械的に SQLite の型へ写すので、この「アプリケーションが読める形」には届かない。

もう一つ、pg_dumpのテキスト出力をパースする案は最初に捨てた。ダンプはあくまで PG に流し戻すための表現で、そこから元の値を復元するにはパーサを書くことになる。それなら最初から pgx で型として正しく読むほうが短いし、変換の誤りがテストで固定できる。タイムスタンプが文字列として何桁で出ているかを推測する代わりに、time.Time を受け取って好きな形式で書き出せばいい。

出力を SQL ファイルにする

ツールが D1 に直接書き込む設計にはしなかった。出力するのはwrangler d1 execute --fileで流せる SQL ファイル群で、適用は別の手順にした。理由は単純で、生成した内容を適用前に人間が読めるからだ。移行は 1 回きりの操作で、やり直しのコストが高い。中身が読めるファイルが中間生成物として残っていると、適用前の確認と、適用後の「何を流したのか」の証拠の両方になる。

d1migrate の使い方
# 件数と型変換のレポートだけ出す。何も書かない
$ d1migrate dry-run --dsn "postgresql://.../..._test?sslmode=require"

# SQL ファイル生成(テーブルごとに 1 ファイル、番号順 = FK 安全な適用順)
$ d1migrate export --dsn "postgresql://..." --out ./d1-export

# 適用
$ for f in ./d1-export/*.sql; do
    wrangler d1 execute <DB_NAME> --remote --file "$f"
  done

# 適用後、PG と D1 の件数を突き合わせる
$ d1migrate verify --dsn "postgresql://.../..._test?sslmode=require"

ファイルはテーブルごとに 1 つで、ファイル名の先頭に連番を振ってある。この番号順がそのまま外部キー安全な適用順になるようにテーブルの並びを決めた。順序を運用手順書の注意書きに書くのではなく、ls の並び順に埋め込んでおけば、順番を間違える経路が消える。

各ファイルは先頭にDELETE FROM <table>を持つ。今回の切り替え方針が「書き込みを短時間止めて、全消しして全部入れ直す」だったからで、差分を追跡するパイプラインは作らなかった。データ量が小さいと分かっていたので、差分同期の複雑さを持ち込むより全入れ替えのほうが確実だと判断した。行が 0 件のテーブルにも DELETE は出力する。再実行で古い行が残らないようにするため。

INSERT は複数行をまとめた形で生成するが、1 文あたり50 行を上限にし、さらに80KBのバイト上限を併用している。D1 の HTTP API には 1 クエリあたり 100KB 程度の上限があり、行のサイズはテーブルによってまったく違うからだ。セッションデータを持つ行は 1 行が数 KB になるので、行数だけで区切ると上限を超える。逆に 1 行が上限を超えるほど大きい場合は、その行だけで 1 文にして出力する。黙って消えるより、import のときに大きな声で失敗したほうがいい。

型の対応は「読む側」から決める

対応表を先に固定して、それをドキュメントに書いてから実装した。判断の基準は一貫していて、新しい実装がその値をどう読むかに合わせる。

PG → D1 の型変換規則
PG                          D1 (TEXT/INTEGER)   変換
--------------------------  ------------------  ---------------------------------
timestamptz                 TEXT                ISO 8601 UTC ミリ秒
                                                2026-07-21T12:00:00.000Z
                                                (µs は切り捨て)
boolean                     INTEGER             0 / 1
text[]                      TEXT                JSON 配列(nil → [])
jsonb                       TEXT                無変換コピー(valid JSON 検査のみ)
bytea(bcrypt ハッシュ)      TEXT                ASCII 文字列(UTF-8/NUL 検査つき)
bytea(SHA-256 ダイジェスト)  TEXT                小文字 hex
bytea(credential_id)       TEXT                base64url パディングなし
bigserial / bigint          INTEGER             数値そのまま

タイムスタンプの形式が JavaScript のtoISOString()と同じ形なのは偶然ではない。新しいランタイムでは有効期限の比較を文字列の辞書順で行っている。ISO 8601 の UTC ミリ秒表現は、辞書順と時系列順が一致する。だから移行ツールが書き出すタイムスタンプは、1 つの例外もなくこの形でなければならない。Go 側は小数点以下 3 桁のフォーマットを使うことで、マイクロ秒を切り捨てる挙動まで揃えている。

jsonbの列は変換していない。PG が返したレンダリングをそのまま TEXT に入れ、valid な JSON かどうかだけを検査する。読む側は JSON としてパースするので、キーの順序や空白の違いは問題にならない。表現を変えなければ変換のバグも入らない、という判断はレスポンス側の検証を書いた別記事でも同じ結論になっている。

同じ bytea 列が、3つの別々の TEXT になる

変換で一番迷ったのが bytea だ。PG 側では同じ型なのに、D1 側での表現が列によって 3 通りに分かれた。型ではなくその列が何を保持しているかで決まるからだ。

  • bcrypt ハッシュを持つ列(パスワード、クライアントシークレット、登録アクセストークン)は、中身が実質 ASCII の文字列なので、そのまま TEXT に入れる。ただし UTF-8 として妥当か、NUL バイトを含んでいないかを検査する。
  • SHA-256 ダイジェストを持つ列(初期アクセストークン、招待トークン)は、小文字の hex 文字列にする。
  • WebAuthn の credential_id は、パディングなしの base64url にする。ブラウザの API が返す表現と揃える必要があるため。

この 3 つを取り違えたときに何が起きるかを考えると、検査を入れる理由がはっきりする。ハッシュの表現を間違えれば、そのユーザーはパスワードが合っているのにログインできない状態になる。しかも移行は成功したように見える。件数は合うし、行も入っている。

黙って壊れるより、うるさく失敗するほうがいい。bytea → TEXT の変換は、少しでも文字列として妥当でない値を見つけたら export 全体を失敗させる。移行を最初からやり直すコストのほうが、資格情報が壊れたデータベースを本番に置くコストよりずっと安い。

間違ったデータベースに向けて実行できないようにする

このツールは本番データベースに接続する。しかも開発中は何十回も実行する。手元のシェル履歴に本番 DSN が残っていて、テスト用のつもりで矢印キーを押したら本番だった、という事故が構造的に起こりうる。

そこで、データベース名に testshadow を含まない DSN は既定で拒否することにした。統合テストが同じ方針を取っていたので、それをツールにも持ち込んだ形だ。

internal/d1migrate/dsn.go
// CheckDSN は、テスト用データベースに見えないものに対しては
// --allow-prod を明示しない限り実行を拒否する。
func CheckDSN(dsn string, allowProd bool) error {
	u, err := url.Parse(dsn)
	if err != nil {
		return fmt.Errorf("parse DSN: %w", err)
	}
	// keyword/value 形式の DSN は url.Parse が「成功」してしまい、
	// 全体が Path に入るので下の部分一致をすり抜けうる。
	// データベース名を一意に取れる URL 形式だけを受け付ける。
	if u.Scheme != "postgres" && u.Scheme != "postgresql" {
		return fmt.Errorf("DSN must be a postgresql:// URL (keyword/value DSNs are not accepted)")
	}
	dbName := strings.ToLower(strings.TrimPrefix(u.Path, "/"))
	if dbName == "" {
		return fmt.Errorf("could not determine database name from DSN; use a postgresql://.../dbname URL")
	}
	if strings.Contains(dbName, "test") || strings.Contains(dbName, "shadow") {
		return nil
	}
	if allowProd {
		return nil
	}
	return fmt.Errorf("database %q contains neither 'test' nor 'shadow' — refusing without --allow-prod", dbName)
}

細かいところに url.Parseの落とし穴がある。PostgreSQL の DSN には URL 形式のほかにhost=... dbname=...という keyword/value 形式があり、これを url.Parse に渡してもエラーにならない。全体が Pathに押し込まれるだけだ。つまり、どこかに test という文字列が紛れているだけでガードをすり抜ける可能性がある。データベース名を一意に特定できない入力は、危険なほうに倒れるのではなく拒否する。

安全装置は全部で 4 つある。

  • データベース名に test / shadow を含まない DSN は、--allow-prod を明示しない限り拒否する。keyword/value 形式の DSN はデータベース名を特定できないので、常に拒否する(fail-closed)。
  • --dsn に環境変数のフォールバックを用意しない。どのデータベースに向けるかは、毎回コマンドラインに書かせる。
  • 出力先に既存のファイルがあれば、--force なしでは実行を拒否する。しかも全ファイルのパスを先に検査してから書き始める。途中で失敗して「一部だけ上書きされたディレクトリ」を残さないため。
  • bytea → TEXT の変換には UTF-8 / NUL の検査を入れる。資格情報のハッシュを黙って壊すくらいなら、export ごと失敗する。

--dsnに環境変数のフォールバックを用意しなかったのは意図的な選択だ。環境変数から既定値を取ると便利だが、どのデータベースに繋がっているかがコマンドを見ても分からなくなる。この操作に関しては、毎回明示的に書かせる不便さのほうが正しい。

本番データが D1 のスキーマを通らなかった

ここからがこの記事の本題になる。

移行先のスキーマは新規に書かれたもので、CHECK 制約と一意インデックスと外部キーが最初から有効になっている。一方、移行元の PG は何年か運用されてきたデータベースで、制約はあとから足されている。PG ではNOT VALID を付けて CHECK 制約を追加できる。既存行を検査せずに制約を付けられるという意味だ。同じように、一意インデックスを張る前から存在していた行は、そのインデックスの条件を満たしていないかもしれない。

つまり、移行元で問題なく存在している行が、移行先では INSERT できない。しかもそれは export のときには分からず、適用のときに初めて分かる。SQL ファイルを流している途中で 1 行だけ落ちる、というのが一番まずい。

そこで export の先頭に preflight を置いた。書き出しを始める前に PG 側へ 2 本のクエリを投げ、1 件でも該当があれば export ごと失敗させる。

internal/d1migrate/export.go
// preflight は、D1 の import を壊すデータ(あるいは黙って入って
// 不変条件を破るデータ)を見つけたら export ごと失敗させる。
// PG がこれらを許しているのは CHECK が NOT VALID で作られているから、
// そして一意インデックスより古い行が存在するから。D1 の新しい
// スキーマは許さない。
checks := []struct{ label, sql string }{
	{
		// D1 のインライン CHECK は INSERT 時にこれを弾く
		"oauth2_clients rows violating skip_consent-excludes-DCR CHECK",
		`SELECT id FROM oauth2_clients
		   WHERE skip_consent AND registration_access_token_hash IS NOT NULL`,
	},
	{
		// D1 の users_email_lower_idx が 2 行目を弾く
		"users with case-insensitively duplicated emails",
		`SELECT LOWER(email) FROM users
		   GROUP BY LOWER(email) HAVING count(*) > 1`,
	},
}

2026-07-24、本番の実データでリハーサルをした。本番 Neon の中身は多くない。移行対象になる refresh_token の行は 285 件で、アカウントはいずれも開発・検証用のものだった。

まず本番に対して読むだけの dry-run を回した。ここで止まった。

preflight が返したエラー
# dry-run(本番に対して read-only)が止まった
# ※ 実際のクライアント id はここでは伏せている

preflight failed: oauth2_clients rows violating skip_consent-excludes-DCR CHECK:
  <client_id> — clean these up in PG before exporting

管理用クライアントが 1 件、DCR(動的クライアント登録)で作られていながらskip_consent が立っていた。移行先のスキーマにはこれを禁じるoauth2_clients_skip_consent_excludes_dcr という CHECK 制約がある。動的に登録されたクライアントが同意画面を飛ばせてしまうのは危ないので、新しい実装ではその組み合わせを構造的に禁止した。移行元にはその制約がなかったので、行が存在できていた。

修正は、影響を確認したうえで移行元の側に入れた。該当クライアントのregistration_access_token_hash を NULL にして DCR 扱いを外し、first-party クライアントとしての性質は保つ。運用上の実態に合わせた形だ。再度 dry-run を回して通過、そこから export に進んだ。

01
dry-run against production (read-only)本番 DSN に対して読むだけの dry-run。ファイルは 1 つも書かない。
02
preflight halts the exportD1 の CHECK 制約に違反する行を 1 件検出し、export そのものを失敗させる。
03
fix the source data, re-runPG 側を修正して再度 dry-run。今度は通る。
04
export 551 rows into 12 filesテーブルごとに 1 ファイル、ファイル番号順が FK 安全な適用順になる。
05
load into a real D1 schemaSTRICT + CHECK + FK ON の実スキーマを持つ dev D1 へ流し込む。
06
verify: every table count matchesPG と D1 の件数を全テーブルで突き合わせ、一致を確認する。
本番データでのリハーサル(2026-07-24)

export は 551 行 / 12 ファイル。これを実際の D1 へ流し込んだ。STRICT モードで、CHECK 制約が有効で、外部キーが ON になっている本物のスキーマに対してだ。全件入り、全テーブルで件数が一致した。移行したクライアントで認可フローの開始まで確認して、リハーサルを終えた。

この 1 件が見つかったのが、本番切り替えの窓の中ではなくリハーサルだった、というのがすべてだ。切り替え当日は書き込みを止めた状態で作業する。その最中に preflight が止まっていたら、選択肢は「本番データを慌てて直す」か「切り戻す」しかない。どちらも、時間の制約がない日にやれば 5 分で終わる作業だった。

preflight を「あったほうがいい検査」ではなく「export の必須の前段」として書いたのは、こういう問題が移行の直前まで存在に気づけない性質を持っているからだ。移行元のアプリケーションは正常に動いている。移行先のスキーマも正しい。両方正しくて、繋いだ瞬間だけ壊れる。

移した「つもり」を件数で潰す

SQL を流し終えたあと、「入った」と言えるのは何を確認したときか。ファイルにエラーが出なかったこと、ではない。verify を別サブコマンドとして書いたのはそのためで、移行元と移行先の行数を全テーブルで突き合わせ、1 つでも食い違えば非ゼロ終了する。

重要なのは、照合が export とまったく同じフィルタを使うことだ。全件移すテーブルなら単純な件数比較でいいが、そうでないテーブルがある。リクエストのテーブルは type ごとに移行対象が違うので、type 別に数える。有効期限切れを除外して移したテーブルは、両側で同じ時刻を基準に「未失効・未使用の行」を数える。移行先でも ISO 文字列としてexpires_at > nowを評価すれば、辞書順が時系列順と一致するので移行元と同じ答えになる。

この設計には前提が 1 つあって、import の直後に流すことを想定している。時間が経つと移行元側の失効フィルタと移行先の実データがずれて、実害のない不一致が出うる。これは仕様として受け入れて、ドキュメントに書いた。

移行先の件数を取る経路も設計の対象にした。wrangler をサブプロセスで呼ぶのではなく、Cloudflare の D1 REST API を直接叩く。理由は 2 つある。1 つは wrangler の出力形式に依存しなくなること。もう 1 つは、この経路が書き込めないことをコードで保証できることだ。

照合用の HTTP クライアントは、SELECT で始まらない SQL を実行時に拒否する。「照合するだけのコマンド」がデータを壊す経路を、レビューではなく型と分岐で塞いでおく。

テストは実際の D1 を必要としない形にした。REST の呼び出しは httptest で置き換えて、件数が一致したとき・しなかったときの両方を単体テストで固定してある。移行元側の件数取得も関数型で受け取るようにして、テストでは in-memory の実装に差し替える。

変換そのものの検証には、別に統合テストを用意した。使い捨ての PG を立てて実際のアプリケーション API 経由でデータを書き、export して、生成された SQL を移行先の実スキーマ(STRICT + CHECK + 外部キー ON)に replay する。フィルタが効いているか、メールアドレスの正規化、credential_id が base64url になっているか、招待トークンが hex になっているか、セッションデータが往復して生き残るかを、そこで固定した。

意図的に移さなかったもの

移行の設計で決めることの半分は「何を移すか」ではなく「何を移さないか」だった。移さないと決めたものが 3 種類ある。

  • セッション。全員が再ログインする前提にした。移すと寿命の短いデータのために変換コードが増える。
  • 認可コード・PKCE・OpenID の一時レコード。いずれも短命で、切り替えの瞬間に進行中だったフローは再ログインで回復する。移行対象は type='refresh_token' の行だけに絞った。
  • パスキー(WebAuthn クレデンシャル)。対象アカウントは全員パスワードを持っていたため、移行せず破棄すると決めた。ツール側にこのテーブルだけの特別扱いは入れていない。破棄の判断が移行元のデータに反映された結果、件数照合は 0 対 0 の一致として通っている。

移さないものを決めておくと、件数照合の意味が変わる。「全部のテーブルで件数が一致すること」を条件にすると、移さないと決めたテーブルが常に不一致になって、照合結果を人間が読み流すようになる。移行対象のフィルタを export と verify で共有しておけば、期待どおりの結果は全一致になり、不一致が出たときに必ず立ち止まる状態が保てる。

実際の切り替え当日は、書き込みを止めた直後に最終同期を取った。短命なアクセストークンも含める必要があったのでフラグを 1 つ足して、最終的な export は 1854 行になった。リハーサルの 551 行との差は、このフラグで新たに含まれるようになった短命なアクセストークンと、リハーサル以降に発行された分だ。リハーサルの成果物を本番に流さず当日に取り直したのはこのためで、先に作っておくと古くなる。

まとめ

データ移行のツールで書くべき量のうち、値を変換するコードは半分もない。残りは間違った実行を止めるコード移し終わったことを機械的に主張するコードだった。移行は 1 回きりの操作で、失敗したときに「たぶん大丈夫」を確かめる手段が手元にないからだ。

そして、移行ツールが最初に見つける問題は、たいてい移行ツールのバグではなく移行元のデータのほうだ。長く動いているデータベースには、あとから足した制約を満たしていない行が残っている。新しいスキーマがそれを弾くのは正しい振る舞いで、問題は「いつ弾かれるか」でしかない。preflight を export の必須の前段に置いたのは、その「いつ」を自分で選ぶためだった。

本番データでリハーサルを一度やる、という手順にも同じ意味がある。移行の機構が動くことを確かめるためではない。本番のデータが移行の機構を通ることを確かめるためだ。この 2 つは違う。

MigrationPostgreSQLCloudflare D1Go