概要
Private IP + REGIONAL でネットワーク遮断と自動フェイルオーバー
ipv4_enabled = false + private_network(Service Networking / VPC ピアリング)でパブリック公開を廃止し、authorized_networks 0.0.0.0/0 を除去する。availability_type = "REGIONAL" は別ゾーンに同期スタンバイを常時複製し、プライマリ障害時に自動フェイルオーバー(RPO≈0)。アプリは同じ接続名のまま無変更で追従できる。
IAM DB 認証でパスワードレス(tfstate 平文を廃止)
DB パスワードの平文ハードコードは tfstate に生で残り漏洩リスクになる。cloudsql.iam_authentication = on + google_sql_user(type=CLOUD_IAM_SERVICE_ACCOUNT) で GSA を DB ユーザー化し、Cloud SQL Auth Proxy の --auto-iam-authn が短命 IAM トークンをパスワード代わりに使う。静的認証情報が完全に消える。
PITR + deletion_protection + prevent_destroy でデータを二重に守る
point_in_time_recovery_enabled = true は WAL を保持し任意の秒単位へ巻き戻せる(誤 UPDATE/DELETE への保険)。deletion_protection = true(API 側の DELETE 拒否)と lifecycle { prevent_destroy }(Terraform 側の destroy/replace 拒否)は守備範囲が異なるため、両方設定して初めて本番DB消失を確実に防げる。
リードレプリカ + PgBouncer で読み取り分離と接続枯渇対策
集計・レポート読み取りをリードレプリカ(別 Auth Proxy エンドポイント)に分離し、プライマリを書き込みに専念させる。Pod スケールアウトで max_connections が枯渇する問題は、Cloud SQL Auth Proxy(TLS+IAM)→ PgBouncer(transaction モード、実接続を 20 本に集約)の 2 段構成で解消する。
問題
ECサイト MOps チームでは、キャンペーン参加者・注文トランザクションを扱う Cloud SQL for PostgreSQL 16 を本番稼働させる。以下の Terraform コードと、DB へ接続する GKE Autopilot 上のアプリ Deployment には 7つの設計上の問題 が潜んでいる。問題点を全て洗い出し、Private IP × REGIONAL HA × IAM データベース認証(パスワードレス)× 自動バックアップ/PITR × deletion_protection + prevent_destroy × リードレプリカ × Cloud SQL Auth Proxy + PgBouncer を考慮した Bad→Good リファクタリングを行え。
制約・前提条件
- Terraform 1.8+(
googleプロバイダー 5.x)、Cloud SQL for PostgreSQL 16、asia-northeast1 - DB は VPC 内部からのみアクセス(パブリックインターネットに晒さない)
- アプリは GKE Autopilot 上の Deployment(Cloud SQL Auth Proxy サイドカー経由で接続)
- 認証は IAM データベース認証(静的パスワードを廃止)
- 集計・レポート系の重い読み取りがプライマリの負荷を圧迫している
- ピーク時に Pod が急増し
FATAL: remaining connection slots are reservedで接続枯渇 - 現状は
deletion_protection = false・バックアップ未設定・パスワードを Terraform に平文ハードコード
悪いコード (Before)
resource "google_sql_database_instance" "campaign_db" {
name = "mops-campaign-db"
database_version = "POSTGRES_16"
region = "asia-northeast1"
# 問題⑤: 誤 destroy でDB消失(保護なし)
deletion_protection = false
settings {
tier = "db-custom-4-16384"
# 問題②: 単一ゾーン → ゾーン障害でDB停止
availability_type = "ZONAL"
ip_configuration {
# 問題①: パブリックIP + 全開放
ipv4_enabled = true
authorized_networks {
name = "all"
value = "0.0.0.0/0"
}
}
# 問題④: backup_configuration なし(PITR 無効)
}
# 問題⑤: lifecycle prevent_destroy なし
}
# 問題③: パスワードを平文ハードコード → tfstate に生保存
resource "google_sql_user" "app" {
name = "app"
instance = google_sql_database_instance.campaign_db.name
password = "P@ssw0rd123"
}
# 問題⑥: リードレプリカなし(読み取りもプライマリ集中)
apiVersion: apps/v1
kind: Deployment
metadata:
name: campaign-app
namespace: mops
spec:
replicas: 3
selector:
matchLabels:
app: campaign-app
template:
spec:
# serviceAccountName なし → default SA
containers:
- name: app
image: .../mops/campaign-app:latest
env:
# 問題①: パブリックIPへ直接接続
- name: DB_HOST
value: "34.84.xx.xx"
- name: DB_PORT
value: "5432"
# 問題③: パスワードを Secret から注入(静的)
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
# 問題⑦: 各 Pod が直接大量接続 → max_connections 枯渇
# 問題⑥: 読み取りもプライマリへ(RO 分離なし)
# Auth Proxy / PgBouncer サイドカーなし
ipv4_enabled = false + private_network(VPC 内部限定)へREGIONAL(同期スタンバイ + 自動フェイルオーバー)へ--auto-iam-authn でパスワードレス化backup_configuration + point_in_time_recovery_enabledmax_connections 枯渇。Auth Proxy + PgBouncer(transaction) で実接続を集約ヒント(段階的開示)
ヒント1 — 方向性
authorized_networks 全開放・静的パスワードの平文ハードコード、(2) 可用性/データ保護 — ZONAL(単一ゾーン)・バックアップ/PITR 未設定・deletion_protection = false、(3) スケーラビリティ/接続管理 — リードレプリカなし(読み取りがプライマリ集中)・コネクションプールなし(max_connections 枯渇)。Private IP は ip_configuration { ipv4_enabled = false, private_network = <VPC> } で構成し、Service Networking(VPC ピアリング)の事前確立を前提にする。
ヒント2 — アプローチ
- 問題①:
ipv4_enabled = false+private_network、authorized_networks 0.0.0.0/0を廃止。ssl_mode = "ENCRYPTED_ONLY" - 問題②:
availability_type = "REGIONAL"(同期スタンバイ + 自動フェイルオーバー、SLA 99.95%) - 問題③:
database_flags { name = "cloudsql.iam_authentication", value = "on" }+google_sql_user { type = "CLOUD_IAM_SERVICE_ACCOUNT" }、Proxy--auto-iam-authn - 問題④:
backup_configuration { enabled = true; point_in_time_recovery_enabled = true; transaction_log_retention_days = 7; backup_retention_settings { retained_backups = 14 } } - 問題⑤:
deletion_protection = true+lifecycle { prevent_destroy = true }(二重) - 問題⑥:
master_instance_name指定のリードレプリカ + アプリ読み取りを RO エンドポイントにルーティング - 問題⑦: Cloud SQL Auth Proxy(TLS+IAM)→ PgBouncer(
POOL_MODE = transaction,DEFAULT_POOL_SIZE = 20)の 2 段
ヒント3 — コードの骨格
# Cloud SQL プライマリ スケルトン
resource "google_sql_database_instance" "campaign_db" {
database_version = "POSTGRES_16"
deletion_protection = true # 修正⑤
settings {
availability_type = "REGIONAL" # 修正②
ip_configuration {
ipv4_enabled = false # 修正①
private_network = google_compute_network.vpc.id
ssl_mode = "ENCRYPTED_ONLY"
}
backup_configuration { # 修正④
enabled = true
point_in_time_recovery_enabled = true
transaction_log_retention_days = 7
backup_retention_settings { retained_backups = 14 }
}
database_flags { name = "cloudsql.iam_authentication"; value = "on" } # 修正③
}
lifecycle { prevent_destroy = true } # 修正⑤
}
resource "google_sql_database_instance" "campaign_db_replica" { # 修正⑥
master_instance_name = google_sql_database_instance.campaign_db.name
settings { availability_type = "ZONAL" }
}
# Auth Proxy サイドカー(修正③⑦)
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.11.0
args: ["--auto-iam-authn", "PROJECT:asia-northeast1:mops-campaign-db"]
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | パブリックIP + authorized_networks 0.0.0.0/0 | セキュリティ | ipv4_enabled=false + private_network(Service Networking)+ ssl_mode=ENCRYPTED_ONLY |
| 2 | availability_type = ZONAL(単一ゾーン) | 可用性 | REGIONAL(同期スタンバイ + 自動フェイルオーバー、SLA 99.95%) |
| 3 | パスワード平文ハードコード(tfstate 保存) | セキュリティ | IAM DB 認証 + CLOUD_IAM_SERVICE_ACCOUNT + Auth Proxy --auto-iam-authn |
| 4 | バックアップ・PITR 未設定 | データ保護 | backup_configuration + point_in_time_recovery_enabled + retention |
| 5 | deletion_protection=false / prevent_destroy なし | 破壊的操作 | deletion_protection=true(API)+ lifecycle prevent_destroy(Terraform) |
| 6 | リードレプリカなし(読み取り集中) | スケーラビリティ | master_instance_name 指定レプリカ + RO エンドポイント分離 |
| 7 | コネクションプールなし(max_connections 枯渇) | 接続管理 | Cloud SQL Auth Proxy + PgBouncer(transaction, pool_size=20) |
アーキテクチャ図 — Bad vs Good の変換フロー
模範解答
resource "google_sql_database_instance" "campaign_db" {
database_version = "POSTGRES_16"
deletion_protection = false # 問題⑤
settings {
availability_type = "ZONAL" # 問題②
ip_configuration {
ipv4_enabled = true # 問題①
authorized_networks {
value = "0.0.0.0/0"
}
}
# 問題④: backup_configuration なし
}
# 問題⑤: prevent_destroy なし
}
resource "google_sql_user" "app" {
name = "app"
instance = google_sql_database_instance.campaign_db.name
password = "P@ssw0rd123" # 問題③
}
# 問題⑥: リードレプリカなし
# ── VPC + Private Service Access(Private IP の前提)──────────
resource "google_compute_network" "vpc" {
name = "mops-vpc"
auto_create_subnetworks = false
}
resource "google_compute_global_address" "private_ip_range" {
name = "mops-sql-private-ip"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = google_compute_network.vpc.id
}
resource "google_service_networking_connection" "private_vpc" {
network = google_compute_network.vpc.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.private_ip_range.name]
}
# ── Cloud SQL プライマリ(HA + Private IP + IAM 認証 + PITR)──
resource "google_sql_database_instance" "campaign_db" {
name = "mops-campaign-db"
database_version = "POSTGRES_16"
region = var.region
deletion_protection = true # 修正⑤(1): API 側
depends_on = [google_service_networking_connection.private_vpc]
settings {
tier = "db-custom-4-16384"
availability_type = "REGIONAL" # 修正②: HA + 自動フェイルオーバー
disk_autoresize = true
ip_configuration { # 修正①: パブリックIP廃止
ipv4_enabled = false
private_network = google_compute_network.vpc.id
enable_private_path_for_google_cloud_services = true
ssl_mode = "ENCRYPTED_ONLY"
}
backup_configuration { # 修正④: バックアップ + PITR
enabled = true
start_time = "17:00" # UTC(JST 02:00)
point_in_time_recovery_enabled = true
transaction_log_retention_days = 7
backup_retention_settings {
retained_backups = 14
retention_unit = "COUNT"
}
}
database_flags { # 修正③: IAM DB 認証
name = "cloudsql.iam_authentication"
value = "on"
}
insights_config { query_insights_enabled = true }
}
lifecycle { prevent_destroy = true } # 修正⑤(2): Terraform 側
}
resource "google_sql_database" "campaign" {
name = "campaign"
instance = google_sql_database_instance.campaign_db.name
}
# 修正③: IAM DB ユーザー(.gserviceaccount.com を除いた名前で登録)
resource "google_sql_user" "app_iam_user" {
name = "mops-campaign-app@${var.project_id}.iam"
instance = google_sql_database_instance.campaign_db.name
type = "CLOUD_IAM_SERVICE_ACCOUNT"
}
# 修正⑥: リードレプリカ(読み取り分離)
resource "google_sql_database_instance" "campaign_db_replica" {
name = "mops-campaign-db-ro"
database_version = "POSTGRES_16"
region = var.region
master_instance_name = google_sql_database_instance.campaign_db.name
deletion_protection = true
settings {
tier = "db-custom-2-8192"
availability_type = "ZONAL" # レプリカは ZONAL で可
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.vpc.id
}
database_flags { name = "cloudsql.iam_authentication"; value = "on" }
}
lifecycle { prevent_destroy = true }
}
# アプリ SA + 最小権限(Auth Proxy 接続 + IAM DB ログイン)
resource "google_service_account" "app_sa" {
account_id = "mops-campaign-app"
}
resource "google_project_iam_member" "app_sql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.app_sa.email}"
}
resource "google_project_iam_member" "app_sql_instance_user" {
project = var.project_id
role = "roles/cloudsql.instanceUser"
member = "serviceAccount:${google_service_account.app_sa.email}"
}
resource "google_service_account_iam_member" "app_wif" { # 修正③: WIF
service_account_id = google_service_account.app_sa.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project_id}.svc.id.goog[mops/campaign-app]"
}
# ksa.yaml — KSA(WIF Annotation)
apiVersion: v1
kind: ServiceAccount
metadata:
name: campaign-app
namespace: mops
annotations:
# 修正③: KSA → GSA バインド(SA キーファイル不要)
iam.gke.io/gcp-service-account: mops-campaign-app@PROJECT_ID.iam.gserviceaccount.com
---
# deployment.yaml — アプリ + PgBouncer + Auth Proxy ×2(改善後)
apiVersion: apps/v1
kind: Deployment
metadata:
name: campaign-app
namespace: mops
spec:
replicas: 3
selector:
matchLabels:
app: campaign-app
template:
metadata:
labels:
app: campaign-app
spec:
serviceAccountName: campaign-app # 修正③: WIF / ADC キーレス
containers:
# ── アプリ本体 ──
- name: app
image: asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/campaign-app@sha256:abc123
env:
- { name: DB_HOST, value: "127.0.0.1" } # 修正⑦: localhost の PgBouncer
- { name: DB_PORT, value: "6432" }
- { name: DB_NAME, value: "campaign" }
# 修正③: IAM DB ユーザー(パスワードなし)
- { name: DB_USER, value: "mops-campaign-app@PROJECT_ID.iam" }
# 修正⑥: 読み取り専用エンドポイント(レプリカ側 Proxy)
- { name: DB_RO_HOST, value: "127.0.0.1" }
- { name: DB_RO_PORT, value: "6433" }
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
seccompProfile: { type: RuntimeDefault }
# ── 修正⑦: PgBouncer(transaction プーリング / プライマリ)──
- name: pgbouncer
image: edoburu/pgbouncer:1.22.1
ports:
- { name: pgbouncer, containerPort: 6432 }
env:
- { name: DB_HOST, value: "127.0.0.1" } # 同 Pod の Auth Proxy(5432)
- { name: DB_PORT, value: "5432" }
- { name: POOL_MODE, value: "transaction" }
- { name: MAX_CLIENT_CONN, value: "500" } # アプリ側の受け口
- { name: DEFAULT_POOL_SIZE, value: "20" } # DB 実接続は最大20に集約
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
# ── 修正③⑦: Cloud SQL Auth Proxy(プライマリ / IAM 認証)──
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.11.0
args:
- "--port=5432"
- "--auto-iam-authn" # IAM DB 認証(静的パスワード廃止)
- "--structured-logs"
- "PROJECT_ID:asia-northeast1:mops-campaign-db"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { memory: "256Mi" }
# ── 修正⑥: リードレプリカ用 Auth Proxy(読み取り分離)──
- name: cloud-sql-proxy-ro
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.11.0
args:
- "--port=6433"
- "--auto-iam-authn"
- "--structured-logs"
- "PROJECT_ID:asia-northeast1:mops-campaign-db-ro"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
---
# pdb.yaml — PodDisruptionBudget(ノードドレイン時もサービス継続)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: campaign-app-pdb
namespace: mops
spec:
minAvailable: 2
selector:
matchLabels:
app: campaign-app
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① パブリックIP + 全開放 | ipv4_enabled=false + private_network + ssl_mode=ENCRYPTED_ONLY | インターネット遮断・TLS 強制・攻撃面の除去 |
| ② ZONAL | availability_type=REGIONAL(同期スタンバイ) | 自動フェイルオーバー・RPO≈0・SLA 99.95% |
| ③ 平文パスワード | IAM DB 認証 + Auth Proxy --auto-iam-authn | 静的認証情報の廃止・短命トークン・ローテーション不要 |
| ④ バックアップ/PITR なし | backup_configuration + point_in_time_recovery_enabled | 任意時点復元・論理破壊への保険・14世代保持 |
| ⑤ 削除保護なし | deletion_protection=true + lifecycle prevent_destroy | API と Terraform の二重防御で誤 destroy を阻止 |
| ⑥ リードレプリカなし | master_instance_name 指定レプリカ + RO Proxy | 読み取り分離でプライマリを書き込みに専念 |
| ⑦ コネクションプールなし | Auth Proxy + PgBouncer(transaction, pool=20) | 実接続を集約し max_connections 枯渇を解消 |
ポイント解説
ip_configuration { ipv4_enabled = false, private_network = ... } だけでは足りず、google_compute_global_address(purpose = "VPC_PEERING")+ google_service_networking_connection で内部レンジを予約・接続してからでないとインスタンス作成が失敗する。depends_on で順序を保証する点が実務で詰まりやすい。REGIONAL はスタンバイ分のコンピュート/ストレージが増えるため課金は約2倍。SLA は 99.95%(ZONAL は SLA 対象外)。本番トランザクションDBは REGIONAL、開発/ステージングは ZONAL と使い分けるのがコスト最適。google_sql_user で CLOUD_IAM_SERVICE_ACCOUNT を登録する際、名前は GSA メールから .gserviceaccount.com を除いた mops-campaign-app@PROJECT.iam 形式にする。間違えると Proxy 経由ログインが PAM authentication failed で失敗する。deletion_protection と prevent_destroy は守備範囲が違う — deletion_protection(API 側)は DELETE 操作を拒否するが、Terraform が「設定変更で置換が必要」と判断した recreate はブロックしない。prevent_destroy(Terraform 側)は plan 段階で destroy/replace を全て止める。両方あって初めて「うっかり本番DB消失」を確実に防げる。transaction(トランザクション単位で接続を貸し出す)は接続効率が最も高いが、セッションレベルの機能(SET、prepared statement、advisory lock、LISTEN/NOTIFY)が使えない制約がある。ORM の prepared statement を使う場合はドライバ設定で対処するか、困る場合は session モードを選ぶ。pg_stat_replication の replay_lag や Cloud Monitoring の database/replication/replica_lag を監視し、ラグが許容値を超えたら重要な読み取りをプライマリにフォールバックする。実務への応用
MOps のキャンペーン参加者DBのように「書き込み(応募受付)」と「集計レポート読み取り」が混在するワークロードでは、リードレプリカへの読み取り分離がプライマリの安定に直結する。ただし応募直後の「参加済み表示」のような read-after-write が必要な箇所はプライマリを読むよう、アプリのリポジトリ層で明示的にルーティングを分ける(例: get_for_display() はプライマリ、aggregate_report() はレプリカ)。
Cloud SQL Auth Proxy の --auto-iam-authn は IAM トークンの有効期限(約1時間)ごとに自動更新されるため、アプリ側は接続文字列に一切パスワードを書かずに済む。Secret Manager でパスワードを管理するよりさらに安全で、ローテーション運用も不要。既存のパスワード認証アプリを移行する場合は、まず IAM ユーザーを追加してパスワードユーザーと併用し、切り替え完了後にパスワードユーザーを削除する段階移行が安全。
max_connections − 予約分)÷ 想定 Pod 数」で算出する。例: max_connections = 200、システム予約・レプリケーション分で 40 を引いて 160、Pod 最大 8 なら 1 Pod あたり 20 が上限の目安。Argo Workflows のバッチ処理(大量 INSERT)が同じDBを叩く場合は、バッチ専用プールを別 PgBouncer で確保し、オンライン系と枠を分けて相互干渉を防ぐ。
今日のまとめ
.gserviceaccount.com を除いた形式、削除防止は API と Terraform の両レベルで設定するのが実務のポイント。
次のステップ
- 発展問題: このプライマリDBをクロスリージョン(
asia-northeast1→asia-northeast2)のリードレプリカ +google_sql_source_representation_instanceを使った DR 構成に拡張し、リージョン障害時の昇格(promote)手順を Terraform + runbook 化する。加えて PgBouncer のオンライン系/バッチ系プール分離を 2 系統エンドポイントで実装する - 参考: Cloud SQL for PostgreSQL 高可用性(Regional)/ Cloud SQL Auth Proxy
--auto-iam-authn/ Private Service Access(VPC ピアリング)/ PITR とバックアップ保持 / PgBouncer transaction pooling ドキュメント