弱点補強: Terraform State 管理 — GCS Backend + State Locking × モジュール/Provider バージョン固定 × ディレクトリ環境分離(workspace 廃止)× Secret Manager 参照 × CI Plan レビュー必須化 × 日次 Drift 検知 × ドメイン別 State 分割(MOps campaign-infra Bad→Good)

2026-07-19 (Day 104) 日曜 弱点補強 ★★★★☆ Terraform 1.8 / GCS Backend Cloud Build / WIF / Drift検知

概要

🔒

GCS Backend は state locking を標準機能として持つ

ローカル terraform.tfstate の Git 管理は複数人の同時 apply による上書き事故を招く。backend "gcs" は Cloud Storage オブジェクトの世代管理を使い、追加のロック用リソースなしで排他制御が自動的に効く(S3+DynamoDB 構成との違い)。

📌

モジュール・Provider のバージョンは明示的に固定する

ref 未指定の Git ソースは常に HEAD を追跡し、上流の破壊的変更が予告なく反映される。?ref=v2.4.1version = "~> 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 に同居
期待する回答形式: 問題点の列挙(番号付き)+ 改善後 Terraform コード(backend/ディレクトリ構成)+ CI/CD 設定(Cloud Build YAML)+ 設計意図の説明

悪い運用構成 (Before)

この運用構成には 7つの構造的な問題 が隠れています。見つけてみてください。
mops-campaign-infra(現状)— ローカルstate・workspace・平文PW・巨大state
# 問題①: 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・レビューなし・drift未検知
# 問題⑤: 各自の端末から直接 apply(CIでのplanレビューなし)
$ gcloud auth login  # 個人アカウントの認証情報を使用
$ terraform apply -var-file=prod.tfvars
# → 誰が何を変更したかdiffがレビューされずに本番反映される

# 先月: 担当者がGCPコンソールからPub/SubのackDeadlineSecondsを
#       緊急対応のため手動変更
# 問題⑥: drift検知の仕組みがない
#       → tfstateとの乖離に誰も気づかないまま運用中
#       → 次のapplyで意図せず上書き・再作成される危険な状態
問題点サマリー(7点)
1state がローカル・Git管理(排他制御なし) — 複数人の同時apply事故。GCS backendで自動locking
2モジュール/Providerバージョン未固定 — 上流の破壊的変更が即反映。ref/versionで固定
3workspace で環境切替 — 誤環境apply事故。ディレクトリ分離へ
4DBパスワードを平文でtfvarsに記載 — Git・tfstateに平文残存。Secret Manager参照へ
5個人端末から直接apply — レビューなし・属人化。CI plan + 承認必須applyへ
6drift検知の仕組みがない — clickops変更に気づかない。日次drift検知へ
7全リソースが単一state(モノリシック) — 爆発半径・plan肥大化。ドメイン別分割へ

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) state 自体の保管・排他制御 — ローカルtfstateのGit管理・複数人同時applyによる競合、(2) 変更フローのガバナンス — 個人端末からの直接apply・planレビューなし・機密値の平文管理、(3) state の構造設計 — モノリシックな単一state・drift検知の仕組みがない。「state ファイルは実インフラの唯一の真実の源のスナップショットである」という前提が崩れると、次のapplyで意図しない削除・再作成が起きる。まずは「誰が」「どこで」「何に対して」applyするかを構造的に固定することがゴール。
ヒント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}/ にディレクトリ分離し、backendprefix を環境ごとに変える。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 code 2(差分あり)なら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点)

#問題点分類改善方法
1state がローカル・Git管理(排他制御なし)保管・排他制御GCS backend(Cloud Storage世代管理による自動locking)
2モジュール/Providerバージョン未固定再現性?ref=vX.Y.Z + required_providers version 固定
3workspace による環境切替(誤適用リスク)ガバナンスenvironments/{dev,staging,prod}/ ディレクトリ分離
4DBパスワードを tfvars に平文記載セキュリティSecret Manager データソース参照 + ignore_changes
5個人端末から直接apply(レビューなし)ガバナンスCloud Build PR plan + 承認必須apply(WIF CI SA)
6drift検知の仕組みがない可観測性日次 plan -detailed-exitcode + Slack通知
7全リソースが単一state(モノリシック)構造設計ドメイン別 prefix 分割 + terraform_remote_state

運用フロー図 — Bad vs Good(SVG)

