B システム設計/インフラ — Terraform の count 添字を for_each と moved で直す(先頭を消すと replace 4 件 → アドレスを業務名に)

2026-09-29 火曜(10/2 バックフィル) 火曜 B: システム設計/インフラ ★★★★★ Terraform / Pub/Sub moved / prevent_destroy / state 分割

概要

9/29 分のバックフィル(10/2 作成)。連番・streak は state の「次の値」を使用。
🏷️

アドレスは同一性

位置でなく業務名を鍵に。先頭を消しても他は不変。

🧭

moved で宣言

書き換えは旧→新を宣言。plan が「0 to destroy」を確認。

🔒

壊せないものは機械で守る

DLQ・topic は prevent_destroy。

📄

plan は成果物

保存した plan だけを apply。state は環境ごと。

問題

MOps 販促システムチームの coupon-platform(GCP Pub/Sub)の Terraform を後輩が count + リスト添字で作った。購読 issue を先頭から消したら、plan に 4 replace + 1 destroy が出た。

この構成には 7つの問題 が隠れています。見つけてから先へ進んでください。
  • 購読 5・環境 3・チーム 4 人 仮定。dlq の滞留は再生成不可 仮定
  • name は ForceNew として数える 仮定。実 terraform / tofu は未実行 未検証
  • versions 未固定、lock は .gitignore、backend 未設定、CI は apply -auto-approve、dev/stg/prod が 1 state

期待する回答形式: 問題点 7 点/改善後 HCL とモデル/実行例/適用パターン/迷った案と前提が変わったときの判断/扱わなかった範囲

ヒント(段階的開示)

ヒント1 — 方向性
count が resource を区別する鍵は何か。先頭を消すと残りの鍵はどう変わるか。
ヒント2 — アプローチ
  • 鍵は変わらない業務名(for_each)
  • 書き換えは moved で旧→新を宣言
  • DLQ・topic は prevent_destroy、state は環境ごと・リモート・ロック付き
  • plan は保存して同じものを apply
ヒント3 — 誘導
for_each = var.subs                 # set(string)
moved { from = ...sub[0]  to = ...sub["issue"] }
lifecycle { prevent_destroy = true }

問題点分析(7点)

#問題点何が起きるか(出所つき)改善
1count + 添字先頭削除で replace 4 + destroy 1 導出for_each(静的な名前)
2moved 無しの書き換え5 destroy + 5 create。dlq の未処理を失う 導出旧→新を moved で宣言
3保護なし-auto-approve で通るprevent_destroy
4全環境 1 state・ローカルdev の誤りが prod に及ぶ。4 人で衝突環境別 state・GCS backend(ロック)
5バージョン未固定・lock 未コミット人・日で provider が違い plan が不一致~> 固定 + lock コミット
6鍵が apply 後に決まり得る設計将来 resource 属性から鍵を作ると「鍵が不明」で失敗 未確認(記憶)鍵は静的な業務名+validation
7plan を見ずに apply見たものと動かすものが別物。drift 検知なしplan -out → 承認 → apply tfplan

先頭を消したときの replace 件数(SVG)

5 個から位置 k を消したときの replace 件数(導出) issue(先頭)count: 4 expirecount: 3 notifycount: 2 auditcount: 1 dlq(末尾)count: 0 for_each: どこを消しても replace 0(destroy は消した 1 件のみ) 前提: name は ForceNew(仮定)。実 plan 未取得。

棒の長さは replace 件数に比例。末尾だけが 1 件の destroy で済む。

Bad → Good(コード差分)

Bad
# Bad: 位置が鍵
resource "google_pubsub_subscription" "sub" {
  count = length(var.subs)              # アドレスは sub[0]..sub[4]
  name  = "coupon-${var.subs[count.index]}" # 先頭を消すと全添字の name がずれる
  topic = google_pubsub_topic.coupon.id
}
# moved なし・prevent_destroy なし・
# 1 state・provider 未固定・apply -auto-approve
Good
# Good: 業務名が鍵
resource "google_pubsub_subscription" "sub" {
  for_each = var.subs                     # set(string)。静的な名前
  name     = "coupon-${each.value}"
  topic    = google_pubsub_topic.coupon.id
  lifecycle { prevent_destroy = true }   # 消すときは意図して外す
}
moved {                                  # 移行は宣言。plan で 0 to destroy を確認
  from = google_pubsub_subscription.sub[0]
  to   = google_pubsub_subscription.sub["issue"]
}
# 環境別 state(GCS ロック)・provider 固定・plan -out → apply tfplan

