はじめに
「ダウンタイム10.5時間」と見積もりに書いた瞬間、それが現実的な数字ではないとわかっていました。
既存の移行計画はシンプルでした。PostgreSQL 14(オンプレミス)上の240テーブルを、pg_dump → Datastream → Dataflow → Cloud Spanner のパイプラインで丸ごと移す。手順は正しい。ただし Datastream 約180分・Dataflow 約180分というボトルネックが2つ連続するため、合計ダウンタイムは約10.5時間になります。
削減策を探るうちに、根本的な問いにたどり着きました。
「240テーブルを全部移す必要があるのか。T1(事前移行時刻)以降に変化したテーブルだけ移せばいいのではないか」
差分同期という考え方自体は自明です。問題は「どのテーブルに変化があるか」を240件分調べる手段でした。ここでLLMを使いました。
縦軸は1つ。
「全テーブルを同じ方法で移す」という前提を、LLMがデータで覆す。
Part 1: pg_dump には差分オプションがない
問題の発見
差分バックアップを検討したとき、最初に確認したのは pg_dump のオプションです。
pg_dump --help | grep -E 'since|where|incremental|filter'
# → 何も返らない
pg_dump は スナップショットツール です。--since も --where も存在しません。「T1以降に更新されたレコードだけ」をエクスポートする手段が、標準コマンドにはない。
代替手段:psql \COPY + WHERE句
SQL で絞り込みができる psql \COPY を使えば、差分だけ CSV に落とせます。
DELTA_DATE="2026-06-01 12:00:00" # D-1 (前日) の pg_dump 取得時刻
psql -U app_user -d app_db \
-c "\COPY (SELECT * FROM myapp.orders WHERE updated_at >= '${DELTA_DATE}') \
TO '/work/migration/delta/orders.csv' CSV HEADER"
updated_at >= T1 というフィルタが効くのは、このカラムが 新規レコードにも更新レコードにも必ず書かれる からです。INSERT 時は updated_at = created_at、UPDATE 時は updated_at が更新される設計であれば、このクエリで T1 以降の「追加 + 更新」を両方拾えます。
ただし、psql \COPY が使えるのは updated_at カラムを持つテーブルだけです。240テーブルそれぞれについて「このカラムがあるか」「実際に変化があるか」を確かめる必要がありました。
Part 2: LLMが240テーブルを3分類する
テーブルは「追記型」と「更新型」で戦略が変わる
差分移行を設計するうえで、テーブルは大きく2種類に分けられます。
| 種類 | 特徴 | 差分戦略 |
|---|---|---|
| 追記型(INSERT-only) | ログ・履歴など。一度書いたら更新しない | T1以降のレコードだけ INSERT |
| 更新型(UPDATE-type) | マスタ・ステータスなど。レコードが更新される | TRUNCATE してから差分を INSERT |
更新型に TRUNCATE が必要な理由は、差分 INSERT だけでは 「D-1に存在していた旧バージョンのレコード」と「差分の新バージョン」が同一テーブルに共存してしまう からです。主キーが重複した状態になります。TRUNCATE で一旦空にしてから、T1以降の全レコードを入れ直す方が確実です。
LLM へのインプット:テーブルリスト240件
Datastream の設定ファイル(source_config.json)には、移行対象テーブルのリストが入っていました。これをそのまま LLM に渡し、「全テーブルの分類クエリを生成してほしい」と依頼しました。
プロンプトのポイントは3つです。
-
スキーマ情報(テーブルが
myappスキーマに存在する) -
分類ロジック(
created_at/updated_atの有無と T1 以降の件数で判定) - 出力形式(action 列付きの一覧として表示)
LLM が生成した SQL は PL/pgSQL の DO ブロックで、240テーブルをループして分類結果を一時テーブルに積み上げる構造です。
LLM が生成した check_delta.sql
-- check_delta.sql
-- 全テーブルの差分分類を一括確認する
-- 使い方: psql -U app_user -d app_db -f check_delta.sql
CREATE TEMP TABLE delta_result (
table_name text,
has_created_at boolean,
has_updated_at boolean,
new_count bigint,
updated_count bigint,
action text
);
DO $do$
DECLARE
tbl text;
v_t1 timestamptz := '2026-06-01 12:00:00+09'; -- D-1 dump 取得時刻
v_has_crt boolean;
v_has_upd boolean;
v_new bigint := 0;
v_upd bigint := 0;
tables text[] := ARRAY[
'orders', 'order_items', 'users', 'products', -- 実際には 240 テーブル
-- ... (source_config.json のテーブルリストをそのまま貼付)
];
BEGIN
FOREACH tbl IN ARRAY tables LOOP
v_new := 0; v_upd := 0;
-- created_at / updated_at カラムの存在を確認
SELECT
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = 'myapp'
AND table_name = tbl
AND column_name = 'created_at'),
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = 'myapp'
AND table_name = tbl
AND column_name = 'updated_at')
INTO v_has_crt, v_has_upd;
-- T1 以降の新規件数(created_at >= T1)
IF v_has_crt THEN
EXECUTE format(
'SELECT COUNT(CASE WHEN created_at >= $1 THEN 1 END) FROM myapp.%I', tbl
) USING v_t1 INTO v_new;
END IF;
-- T1 以降の更新件数(updated_at >= T1 かつ created_at < T1)
IF v_has_upd AND v_has_crt THEN
EXECUTE format(
'SELECT COUNT(CASE WHEN updated_at >= $1 AND created_at < $1 THEN 1 END) FROM myapp.%I', tbl
) USING v_t1 INTO v_upd;
END IF;
INSERT INTO delta_result VALUES (
tbl, v_has_crt, v_has_upd, v_new, v_upd,
CASE
WHEN NOT v_has_crt AND NOT v_has_upd THEN '列なし ← 要確認'
WHEN v_new = 0 AND v_upd = 0 THEN '変化なし ← スキップ'
WHEN v_upd = 0 THEN '追記のみ ← TRUNCATEなし'
ELSE '更新あり ← TRUNCATE必要'
END
);
END LOOP;
END;
$do$;
SELECT table_name, has_created_at, has_updated_at, new_count, updated_count, action
FROM delta_result
ORDER BY action, table_name;
デバッグ:「列なし」が全件に出る
最初の実行でつまずきました。
table_name | has_created_at | has_updated_at | action
----------------+----------------+----------------+------------------
orders | f | f | 列なし ← 要確認
order_items | f | f | 列なし ← 要確認
...(全240テーブルが同じ結果)
created_at/updated_at が「存在しない」と判定されています。実際には全テーブルにカラムがあるはずです。原因はすぐ判明しました。
-- 問題のあった箇所
WHERE table_schema = 'public' -- ← LLM は public スキーマを仮定していた
-- 修正後
WHERE table_schema = 'myapp' -- ← 実際のスキーマ名
Datastream の設定ファイルにはスキーマ名が明記されていたのに、LLM に渡したプロンプトに含めていませんでした。スキーマ名を補足して再依頼し、2箇所(information_schema.columns の検索条件と FROM 句の修飾子)を修正して解決です。
-- FROM 句の修正(全テーブルに myapp. を付ける)
EXECUTE format('SELECT COUNT(...) FROM myapp.%I', tbl)
-- 修正前は FROM %I のみで、スキーマ修飾なし
LLM にコードを生成させるときにスキーマ名を明示しなかったというヒューマンエラーで、LLM 側の問題ではありません。コンテキストを適切に渡す責任は人間側にあります。
実行結果:240テーブルの分類確定
table_name | has_created_at | has_updated_at | new_count | updated_count | action
------------------+----------------+----------------+-----------+---------------+------------------
orders | t | t | 0 | 3 | 更新あり ← TRUNCATE必要
products | t | t | 12 | 0 | 追記のみ ← TRUNCATEなし
access_logs | t | f | 847 | 0 | 追記のみ ← TRUNCATEなし
code_masters | t | t | 0 | 0 | 変化なし ← スキップ
...
集計すると:
| 分類 | 件数 | 移行当日の処置 |
|---|---|---|
| 更新あり → TRUNCATE必要 | 29テーブル | pg_dump 全量 → Spanner TRUNCATE → 再投入 |
| 追記のみ → TRUNCATEなし | 29テーブル | psql COPY 差分CSV → INSERT |
| 変化なし → スキップ | 179テーブル | 何もしない |
| タイムスタンプ列なし → 個別対応 | 3テーブル | 件数確認後、別カラムで代替またはTRUNCATE |
| 合計 | 240テーブル |
240テーブルのうち179テーブル(75%)が移行当日はスキップできる。 これが今回の発見の核心です。
Part 3: D-1戦略とダウンタイム削減の実践
「変化なし75%」が戦略を変えた
元の計画では Datastream + Dataflow を全240テーブルに対してダウンタイム中に実行していました。これが合計6時間を占める最大のボトルネックです。
分類結果を見て立てた新しい計画は2フェーズ構成です。
【D-1(前日夜)— ダウンタイム外】
全240テーブルの pg_dump → 移行用 VM に転送・復元
→ T1(dump 取得時刻)を確定し、翌日の差分の基準点にする
→ サービス稼働中の作業であり、ダウンタイムにはカウントしない
【移行当日(メンテナンス中)— ダウンタイム開始】
変化なし 179テーブル ─── スキップ
追記のみ 29テーブル → psql COPY WHERE updated_at >= T1 → CSV
↓ delta VM PostgreSQL へ投入
更新あり 29テーブル → pg_dump(29テーブル全量)→ delta VM へ復元
↓ Spanner 側を事前 TRUNCATE(29テーブル)
↓
Datastream(delta VM → GCS) : 約30分 ← ダウンタイム中
↓
Dataflow(GCS → Spanner) : 約90分 ← ダウンタイム中
個別対応 3テーブル → 件数確認後に処置
Datastream・Dataflow はどちらもダウンタイム中に実施します。処理対象を「T1以降に変化があった58テーブルの差分データ」に絞ることで、全240テーブルを処理していた全量移行時より大幅に短縮されます。
delta_migration.sh — LLM が生成した7ステップスクリプト
分類結果ファイル(check_delta_result.txt)を読み込んで差分移行を自動実行するスクリプトを、こちらも LLM に生成させました。
#!/bin/bash
# 使い方:
# source ./env.delta.prd # 環境変数ロード
# sh delta_migration.sh --t1 "YYYY-MM-DD HH:MM:SS" [--step N]
#
# 実行環境:
# STEP 1-3 : オンプレミス(PostgreSQL ソースサーバ)
# STEP 4 : 移行用 VM(GCP)
# STEP 5-7 : GCP(移行用 VM または Cloud Shell)
set -euo pipefail
T1=""
TARGET_STEP="all"
# 指定ステップのみ実行(--step 省略時は全ステップ)
run_step() {
[[ "${TARGET_STEP}" == "all" || "${TARGET_STEP}" == "$1" ]]
}
while [[ $# -gt 0 ]]; do
case "$1" in
--t1) T1="$2"; shift 2 ;;
--step) TARGET_STEP="$2"; shift 2 ;;
*) echo "[ERROR] 不正な引数: $1"; exit 1 ;;
esac
done
[ -z "${T1}" ] && { echo "[ERROR] --t1 は必須です"; exit 1; }
DELTA_DATE="${T1}"
CHECK_RESULT="./check_delta_result.txt"
# ---- STEP 1: TRUNCATE必要テーブルの pg_dump(29テーブル全量)----
run_step 1 && {
TRUNCATE_TABLES=$(grep "TRUNCATE必要" "${CHECK_RESULT}" | awk '{print $1}')
TABLE_OPTS=$(echo "${TRUNCATE_TABLES}" | xargs -I{} echo "--table=myapp.{}")
pg_dump -U app_user -d app_db \
${TABLE_OPTS} \
-Fc -f "${DELTA_DIR}/truncate_tables.dump"
echo "[STEP1] pg_dump 完了: $(echo "${TRUNCATE_TABLES}" | wc -l) テーブル"
}
# ---- STEP 2: 追記のみ/更新ありテーブルの差分 CSV 出力 ----
run_step 2 && {
mkdir -p "${DELTA_DIR}/csv"
grep -v "変化なし\|列なし" "${CHECK_RESULT}" | awk '{print $1}' | \
while IFS= read -r tbl; do
psql -U app_user -d app_db \
-c "\COPY (SELECT * FROM myapp.${tbl} WHERE updated_at >= '${DELTA_DATE}') \
TO '${DELTA_DIR}/csv/${tbl}.csv' CSV HEADER"
echo "[STEP2] ${tbl}: $(wc -l < "${DELTA_DIR}/csv/${tbl}.csv") 行"
done
}
# ---- STEP 3: GCS へアップロード ----
run_step 3 && {
gsutil -m cp -r "${DELTA_DIR}/" "gs://${GCS_BUCKET}/delta/${WORK_DATE}/"
}
# ---- STEP 4: 移行 VM 上で delta PostgreSQL DB に復元 ----
run_step 4 && {
gsutil cp "gs://${GCS_BUCKET}/delta/${WORK_DATE}/truncate_tables.dump" /tmp/
pg_restore -U postgres -d delta_db --no-owner /tmp/truncate_tables.dump
# CSV(追記のみテーブル分)も delta_db に投入
for csv in /tmp/delta_csv/*.csv; do
tbl=$(basename "${csv}" .csv)
psql -U postgres -d delta_db \
-c "\COPY myapp.${tbl} FROM '${csv}' CSV HEADER"
done
}
# ---- STEP 5: Spanner の TRUNCATE必要テーブルを空にする ----
run_step 5 && {
grep "TRUNCATE必要" "${CHECK_RESULT}" | awk '{print $1}' | \
while IFS= read -r tbl; do
gcloud spanner databases execute-sql "${SPANNER_DB}" \
--instance="${SPANNER_INSTANCE}" \
--sql="DELETE FROM ${tbl} WHERE TRUE"
done
}
# ---- STEP 6: delta VM PostgreSQL → GCS への Datastream 作成 ----
run_step 6 && {
gcloud datastream streams create "delta-stream-${WORK_DATE}" \
--location="${REGION}" \
--source-name="pg-source-connection" \
--postgresql-source-config="source_config_delta.json" \
--gcs-destination-config="gcs_destination_config_delta.json"
}
# ---- STEP 7: GCS → Spanner への Dataflow 実行 ----
run_step 7 && {
gcloud dataflow jobs run "delta-dataflow-${WORK_DATE}" \
--gcs-location="${DATAFLOW_TEMPLATE}" \
--region="${REGION}" \
--parameters="inputFilePattern=gs://${GCS_BUCKET}/delta-stream/...,\
spannerProjectId=${PROJECT_ID},\
spannerInstanceId=${SPANNER_INSTANCE},\
spannerDatabaseId=${SPANNER_DB}"
}
--step N オプションでステップ単位の再実行ができるのがポイントです。途中でエラーが出ても全体を最初からやり直す必要がなく、失敗したステップから再開できます。
スクリプトのデバッグ:引数解析エラー
LLM が最初に生成したスクリプトへの最初のコマンドがこれでした。
sh delta_migration.sh 1 2>&1 | tee delta_migration.log
# [ERROR] 不正な引数: 1
ステップ番号を位置引数で渡したのに、スクリプトは --step 1 形式のみ受け付けていました。LLM にエラーメッセージを渡して修正を依頼し、--step なしの位置引数も受け付ける形に変更。また「STEP 1-2 以外では --t1 を必須にしない」というロジックも追加しました。
こういった往復が2〜3回入るのが LLM を使ったコード生成の実態です。一発で動くことは少ない。ただし エラーメッセージをそのまま渡すと即座に原因を特定して修正案を出してくる ので、デバッグ時間は手書きより短くなります。
Before / After
| Before(全量移行) | After(D-1 + 差分移行) | |
|---|---|---|
| pg_dump(240テーブル全量) | 約60分(ダウンタイム中) | D-1に完了(ダウンタイム外) |
| dump転送 + VM復元 | 約120分(ダウンタイム中) | D-1に完了(ダウンタイム外) |
| psql COPY / delta投入 / Spanner TRUNCATE等 | なし | 約40分(ダウンタイム中) |
| Datastream | 約180分(ダウンタイム中) | 約30分(ダウンタイム中) |
| Dataflow | 約180分(ダウンタイム中) | 約90分(ダウンタイム中) |
| 起動・検証・調整・疎通確認等 | 約90分 | 約90分 |
| 移行対象テーブル数 | 240テーブル | 58テーブル(179はスキップ) |
| 合計ダウンタイム | 約630分(10.5時間) | 約250分(約4時間) |
| 削減率 | — | ≈ 60% |
Part 4: LLMがどこまで生成できたか
この作業を振り返ると、LLM への依頼は大きく3段階に分けられます。
| フェーズ | 人間がやったこと | LLM がやったこと |
|---|---|---|
| 分類クエリ生成 | テーブルリスト提供、スキーマ名を(後から)補足、実行結果の確認 |
check_delta.sql(PL/pgSQL、240テーブル対応)を生成 |
| 移行スクリプト生成 | 分類結果・7ステップ構成・環境変数の命名規則を提供 |
delta_migration.sh(413行)を生成 |
| デバッグ | エラーメッセージ・実行ログを貼り付ける | 原因を特定し、修正箇所をピンポイントで提示 |
LLM が単体で苦手だったのは「実際の環境固有の情報を把握すること」です。スキーマ名・接続文字列・既存設定ファイルの構造など、コードの外にある文脈は人間が補う必要があります。逆に言えば、そこさえ丁寧に渡せば、200行・400行のコードを構造的に生成することは問題なくできます。
また、エラーが起きたときに「LLMのせいにする前にプロンプトを見直す」という姿勢が重要でした。今回の table_schema = 'public' のケースは、渡すべき情報を渡していなかった人間側の問題です。
おわりに
「全テーブルを同じ手順で移す」という初期計画に対し、LLM が出した答えはテーブルを3分類して、変化のない75%を捨てることでした。
| フェーズ | 手段 | 得られた価値 |
|---|---|---|
| 制約の発見 |
pg_dump --help の空振り |
psql \COPY という代替手段の発見 |
| 240テーブル分類 | LLM が生成した check_delta.sql
|
179テーブルのスキップが判明 |
| D-1戦略への転換 | 分類結果からの逆算 | pg_dump をダウンタイム外に移動、Datastream/Dataflow は差分データのみ処理 |
| 差分移行の自動化 | LLM が生成した delta_migration.sh
|
当日手順をスクリプト化、再実行対応 |
| 結果 | ダウンタイム 630分 → 約250分(約4時間・約60%削減) |
LLM を「コード生成ツール」として使う場合、価値が出るのは スケールが大きいとき です。今回のような「240件あるが、構造は均一」というケースは、人間が手で書くと膨大な時間がかかるのに、LLM はテーブルリストさえ渡せば数分で対応します。
pg_dump の制約という一見小さな発見が、10.5時間のダウンタイムを約4時間に縮めた起点になりました。