Bad(変更前)— 個人端末・単一state・レビューなし terraform.tfstate(ローカル・Git管理) 問題①: 排他制御なし → 同時apply事故 問題⑦: DB/Pub/Sub/GKE/CDN が単一stateに同居 担当者の個人端末 問題②: module ref未指定・provider未固定 問題③: workspace select(誤環境選択リスク) 問題④: prod.tfvars に db_password 平文記載 terraform apply(直接実行) 問題⑤: レビューなし・個人gcloud認証・監査ログなし 本番 GCP インフラ 問題⑥: コンソールからの手動変更(clickops)を検知できない tfstateとの乖離を抱えたまま運用 → 次applyで意図せぬ上書き × 2人同時applyで片方の変更が消える寸前 × 上流moduleの破壊的変更が予告なく反映 × DBパスワードがGit履歴・tfstateに永久に残存 × レビューされていない変更が本番に届く × clickopsの乖離に気づかないまま運用 × 1リソースの変更でも全stateがplan対象(爆発半径大) Good(変更後)— GCS backend・CI・drift検知ループ GCS backend(prefix別 state 分割) 修正①⑦: 世代管理による自動locking / campaign-db・pubsub・gke・cdn を分割 修正②: module ref=v2.4.1 / provider version="~> 5.30" 固定 environments/{dev,staging,prod}/ 修正③④: ディレクトリで環境固定 / db_password は Secret Manager 参照 Cloud Build: PR trigger → terraform plan 修正⑤: WIF CI専用SA・plan diff を PR コメント投稿 人間がレビュー → 承認 → main マージ 個人のgcloud認証情報は一切使わない 承認必須 apply トリガー(Cloud Build) approvalConfig.approvalRequired = true 本番 GCP インフラ レビュー済み・承認済みの変更のみ反映される Cloud Scheduler: 毎日06:00 JST drift-check 修正⑥: plan -detailed-exitcode(0/1/2判定) exit code 2(差分あり)→ Slack通知 clickopsによる乖離を24h以内に検知 乖離があれば手動修正 → 次PRで反映 ✓ GCS backendで同時apply事故を構造的に防止 ✓ PR plan レビュー必須で属人化・無監査applyを排除 ✓ 日次drift検知でclickopsの乖離を24h以内に発見 ✓ ドメイン別state分割で爆発半径・plan時間を縮小 修正

模範解答

ディレクトリ構成:

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
Before — ローカルstate・ref未指定・workspace・平文PW
# 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
After — GCS backend + ref固定 + Secret Manager + 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パスワード平文tfvarsSecret Manager data参照 + ignore_changesGit・tfvarsからの機密値漏洩を防止
⑤ 個人端末から直接applyCloud Build PR plan + 承認必須apply(WIF)レビュー・監査ログの仕組み化、属人化排除
⑥ drift検知なし日次 plan -detailed-exitcode + Slack通知clickopsによる乖離を24h以内に検知
⑦ モノリシックstateドメイン別prefix分割 + terraform_remote_stateplan時間短縮・爆発半径縮小

ポイント解説

1GCS backend の locking は追加インフラ不要backend "gcs" を指定するだけで state locking が自動的に有効になる。DynamoDBのような別のロックテーブルを用意する必要があるS3 backendとの違いを理解しておくと、GCPを使う際の設計判断が速くなる。
2terraform plan -detailed-exitcode の3値は drift 検知の基本パターン0(変更なし)・1(エラー)・2(差分あり)を明確に使い分けることで、CI/CDパイプラインの分岐(通知するかしないか)を機械的に判定できる。この仕組みはTerraform以外のIaCツール(Pulumi等)でも同様の思想で応用できる。
3workspace は「簡易切り替え」には便利だが「本番運用のガードレール」としては弱い — 個人の検証用に一時的な環境を作る用途(feature workspace)には向くが、dev/staging/prodのような恒常的な環境分離には、コマンド履歴に依存しないディレクトリ構造の方が事故率を下げられる。
4Secret Manager 参照は「Gitからの漏洩」を防ぐが「tfstateからの漏洩」は防がない — この違いを混同すると「Secret Managerに移行したから安全」という誤った安心感を持ちやすい。state バケット自体のアクセス制御・CMEK暗号化・監査ログ(Cloud Audit Logs)も合わせて設計する。
5state 分割の粒度は「変更頻度」と「爆発半径」のトレードオフ — 分割しすぎると 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管理の改善は、それらの「個々のリソース設定を正しくする」演習の一段上の階層 —「その正しい設定を、どう安全に・チームで・継続的に適用し続けるか」という運用基盤にあたる。

段階移行の現実的なステップ: (1) 既存の単一stateを terraform state mvcampaign-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のstate管理は「① GCS backendでlockingを標準機能として得る ② module/providerのバージョン固定で予期しない破壊的変更を防ぐ ③ workspaceではなくディレクトリで環境分離し誤applyを構造的に防ぐ ④ Secret Manager参照でGit上の平文機密値を排除する(ただしtfstate自体の暗号化・アクセス制御は別途必要)⑤ CIでのplanレビュー必須化で属人化と無監査applyを防ぐ ⑥ 定期drift検知でclickopsによる乖離を早期発見する ⑦ ドメインごとのstate分割で爆発半径とレビュー負荷を抑える」という7点に集約できる。個々のリソース設定を正しく書けても、それを安全にチームで運用し続ける仕組みがなければ、いずれ「誰かの端末からの事故」が本番に届いてしまう。

次のステップ

  • 発展問題: terraform_remote_state によるドメイン間参照が増えてきた場合に、Terraform Cloud/Enterpriseを使わずOSS構成のまま「どのstateがどのstateに依存しているか」を可視化する仕組み(依存グラフの自動生成)を設計せよ
  • 参考: Terraform gcs backend ドキュメント / terraform plan -detailed-exitcode / Terraform stateのベストプラクティス / Renovate(Terraform module自動更新)/ Cloud Build 承認トリガー(approvalConfig

自己評価(あとで記入)

自分の回答

気づき・メモ