概要
GCS Backend は state locking を標準機能として持つ
ローカル terraform.tfstate の Git 管理は複数人の同時 apply による上書き事故を招く。backend "gcs" は Cloud Storage オブジェクトの世代管理を使い、追加のロック用リソースなしで排他制御が自動的に効く(S3+DynamoDB 構成との違い)。
モジュール・Provider のバージョンは明示的に固定する
ref 未指定の Git ソースは常に HEAD を追跡し、上流の破壊的変更が予告なく反映される。?ref=v2.4.1 と version = "~> 5.30" で固定し、Renovate 経由で計画的に更新する運用に変える。
workspace ではなくディレクトリで環境を分離する
terraform workspace select prod は選択状態がターミナルに依存し、tfvars の指定漏れと組み合わさると誤環境への apply 事故が起きやすい。environments/{dev,staging,prod}/ というディレクトリ構造そのものを環境選択にする。
CI での plan レビュー必須化 + 日次 drift 検知
個人端末からの直接 apply はレビューも監査ログも属人化する。PR ごとの plan 自動実行 + 承認必須の apply トリガーに加え、terraform plan -detailed-exitcode の日次実行で clickops による state と実インフラの乖離を早期検知する。
問題
ECサイト MOps チームの Terraform リポジトリ mops-campaign-infra は、Cloud SQL・Pub/Sub・GKE・CDN など複数の GCP リソースを管理しているが、運用フローに7つの構造的な問題が潜んでいる。先週、担当者Aさんの端末から terraform apply を実行した直後、担当者Bさんも別の変更を同じタイミングで apply しようとして、2人の変更のうち片方が丸ごと消し飛ぶ寸前だった。さらに調査すると、先月コンソールから手動変更された Pub/Sub の ackDeadlineSeconds が tfstate に反映されないまま放置されており、次の apply で意図せず上書き・再作成される危険な状態であることが判明した。
制約・前提条件
- Terraform 1.8+、
googleプロバイダー 5.x - リポジトリ
mops-campaign-infraは Cloud SQL・Pub/Sub・GKE・CDN のモジュールを含む - 環境は dev / staging / prod の3つ、CI/CD は Cloud Build(GitHub連携)、認証は WIF
- Slack 通知は Incoming Webhook を利用する想定
- 現状:
terraform.tfstateはリポジトリ直下でローカル管理・Git 管理下、環境切替はterraform workspace select、DB パスワードはprod.tfvarsに平文記載、apply は各自の端末から直接実行、drift 検知なし、全リソースが1つの state に同居
悪い運用構成 (Before)
# 問題①: backend未設定 → terraform.tfstate をリポジトリ直下でGit管理
# (複数人が同時 apply すると片方の変更が丸ごと上書きされる)
# 問題③: 環境切替は workspace
# $ terraform workspace select prod && terraform apply -var-file=prod.tfvars
# → tfvarsの指定漏れ・workspace選び間違いで誤環境に適用するリスク
module "campaign_db" {
# 問題②: ref(バージョン)未指定 → module側の破壊的変更が即座に反映
source = "git::https://github.com/mops-team/tf-modules.git//cloud-sql"
}
resource "google_sql_user" "app" {
name = "app"
instance = module.campaign_db.instance_name
# 問題④: prod.tfvars に db_password = "P@ssw0rd123" と平文記載しGit管理
password = var.db_password
}
module "campaign_pubsub" { source = "./modules/pubsub" }
module "campaign_gke" { source = "./modules/gke" }
module "campaign_cdn" { source = "./modules/cdn" }
# 問題⑦: DB・Pub/Sub・GKE・CDN が全部同じ state に同居(モノリシックstate)
# → CDNのTTLを1つ変えるだけのplanでもDB全体が評価対象になる
# 問題⑤: 各自の端末から直接 apply(CIでのplanレビューなし)
$ gcloud auth login # 個人アカウントの認証情報を使用
$ terraform apply -var-file=prod.tfvars
# → 誰が何を変更したかdiffがレビューされずに本番反映される
# 先月: 担当者がGCPコンソールからPub/SubのackDeadlineSecondsを
# 緊急対応のため手動変更
# 問題⑥: drift検知の仕組みがない
# → tfstateとの乖離に誰も気づかないまま運用中
# → 次のapplyで意図せず上書き・再作成される危険な状態
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①:
backend "gcs"に変更。GCS backendはCloud Storageオブジェクトの世代管理でstate lockingが標準機能として自動的に効く(DynamoDBのような別リソース不要) - 問題②:
source = "...tf-modules.git//cloud-sql?ref=v2.4.1"でバージョン固定。required_providers { google = { version = "~> 5.30" } }も固定しRenovateで計画的に更新 - 問題③:
environments/{dev,staging,prod}/にディレクトリ分離し、backendのprefixを環境ごとに変える。workspaceは誤適用の温床になるため廃止 - 問題④:
data "google_secret_manager_secret_version"で取得し、lifecycle { ignore_changes }でローテーション後の誤差分検知を抑制 - 問題⑤: Cloud Build でPRごとに
planを実行しPRコメント投稿、mainマージ後に承認必須のapplyトリガー。認証はWIF経由のCI専用SA - 問題⑥: Cloud Scheduler で毎日
terraform plan -detailed-exitcodeを実行し、exit code2(差分あり)ならSlack通知 - 問題⑦: リソースドメインごとに
prefixを分けてstate分割。他ドメインの値はterraform_remote_stateで参照専用に利用
ヒント3 — コードの骨格
# backend.tf スケルトン
terraform {
backend "gcs" {
bucket = "mops-terraform-state-prod"
prefix = "campaign-db" # 修正⑦: ドメインごとに state 分割
}
required_version = ">= 1.8.0, < 2.0.0"
required_providers {
google = { source = "hashicorp/google", version = "~> 5.30" } # 修正②
}
}
module "campaign_db" {
source = "git::https://github.com/mops-team/tf-modules.git//cloud-sql?ref=v2.4.1" # 修正②
}
data "google_secret_manager_secret_version" "db_password" { # 修正④
secret = "campaign-db-app-password"
}
# cloudbuild-drift-check.yaml(Cloud Scheduler 毎日起動、修正⑥)
# terraform plan -detailed-exitcode → exit 2 なら Slack通知
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | state がローカル・Git管理(排他制御なし) | 保管・排他制御 | GCS backend(Cloud Storage世代管理による自動locking) |
| 2 | モジュール/Providerバージョン未固定 | 再現性 | ?ref=vX.Y.Z + required_providers version 固定 |
| 3 | workspace による環境切替(誤適用リスク) | ガバナンス | environments/{dev,staging,prod}/ ディレクトリ分離 |
| 4 | DBパスワードを tfvars に平文記載 | セキュリティ | Secret Manager データソース参照 + ignore_changes |
| 5 | 個人端末から直接apply(レビューなし) | ガバナンス | Cloud Build PR plan + 承認必須apply(WIF CI SA) |
| 6 | drift検知の仕組みがない | 可観測性 | 日次 plan -detailed-exitcode + Slack通知 |
| 7 | 全リソースが単一state(モノリシック) | 構造設計 | ドメイン別 prefix 分割 + terraform_remote_state |
運用フロー図 — Bad vs Good(SVG)
模範解答
ディレクトリ構成:
mops-campaign-infra/
├── environments/
│ ├── dev/
│ │ ├── backend.tf # prefix = "dev"
│ │ ├── main.tf
│ │ └── dev.tfvars # 機密値は含めない
│ ├── staging/
│ │ └── ... # prefix = "staging"
│ └── prod/
│ ├── backend.tf # prefix = "campaign-db" 等ドメイン別
│ ├── main.tf
│ └── prod.tfvars # 機密値は含めない
├── modules/
│ ├── cloud-sql/
│ ├── pubsub/
│ ├── gke/
│ └── cdn/
└── cloudbuild/
├── plan.yaml
├── apply.yaml
└── drift-check.yaml
# backend未設定 → terraform.tfstate をGit管理 # 問題①
module "campaign_db" {
source = "git::...tf-modules.git//cloud-sql" # 問題②: ref未指定
}
resource "google_sql_user" "app" {
name = "app"
instance = module.campaign_db.instance_name
password = var.db_password # 問題④: 平文tfvars
}
module "campaign_pubsub" { source = "./modules/pubsub" }
module "campaign_gke" { source = "./modules/gke" }
module "campaign_cdn" { source = "./modules/cdn" }
# 問題⑦: 全て同一state
# environments/prod/backend.tf
# 修正①⑦: GCS remote backend + ドメインごとの prefix 分割
terraform {
backend "gcs" {
bucket = "mops-terraform-state-prod"
prefix = "campaign-db" # Pub/Subは"campaign-pubsub"、GKEは"campaign-gke"
}
# 修正②: Terraform本体・プロバイダーのバージョン固定
required_version = ">= 1.8.0, < 2.0.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.30"
}
}
}
# environments/prod/main.tf
# 修正②: モジュールを ref でバージョン固定(Renovateで計画的に更新)
module "campaign_db" {
source = "git::https://github.com/mops-team/tf-modules.git//cloud-sql?ref=v2.4.1"
project_id = var.project_id
region = var.region
# 修正③: environments/prod/ 配下であることが環境の唯一の真実の源
}
# 修正④: Secret Managerからアプリ DB パスワードを取得(tfvars平文を廃止)
data "google_secret_manager_secret_version" "db_password" {
secret = "campaign-db-app-password"
}
resource "google_sql_user" "app" {
name = "app"
instance = module.campaign_db.instance_name
password = data.google_secret_manager_secret_version.db_password.secret_data
lifecycle {
# パスワードローテーション後の誤差分検知を抑制
ignore_changes = [password]
}
}
# 修正⑦: 他ドメイン(Pub/Sub)の出力値は terraform_remote_state で参照専用に取得
data "terraform_remote_state" "pubsub" {
backend = "gcs"
config = {
bucket = "mops-terraform-state-prod"
prefix = "campaign-pubsub"
}
}
# cloudbuild/plan.yaml — 修正⑤: PR作成・更新時に自動トリガー
steps:
- id: terraform-init
name: hashicorp/terraform:1.8
args: ["init", "-input=false"]
dir: environments/prod
- id: terraform-plan
name: hashicorp/terraform:1.8
args:
- "plan"
- "-detailed-exitcode"
- "-no-color"
- "-var-file=prod.tfvars"
- "-out=tfplan"
dir: environments/prod
- id: post-plan-comment
name: gcr.io/cloud-builders/gcloud
entrypoint: bash
args:
- -c
- |
terraform show -no-color environments/prod/tfplan > /workspace/plan.txt
# 修正⑤: plan diff を GitHub PR コメントとして投稿(レビュー必須化)
./scripts/post-plan-to-pr-comment.sh /workspace/plan.txt
serviceAccount: "projects/${PROJECT_ID}/serviceAccounts/tf-ci@${PROJECT_ID}.iam.gserviceaccount.com"
# 修正⑤: WIF経由のCI専用SA。個人のgcloud認証情報は一切使わない
options:
logging: CLOUD_LOGGING_ONLY
# cloudbuild/apply.yaml — 修正⑤: mainマージ後、手動承認トリガーでのみ実行
steps:
- id: terraform-init
name: hashicorp/terraform:1.8
args: ["init", "-input=false"]
dir: environments/prod
- id: terraform-apply
name: hashicorp/terraform:1.8
args: ["apply", "-input=false", "tfplan"]
dir: environments/prod
serviceAccount: "projects/${PROJECT_ID}/serviceAccounts/tf-ci@${PROJECT_ID}.iam.gserviceaccount.com"
# トリガー側で approvalConfig.approvalRequired = true を設定し、
# plan.yaml 成功 + 人間の承認を経て初めて起動する
# cloudbuild/drift-check.yaml — 修正⑥: Cloud Scheduler が毎日06:00 JSTに起動
steps:
- id: terraform-init
name: hashicorp/terraform:1.8
args: ["init", "-input=false"]
dir: environments/prod
- id: detect-drift
name: hashicorp/terraform:1.8
entrypoint: bash
args:
- -c
- |
terraform plan -detailed-exitcode -no-color -var-file=prod.tfvars -out=/dev/null
echo "exit_code=$?" > /workspace/exit_code.txt
dir: environments/prod
- id: notify-if-drift
name: gcr.io/cloud-builders/curl
entrypoint: bash
args:
- -c
- |
EXIT_CODE=$(grep -o '[0-9]*' /workspace/exit_code.txt)
if [ "$EXIT_CODE" = "2" ]; then
curl -X POST "$$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"text":"⚠️ Terraform drift detected: environments/prod/campaign-db"}'
fi
serviceAccount: "projects/${PROJECT_ID}/serviceAccounts/tf-ci@${PROJECT_ID}.iam.gserviceaccount.com"
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① ローカルstate・Git管理 | backend "gcs"(世代管理による自動locking) | 複数人同時applyの上書き事故を構造的に防止 |
| ② バージョン未固定 | module ref固定 + required_providers version固定 | 上流の破壊的変更を計画的な更新に変換 |
| ③ workspaceで環境切替 | environments/{dev,staging,prod}/ ディレクトリ分離 | 誤環境applyの構造的な発生源を排除 |
| ④ DBパスワード平文tfvars | Secret Manager data参照 + ignore_changes | Git・tfvarsからの機密値漏洩を防止 |
| ⑤ 個人端末から直接apply | Cloud Build PR plan + 承認必須apply(WIF) | レビュー・監査ログの仕組み化、属人化排除 |
| ⑥ drift検知なし | 日次 plan -detailed-exitcode + Slack通知 | clickopsによる乖離を24h以内に検知 |
| ⑦ モノリシックstate | ドメイン別prefix分割 + terraform_remote_state | plan時間短縮・爆発半径縮小 |
ポイント解説
backend "gcs" を指定するだけで state locking が自動的に有効になる。DynamoDBのような別のロックテーブルを用意する必要があるS3 backendとの違いを理解しておくと、GCPを使う際の設計判断が速くなる。terraform plan -detailed-exitcode の3値は drift 検知の基本パターン — 0(変更なし)・1(エラー)・2(差分あり)を明確に使い分けることで、CI/CDパイプラインの分岐(通知するかしないか)を機械的に判定できる。この仕組みはTerraform以外のIaCツール(Pulumi等)でも同様の思想で応用できる。terraform_remote_state の依存関係が複雑になり、逆に追いにくくなる。「書き込み系(DB・Pub/Sub)」と「配信系(GKE・CDN)」のように、変更の性質でドメインを分けるのが実務的な落とし所になりやすい。実務への応用
MOpsチームの mops-campaign-infra リポジトリは、Cloud SQL(7/14分)・VPC Service Controls(6/27分)・Cloud Armor/CDN(7/7分)など、これまでの日次演習で扱ってきたリソース群を実際に管理するTerraformコードそのものにあたる。今回のstate管理の改善は、それらの「個々のリソース設定を正しくする」演習の一段上の階層 —「その正しい設定を、どう安全に・チームで・継続的に適用し続けるか」という運用基盤にあたる。
terraform state mv で campaign-db / campaign-pubsub / campaign-gke / campaign-cdn の4つのGCS prefixに分割 → (2) terraform workspace の使用箇所を洗い出し environments/ ディレクトリへ移行 → (3) prod.tfvars から機密値をgrepで洗い出しSecret Managerへ移行 → (4) Cloud BuildトリガーをGitHub連携で設定し、まず plan.yaml のみ先行導入してレビュー文化を根付かせてから apply.yaml の承認必須化に進む。
drift検知の「定期実行 + 異常時Slack通知」パターンは、既存のArgo Workflows CronWorkflowのonExitハンドラ設計(7/8分)と同じ思想。IaCの健全性監視も、アプリケーションの可観測性と同じ「定常状態からの逸脱を定量的に検知する」設計原則で統一的に扱える。
今日のまとめ
次のステップ
- 発展問題:
terraform_remote_stateによるドメイン間参照が増えてきた場合に、Terraform Cloud/Enterpriseを使わずOSS構成のまま「どのstateがどのstateに依存しているか」を可視化する仕組み(依存グラフの自動生成)を設計せよ - 参考: Terraform
gcsbackend ドキュメント /terraform plan -detailed-exitcode/ Terraform stateのベストプラクティス / Renovate(Terraform module自動更新)/ Cloud Build 承認トリガー(approvalConfig)