概要
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点)
| # | 問題点 | 何が起きるか(出所つき) | 改善 |
|---|---|---|---|
| 1 | count + 添字 | 先頭削除で replace 4 + destroy 1 導出 | for_each(静的な名前) |
| 2 | moved 無しの書き換え | 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 |
| 7 | plan を見ずに apply | 見たものと動かすものが別物。drift 検知なし | plan -out → 承認 → apply tfplan |
先頭を消したときの replace 件数(SVG)
棒の長さは 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-approveGood
# 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 で検証 |
ポイント解説
- アドレスは同一性: 位置を鍵にすると位置が動いただけで別物になる
- リファクタは宣言する: moved で状態は付け替えのみ。0 を確認してから apply
- 壊せないものは機械で守る: prevent_destroy は apply を止める
- 環境は state で分ける: 1 回の apply に dev と prod を入れない
- 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 する。