概要
DMS継続的レプリケーションによる移行
in-place書き換えは実質「隠されたバックアップ→リストア」。Database Migration Serviceで明示的に新PG16インスタンスへ移行することで、リハーサル・監視・ロールバックを正面から設計できる。
周辺トポロジの棚卸し
Cloud SQLのネイティブレプリケーションはメジャーバージョンを跨げない。Primaryだけでなく、Readレプリカやバッチジョブなど「依存する周辺コンポーネント」を移行対象として洗い出す。
PgBouncer PAUSE/RESUME
接続を止めずに切り替えると進行中トランザクションが強制切断される。コネクションプーラーのPAUSE/RESUMEでカットオーバーの瞬間を安全に扱う。
切り戻し余地の保持
「コマンド成功」と「アプリが正しく動く」は別軸。旧インスタンスを一定時間読み取り専用で残し、検証スイートに落ちても即座に戻れる状態を維持する。
問題
ECサイト MOps チームが運用するクーポンサービス(coupon-service)のCloud SQL for PostgreSQLは、現在バージョン14系で稼働している。2026年11月にPostgreSQL 14のCloud SQLサポート終了(EOL)が予告され、16系への移行が必須になった。このサービスは2026-08-15の改善で、PgBouncer(transaction pooling)× 同リージョンReadレプリカ(読み書き分離)× statement_timeout × UPDATE ... WHERE ... RETURNINGによる原子的な残数消化、という構成に既に改善済みである。
新人エンジニアが書いた移行計画には7つの設計上の問題が潜んでいる。
問題点を全て洗い出し、Database Migration Service (DMS) による継続的レプリケーション移行 × 既存Readレプリカの再構成 × PgBouncer PAUSE/RESUMEによるコネクションドレイン × Argo Workflows CronWorkflowの一時サスペンド × 切り戻し(ロールバック)手順の明文化 × ポストアップグレード検証スイートを考慮したBad→Goodリファクタリングを行ってください。
制約・前提条件
- Cloud SQL for PostgreSQL、Terraform 1.8+(
googleプロバイダー 5.x) - 現行構成: Primary(PG14,
availability_type=REGIONAL)+ 同リージョンReadレプリカ(PG14)+ PgBouncer(transaction pooling、K8s Deployment) coupon-serviceはGKE Autopilot上のAPI(読み書き)と、Argo Workflowsで日次実行されるcoupon-expiry-batch(CronWorkflow、期限切れクーポンの一括失効処理でDBに直接書き込む)の2系統からアクセスされる- クーポンは会員がリアルタイムに消費するため業務時間帯の停止は許容されないが、深夜1:00-5:00は実質トラフィックがゼロに近い
- 拡張機能として
pg_trgm(クーポンコードのあいまい検索用)とuuid-ossp(クーポンID生成用)を使用している
悪い移行計画 (Before)
# 問題①: in-place書き換え。ダウンタイムがDBサイズに比例、失敗時のロールバック手段なし
# 問題②: 拡張機能(pg_trgm/uuid-ossp)や非互換構文の事前チェックなし
# 問題③: Readレプリカ(PG14)の存在が計画から欠落
# 問題④: PgBouncerのコネクションドレインなし
# 問題⑤: coupon-expiry-batch(CronWorkflow)が計画から欠落
# 問題⑥: メンテナンスウィンドウがSlack予告のみで運用計画に未組込
# 問題⑦: 「コマンド成功」のみが完了判定基準
1. 深夜メンテナンス予告をSlackに投稿する
2. gcloud sql instances patch coupon-db-primary \
--database-version=POSTGRES_16
3. コマンドが成功したら移行完了とし、Slackに完了報告を投稿する
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①:
gcloud sql instances patch --database-versionの直接実行 → Database Migration Service (DMS) のhomogeneous migration(継続的レプリケーション)で新規PG16インスタンスを別立てし、同期完了後の短時間カットオーバーに留める - 問題②: 拡張機能・非互換構文の事前チェックなし → ステージングの本番同等クローンでDMS移行を事前リハーサルし、動作を検証してから本番に着手する
- 問題③: Readレプリカ(PG14)が計画から欠落 → メジャーバージョンを跨げない制約上、DMSの移行先(新PG16)から新たにPG16のReadレプリカを構成し直す
- 問題④: PgBouncerのコネクションドレインなし → カットオーバー時はPgBouncerの
PAUSE→ DMS最終カットオーバー → 接続先切替 →RESUMEの手順を明文化する - 問題⑤:
coupon-expiry-batch(CronWorkflow)が計画から欠落 → Argo WorkflowsのCronWorkflowをsuspend: trueで一時停止してからカットオーバーする - 問題⑥: メンテナンスウィンドウがSlack予告のみ → 深夜1:00-5:00の低トラフィック帯を正式なウィンドウとして選定し、バッチサスペンド・PgBouncer PAUSEを同一時間枠に組み込む
- 問題⑦: 「コマンド成功」=完了という判定基準 → ポストアップグレード検証スイート(拡張機能smoke test・EXPLAIN比較・アプリヘルスチェック)を自動実行し、旧PG14を24時間読み取り専用で保持して切り戻し余地を残す
ヒント3 — コードの骨格
# Database Migration Service 接続プロファイル + 移行ジョブ のスケルトン
resource "google_database_migration_connection_profile" "source_pg14" {
connection_profile_id = "coupon-db-source-pg14"
postgresql { ... } # 既存Primary(PG14)への接続情報
}
resource "google_database_migration_connection_profile" "destination_pg16" {
connection_profile_id = "coupon-db-dest-pg16"
cloudsql {
cloudsql_id = google_sql_database_instance.coupon_db_pg16.name
}
}
resource "google_database_migration_migration_job" "pg14_to_pg16" {
migration_job_id = "coupon-db-pg14-to-pg16"
type = "CONTINUOUS" # 継続的レプリケーションでダウンタイム最小化
source = google_database_migration_connection_profile.source_pg14.name
destination = google_database_migration_connection_profile.destination_pg16.name
}
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | in-place書き換え・ロールバック手段なし | 移行方式層 | DMS継続的レプリケーションで新インスタンスへ移行 |
| 2 | 拡張機能・非互換構文の未検証 | 移行方式層 | ステージングでDMS移行リハーサル |
| 3 | Readレプリカが計画から欠落 | 周辺トポロジ層 | PG16側でレプリカ再構成 |
| 4 | コネクションドレインなし | 接続制御層 | PgBouncer PAUSE/RESUME |
| 5 | バッチジョブが計画から欠落 | 周辺トポロジ層 | CronWorkflow suspend |
| 6 | メンテナンスウィンドウが宣言のみ | 接続制御層 | 深夜帯を正式ウィンドウ化 |
| 7 | 「コマンド成功」=完了判定 | 検証・後戻り層 | 検証スイート+旧インスタンス保持 |
カットオーバーフロー図 — Bad vs Good の変換
模範解答
# 問題①②③⑤⑥⑦: 移行というプロセス設計がまるごと欠落
1. Slackに深夜メンテナンス予告を投稿する
2. gcloud sql instances patch coupon-db-primary \
--database-version=POSTGRES_16
3. コマンドが成功したら移行完了とし、
Slackに完了報告を投稿する
# ── 新規PG16インスタンス(DMS移行先)─────────────────
resource "google_sql_database_instance" "coupon_db_pg16" {
name = "coupon-db-primary-pg16"
database_version = "POSTGRES_16"
region = "asia-northeast1"
settings {
tier = "db-custom-4-16384"
availability_type = "REGIONAL"
backup_configuration {
enabled = true
point_in_time_recovery_enabled = true
}
}
deletion_protection = true # 修正⑦: 切り戻し余地の確保
}
# ── DMS 接続プロファイル: 移行元(PG14) ──────────────
resource "google_database_migration_connection_profile" "source_pg14" {
connection_profile_id = "coupon-db-source-pg14"
location = "asia-northeast1"
postgresql {
host = google_sql_database_instance.coupon_db_primary.private_ip_address
port = 5432
username = "dms_migration_user"
password = var.dms_migration_user_password
}
}
# ── DMS 接続プロファイル: 移行先(PG16) ──────────────
resource "google_database_migration_connection_profile" "destination_pg16" {
connection_profile_id = "coupon-db-dest-pg16"
location = "asia-northeast1"
cloudsql {
cloudsql_id = google_sql_database_instance.coupon_db_pg16.name
}
}
# ── DMS 移行ジョブ(修正①: 継続的レプリケーション)───
resource "google_database_migration_migration_job" "pg14_to_pg16" {
migration_job_id = "coupon-db-pg14-to-pg16"
location = "asia-northeast1"
type = "CONTINUOUS" # フルロード後にWALを継続同期
source = google_database_migration_connection_profile.source_pg14.id
destination = google_database_migration_connection_profile.destination_pg16.id
reverse_ssh_connectivity {
vm = var.dms_bastion_vm_name
vm_ip = var.dms_bastion_vm_ip
vm_port = 22
vpc = var.vpc_self_link
}
}
# ── PG16側のReadレプリカ(修正③: 取り残し解消)──────
# カットオーバー完了・検証OK後にapplyする
resource "google_sql_database_instance" "coupon_db_pg16_replica" {
name = "coupon-db-replica-pg16"
database_version = "POSTGRES_16"
region = "asia-northeast1"
master_instance_name = google_sql_database_instance.coupon_db_pg16.name
settings {
tier = "db-custom-4-16384"
}
}
① 事前準備(本番着手の1週間以上前)
├─ ステージングの本番同等クローンでDMS移行をリハーサル(修正②)
│ → pg_trgm / uuid-ossp の再作成、主要クエリの動作確認、EXPLAIN比較
└─ ポストアップグレード検証スイート(Cloud Run Job化)を用意(修正⑦)
→ 拡張機能smoke test / 主要クエリのEXPLAIN比較 / アプリヘルスチェック
② DMS移行ジョブ開始(本番、業務時間中でも可)
→ CONTINUOUS移行がPG14からPG16へフルロード→継続的にWAL同期
→ レプリケーション遅延をCloud Monitoringで監視し、収束するまで待機
③ メンテナンスウィンドウ突入(深夜1:00、修正⑥)
├─ Argo Workflows: coupon-expiry-batch CronWorkflowをsuspend:trueに変更(修正⑤)
├─ PgBouncer: PAUSE を発行し、進行中トランザクションの完了を待つ(修正④)
└─ DMSのレプリケーション遅延が0に収束していることを確認
④ カットオーバー実行
├─ DMS migration job を PROMOTE(移行先PG16インスタンスをスタンドアロン化)
├─ PgBouncerの接続先設定をPG16インスタンスのIPへ切替
└─ PgBouncer: RESUME を発行し、保留していたクエリを新Primaryへ流す
⑤ ポストアップグレード検証(修正⑦)
→ 検証スイート(拡張機能smoke test / EXPLAIN比較 / アプリヘルスチェック)を実行
→ 全項目パス後、Argo WorkflowsのCronWorkflowをsuspend:falseに戻す
⑥ 切り戻し余地の保持(修正⑦)
→ 旧PG14 Primaryは削除せず、読み取り専用で24時間保持
→ 検証スイートで異常を検知した場合、PgBouncerの接続先を旧PG14へ戻す
→ 24時間問題なければ、旧PG14 Primary・旧PG14 Readレプリカを退役
⑦ PG16側Readレプリカの構成(修正③)
→ ⑥の退役判断が確定した後、coupon_db_pg16_replicaをterraform applyで作成
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① in-place書き換え | DMS継続的レプリケーションで新インスタンスへ移行 | ダウンタイム最小化・ロールバック手段の確保 |
| ② 拡張機能・非互換構文未検証 | ステージングでDMS移行リハーサル | 本番着手前に互換性問題を発見 |
| ③ Readレプリカが計画から欠落 | PG16側でレプリカ再構成 | 読み書き分離構成の継続 |
| ④ コネクションドレインなし | PgBouncer PAUSE/RESUME | トランザクションの強制切断を回避 |
| ⑤ バッチジョブが計画から欠落 | CronWorkflow suspend | カットオーバー中のデータ不整合を防止 |
| ⑥ ウィンドウが宣言のみ | 深夜1-5時を正式ウィンドウ化 | 具体的な段取りとしてバッチ停止・PAUSEを内包 |
| ⑦ 「コマンド成功」=完了判定 | 検証スイート+旧インスタンス24時間保持 | アプリ挙動の裏付けと即時切り戻し余地 |
ポイント解説
UPDATE ... RETURNINGで残数を原子的に消化している設計では、切断のタイミング次第で二重計上や取りこぼしのリスクが生じ得る。実務への応用
同様の「メジャーバージョンアップグレード=実質的な移行」という捉え方は、Cloud SQLに限らずBigQueryのスキーマ移行やKafkaのブローカーバージョンアップ、あるいはライブラリのメジャーバージョンアップ(例: Pydantic v1→v2)にも応用できる。「バージョンを上げる」という言葉の軽さに反して、実際には「新しい環境を作り、検証し、トラフィックを移し、戻れる状態を保ったまま旧環境を退役させる」という一連のプロセスであるという認識を持つことが、EOL対応を場当たり的な作業から計画的なプロジェクトへ格上げする第一歩になる。
今日のまとめ
次のステップ
- 発展問題: DMSのCONTINUOUS移行中、旧PG14 Primaryと新PG16インスタンスの両方に書き込みが発生してしまうケース(例: バッチのsuspendを忘れた、手動SQLを直接旧Primaryに実行してしまった)が起きた場合、DMSの同期はどう振る舞うか(片方向レプリケーションのため新PG16側には反映されない)を調査し、この事故を検知する仕組み(新旧インスタンスの行数・チェックサム比較バッチ等)を設計せよ
- 参考: Database Migration Service(homogeneous migration) / Cloud SQL major version upgrade(in-place) / PgBouncer PAUSE/RESUME / Argo Workflows CronWorkflow suspend / pg_stat_statements