4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQL→Cloud Spanner 240テーブル移行:LLMによるテーブル分類とダウンタイム約60%削減の実践

4
Posted at

はじめに

「ダウンタイム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つです。

  1. スキーマ情報(テーブルが myapp スキーマに存在する)
  2. 分類ロジックcreated_at/updated_at の有無と T1 以降の件数で判定)
  3. 出力形式(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時間に縮めた起点になりました。


参考

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?