適用パターン: 安定した識別子をアドレスに/宣言的リファクタ(moved)/blast radius の分割/plan・apply の分離。書籍の章対応は無し(B の日のため)。

模範解答

versions / variables / main(HCL)

terraform {
  required_version = "~> 1.9"
  required_providers {
    google = { source = "hashicorp/google", version = "~> 6.0" }   # 固定し、lock をコミット
  }
  backend "gcs" {
    bucket = "mops-tfstate"
    prefix = "coupon-platform/prod"   # 環境ごとに分ける。ロック付きリモート state
  }
}

variable "subs" {
  type    = set(string)
  default = ["issue", "expire", "notify", "audit", "dlq"]
  validation {
    condition     = alltrue([for s in var.subs : can(regex("^[a-z]+$", s))])
    error_message = "購読名は英小文字のみ。"
  }
}

CI(plan を保存して同じものを apply)

terraform plan -out=tfplan -input=false      # PR ごと。差分をコメントへ
terraform show -no-color tfplan > plan.txt   # レビュー用
terraform apply tfplan                          # 承認後。tfplan 以外は適用不可

アドレス差分モデル(planmodel.py 抜粋)

def plan_count(old: list[str], new: list[str]) -> Counter:
    c = Counter()
    for i in range(max(len(old), len(new))):
        if i >= len(new):      c["destroy"] += 1   # 末尾の添字が消える
        elif i >= len(old):    c["create"]  += 1
        elif old[i] != new[i]: c["replace"] += 1   # name が ForceNew(仮定)
    return +c

実行例

$ python3 planmodel.py   # 導出(実 terraform は未実行)
issue   を消す  count={'replace': 4, 'destroy': 1}  for_each={'destroy': 1}
expire  を消す  count={'replace': 3, 'destroy': 1}  for_each={'destroy': 1}
notify  を消す  count={'replace': 2, 'destroy': 1}  for_each={'destroy': 1}
audit   を消す  count={'replace': 1, 'destroy': 1}  for_each={'destroy': 1}
dlq     を消す  count={'destroy': 1}                for_each={'destroy': 1}
5 passed   # moved 無し 5 destroy+5 create / 有り 0 変更 を含む 5 テスト

迷った案と採らなかった理由

案採らなかった理由前提が変わったら
count のまま「末尾にだけ足す」人の規則。消す操作は必ず来る増減しないと保証できるなら可
state mv を手で履歴に残らず他人の state に反映されないmoved が使えない旧版なら
Workspaces で環境分けselect 1 つで prod と dev を取り違える環境差が小さい小規模なら可
Terragrunt依存が 1 段増える。3 環境なら重複の方が読みやすい環境が 10 超なら再検討
import で回避手作業と取りこぼしstate 消失時
OpenTofu へ切替挙動差を測っていない。ライセンス(BSL 対 MPL)は組織判断 未確認法務が BSL に懸念を示したら同じ HCL で検証

ポイント解説

  1. アドレスは同一性: 位置を鍵にすると位置が動いただけで別物になる
  2. リファクタは宣言する: moved で状態は付け替えのみ。0 を確認してから apply
  3. 壊せないものは機械で守る: prevent_destroy は apply を止める
  4. 環境は state で分ける: 1 回の apply に dev と prod を入れない
  5. plan は成果物: 見たものと動かすものを一致させる

扱わなかった範囲(止め方)

  • 実 terraform / tofu は未実行(tofu は未インストールのシムのみ)。replace 件数は ForceNew 仮定のモデルによる導出で、実 plan との一致は未検証
  • moved の挙動・for_each の未確定鍵エラー・BSL ライセンスは記憶に基づく未確認
  • 購読属性ごとの ForceNew 一覧、IAM、WIF による CI 認証、Atlantis 等の運用基盤、state 分割後の環境間参照は扱っていない

発展: state 分割後、prod から stg の topic ID を参照するには remote state と data 参照のどちらがよいか。

今日のまとめ

アドレスに位置でなく業務名を使い、書き換えは moved で宣言、壊せないものは prevent_destroy、環境は state で分け、plan は保存して同じものを apply する。

自己評価(あとで記入)