結論
-
for_eachから要素を1つ外す = そのインスタンスのdestroyそのもの。ファイルごと消した場合と結果は同じです。 - 同じ付与を2箇所で宣言すると危険。「自分のぶんだけ消したつもり」が、他サービスの権限を消す可能性あり。
- 実体を残して state から外す手段は3つあります。
- ただし
removedブロックはfor_eachのインスタンスには単独で使えません。
Error: Resource instance keys not allowedになります。 - 残るのは
moved→removedの2 PR か、terraform state rmです。
- ただし
- dev 環境だったため、今回は
state rmを選びました。
事象
あるアプリケーション(以下 app-a)を基盤から撤去することになりました。Terraform 側の残骸を消す PR を書いていたときの話です。対象は dev 環境です。
1つのサービスを撤去しようとしたら、無関係な6サービスが止まりかけました。
前提の構成
GKE 上の各サービスは Secret Manager のシークレットを使っています。
シークレットは External Secrets Operator(ESO)経由で取り込みます。
Secret Manager を読みに行くのは、各サービスではなく ESO です。
ESO が読めるかは、シークレットごとの IAM バインディングで決まります。
付与するロールは roles/secretmanager.secretAccessor です。
シークレット本体とバインディングは、Terraform で管理しています。
撤去と関係するシークレットは次の2種類です。
-
app-a専用:app-a-token、app-a-cookie-secret - 6サービスで共有:
shared-client-secret
shared-client-secret への ESO の読み取り権限が消えたとします。
すると、それを使う6サービスがまとめて同期に失敗します。
撤去で消す対象は app-a 専用のシークレットと、その IAM バインディングです。
どちらも app_a.secret_manager.tf にまとまっていました。
そこで、このファイルを丸ごと削除して terraform plan を打ちました。
Plan: 0 to add, 0 to change, 6 to destroy.
# google_secret_manager_secret.app_a_token will be destroyed
# google_secret_manager_secret.app_a_cookie_secret will be destroyed
# google_secret_manager_secret_iam_member.external_secrets_app_a["app-a-cookie-secret"] will be destroyed
# google_secret_manager_secret_iam_member.external_secrets_app_a["shared-client-secret"] will be destroyed
...
6サービスが使う共有シークレットへの、ESO の読み取り権限を消す処理が含まれていました。
気づきにくい理由
該当のバインディングはコード上に2箇所で宣言されていました。
# shared.tf — 共有シークレットの正しい置き場
resource "google_secret_manager_secret_iam_member" "external_secrets_shared" {
secret_id = google_secret_manager_secret.shared_client_secret.id
role = "roles/secretmanager.secretAccessor"
member = local.eso_principal
}
# app_a.secret_manager.tf — 撤去対象のファイル
resource "google_secret_manager_secret_iam_member" "external_secrets_app_a" {
for_each = {
"app-a-cookie-secret" = google_secret_manager_secret.app_a_cookie_secret.id
"shared-client-secret" = google_secret_manager_secret.shared_client_secret.id # ← これ
}
secret_id = each.value
role = "roles/secretmanager.secretAccessor"
member = local.eso_principal
}
二重管理です。
宣言は2つですが、GCP 側の実体は1個だけです。
実体は「あるプリンシパルに、あるロールを付与した」という事実だからです。
しかも、各サービスは ESO という1つの部品を経由してシークレットを読みます。
そのため、IAM のメンバーは全サービス共通の1プリンシパルです。
app-a のファイルの宣言は「app-a のための付与」のつもりでした。
しかし、シークレット・ロール・メンバーが共有側の宣言と完全に一致します。
つまり、同じ実体を指していました。
実際に確認するとこうなります。
$ gcloud secrets get-iam-policy shared-client-secret
bindings:
- members:
- principal://iam.googleapis.com/projects/.../subject/ns/external-secrets/sa/external-secrets
- serviceAccount:other-app@example.iam.gserviceaccount.com
role: roles/secretmanager.secretAccessor
ESO のプリンシパルは1度しか現れません。宣言は2箇所でも、実体は1個です。
片方を destroy すれば、もう片方の宣言が指す実体も消えてしまう状態になりました。
このままマージすると、shared-client-secret を読む6サービスが同期に失敗します。
教訓:
_iam_member系は「付与という事実」をリソースにしています。こうした API では、複数の宣言が同じ実体を指すことが可能です。コードの宣言数と、実体の数は一致しません。
AWS での再現
これは GCP に限った話ではありません。AWS でも同じ構成を再現しました。
使うのは aws_iam_role_policy_attachment です。
ロールへのポリシーのアタッチも「付与という事実」のリソースです。1つのロールに、同じポリシーを2つの宣言から付けます。
# shared.tf
resource "aws_iam_role_policy_attachment" "shared" {
role = aws_iam_role.eso.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess"
}
# app_a.tf
resource "aws_iam_role_policy_attachment" "app_a" {
for_each = {
"app-a" = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
"shared" = "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess" # ← 重複
}
role = aws_iam_role.eso.name
policy_arn = each.value
}
apply は成功し、その後の plan も No changes です。
しかし、実体のアタッチは S3 と SSM の2つだけです。
plan の refresh を見ると、2つの宣言の id が完全に一致しています。
aws_iam_role_policy_attachment.shared: Refreshing state... [id=tf-dup-demo-eso/arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess]
aws_iam_role_policy_attachment.app_a["shared"]: Refreshing state... [id=tf-dup-demo-eso/arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess]
宣言は2つあっても、実体は1つです。
Terraform の出力だけで確かめられます。
for_each から shared を外して apply → SSM のアタッチは消えました。
次の plan → 共有側の shared が will be created になります。
共有側は気づかないうちに権限を失っていたわけです。
このあと紹介する removed のエラーも AWS で同じでした。moved → removed と state rm の結果も同じです。
二重管理になった経緯
git の履歴をたどると、誰かが重複を書いたわけではありませんでした。最初は別々の付与でした。構成変更が積み重なるうちに、同じ実体になっていました。
図の KSA は Kubernetes の ServiceAccount のことです。
-
app-a導入時:app-aが共有シークレットを ESO 経由で使い始めたため、自分のファイルで ESO への付与を書いた。共有側のサービスはまだ ESO 使用なし。 - ESO の認証方式を変更: ESO の認証が KSA のプリンシパルを直接使う方式に変更。
app-aのファイルのmemberも書き換えられた - 共有側のサービスも ESO を使い始める: 共有側のファイルに ESO への付与が追加された。ここで、シークレット・ロール・メンバーの組が
app-a側と完全に一致した
③の PR は、それ自体は正しい変更です。共有側のサービスが ESO で読むなら、共有側で付与を持つのは自然です。
②と③は同じ日にマージされた別々の PR でした。それぞれの差分だけを見ても、重複には気づけません。
しかも、③の apply はエラーになりません。
付与済みのメンバーを _iam_member で再度付与しても、apply は成功します。IAM ポリシーが変わらないからです。
Terraform の state には2つのリソースが並びました。どちらも同じ実体を管理する形のまま、1か月後の撤去まで残りました。
AI と書いた PR とレビュー
振り返ると、もう一つ思い当たることがあります。①と③の PR はどちらも AI と一緒に書かれていました。コミットに AI の Co-Authored-By が残っています。
③の PR のコードコメントには「app-a 側と同じ書き方」とありました。既存の付与をお手本として参照していたわけです。すぐ隣に同じ付与があることは、コード上に見えていました。
それでも、「同じ書き方」が「同じ実体」になることには、誰も触れていません。AI の差分はもっともらしく整って見えます。差分が正しく見えると、周りの既存コードまで読む動機が薄れます。
AI に書かせた差分を、差分の中だけでレビューしていた。これも原因の一つかもしれません。
教訓: 二重管理は、1つの PR で生まれるとは限らない。共有の読み手(今回は ESO)への付与は、あとから別の宣言と衝突することもある。読み手側の構成変更(認証方式の変更・利用者の追加)がきっかけになる。
2段階 apply でも防げない理由
最初に考えたのは、2段階 apply でした。ファイルごと消す前に、共有シークレットの行だけ for_each から先に外す案です。
この案でも destroy は避けられません。
for_each から要素を外すのは、そのインスタンスの destroy そのものです。plan の出力もファイルごと消した場合と同じ will be destroyed になります。
# 2段階 apply の1段階目
Plan: 0 to add, 0 to change, 1 to destroy.
# google_secret_manager_secret_iam_member.external_secrets_app_a["shared-client-secret"] will be destroyed
plan には destroy の理由も出ます。AWS 版で再現したときの出力です。
# aws_iam_role_policy_attachment.app_a["shared"] will be destroyed
# (because key ["shared"] is not in for_each map)
「キーが for_each に無いから destroy する」と、Terraform 自身が書いています。
図にするとこうなります。
Terraform から見ると、インスタンス B は「state にあるのにコードに無い」ものです。そのため destroy 対象になります。destroy の API 呼び出しは、インスタンス A が指していたのと同じ実体を消します。Terraform は、A と B が同じ実体を指していることを知りません。
この問題の芯は「宣言から外す」と「リソースを消す」が区別されていないことです。撤去でやりたいのは、実体を残したまま state から外すことです。destroy したいわけではありません。
3つの解決策の比較
2段階 apply は前の節で見たとおり成立しません。そのため、比較から外します。
| 案 | 手段 |
for_each の1キーに使えるか |
PR / apply | CI の apply に閉じるか | 注意点 |
|---|---|---|---|---|---|
| A |
removed ブロック |
使えない(Resource instance keys not allowed) |
— | — | リソース全体を外すと、全キーが state から外れる |
| B |
moved で退避 → removed
|
使える | 2 PR / 2 apply | 閉じる | 1つ目と2つ目の間は、二重管理の状態が残る |
| C | terraform state rm |
使える | 1 PR | 閉じない(手で state を書き換える) | 打ち間違えると state と実体が乖離する。マージまでの順序を守る必要がある |
案A: removed ブロックで destroy を抑止する
Terraform 1.7 以降の removed ブロックはまさにこのための機能です。
removed {
from = google_secret_manager_secret_iam_member.external_secrets_app_a
lifecycle {
destroy = false
}
}
しかし for_each のインスタンスに対しては使えません。
$ terraform plan
Error: Resource instance keys not allowed
Resource address must be a resource (e.g. "test_instance.foo"),
not a resource instance (e.g. "test_instance.foo[1]").
removed.from はリソースアドレスしか受け付けず、インスタンスキー付きのアドレス(foo.bar["key"])は渡せません(Terraform 1.13.5(GCP)と 1.13.4(AWS)で実測済み)。
リソース全体を removed すると、for_each の全要素が state から外れます。そのため「1キーだけ外す」はこの機能では表現できません。
removed は「リソース単位の撤去」のための機能です。「インスタンス単位の撤去」は想定されていません。
案B: moved で退避してから removed する
moved ブロックは removed と違い、インスタンスキーを取れます。
1つ目の PR では3つの変更をまとめて入れます。
-
for_eachからshared-client-secretのキーを外す - 退避先のリソースを単独で宣言する
-
movedで、インスタンスを退避先へ移す
moved の to はコードに宣言されている必要があります。退避先の resource が無いと、plan が失敗します。
# 1つ目の PR: for_each からキーを外し、単独リソースへ退避
resource "google_secret_manager_secret_iam_member" "external_secrets_app_a" {
for_each = {
"app-a-cookie-secret" = google_secret_manager_secret.app_a_cookie_secret.id
}
secret_id = each.value
role = "roles/secretmanager.secretAccessor"
member = local.eso_principal
}
resource "google_secret_manager_secret_iam_member" "shared_client_secret_eso" {
secret_id = google_secret_manager_secret.shared_client_secret.id
role = "roles/secretmanager.secretAccessor"
member = local.eso_principal
}
moved {
from = google_secret_manager_secret_iam_member.external_secrets_app_a["shared-client-secret"]
to = google_secret_manager_secret_iam_member.shared_client_secret_eso
}
2つ目の PR では退避先の resource を消し、removed を置きます。
# 2つ目の PR: 退避先ごと state から外す
removed {
from = google_secret_manager_secret_iam_member.shared_client_secret_eso
lifecycle { destroy = false }
}
メリットは変更がすべて CI の terraform apply に閉じることです。誰も手でコマンドを打ちません。
デメリットは 2 PR / 2 apply が必要なことです。1つ目がマージされて apply が通るまで、2つ目は出せません。
moved を経由するこの回避策は次の記事で先に紹介されています。
案C: terraform state rm を打ってからファイルを削除する
$ terraform state rm 'google_secret_manager_secret_iam_member.external_secrets_app_a["shared-client-secret"]'
state からインスタンスだけが外れます。実体は消えず、plan の destroy は6件から5件に減ります。
メリットは 1 PR で済むことです。
デメリットは CI の apply の外から state を直接書き換えることです。対象キーや環境を打ち間違えれば、state と実体が乖離します。
今回の判断
案C(state rm)を採りました。理由は2つです。
- 対象が dev 環境であり、乖離が起きても復旧コストが小さい
- 2 PR に割ると、1つ目と2つ目の間に「二重管理に戻った状態」が挟まる
決め手は2つ目の理由でした。1つ目の PR を apply しても、app-a の撤去は終わりません。退避先と shared.tf の2つの宣言が同じ実体を指したまま残ります。その間に誰かが退避先を消せば、同じ事故がもう一度起きます。
state rm を安全に打つための順序
案C にも落とし穴があります。state rm の後に、別の infra PR がマージされたとします。すると apply が走り、消したはずのインスタンスが state に戻ってきます。コードには、まだ宣言が残っているためです。そこで、作業の順序を固定しました。
レビュー通過 →
state rm→ 即マージ
この順序を PR 本文の冒頭に「マージ前に必要な手作業(重要)」として書きました。実行するコマンドもそのまま貼りました。レビュアーが手順を知らずにマージする、という状態を作らないためです。
再発防止
事故の前からあったルール
この二重管理を禁じるルールはすでにありました。基盤の Terraform 規約に、次の趣旨の項目があります。
共有シークレットへの付与は、1箇所に集約する。後発のサービス側は、固有のシークレットだけを付与する。
このルールが入ったのは、③の PR の5日後です。別のサービスのレビューで、同じ重複が指摘されたことがきっかけでした。
ところが、ルールを足したときに既存のコードは見直されませんでした。app-a 側の重複はそのまま残りました。
ルールで防げるのは、これから書くコードの重複です。すでにある重複はルールでは見つかりません。
撤去の手順の追記
今回の対応のあと、撤去の手順も規約に追記しました。中身は上で比較した3つの選択肢と制約です。
state rm は、既定の手段にはしないと明記しました。1 PR で済みますが、CI の apply に閉じる原則を外れるからです。今回は dev 環境だったので選べた手段です。
plan の JSON から重複を探す
ルールの弱点は既存の重複を見つけられないことでした。そこで、機械で探す方法を考えました。
terraform show -json を使うと、plan を JSON で取り出せます。その中の planned_values には、変更分だけでなく構成全体が入ります。つまり、どの PR の CI でも、既存の重複まで含めて検査できます。
検査は jq で書けます。
# dup-iam.jq: 同じ付与を指すシークレットの _iam_member の組を出す
[ .planned_values.root_module
| .. | objects | .resources? // empty | .[]
| select(.type == "google_secret_manager_secret_iam_member")
| .values as $v
| { address,
key: [ .type,
($v.secret_id // "" | split("/") | last),
$v.role, $v.member, ($v.condition | tostring) ] }
]
| group_by(.key)
| map(select(length > 1) | map(.address))
CI では次のように使います。重複が1組でもあれば、終了コード1で落ちます。
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
jq -f dup-iam.jq plan.json | jq -e 'length == 0'
今回の構成を模したダミーの plan で試すと、二重管理の2つが組で出ます。
[
[
"google_secret_manager_secret_iam_member.external_secrets_shared",
"google_secret_manager_secret_iam_member.external_secrets_app_a[\"shared-client-secret\"]"
]
]
ただし、限界もあります。
- apply まで値が決まらない属性は、plan に値が出ません。
- 対象はシークレットへの付与だけです。バケットなどは対象を表す属性名が違います。広げるなら、型ごとに
selectとキーを足します。 - state をまたぐ重複は見えません。検査できるのは、1つの state の中だけです。
もう一つ、撤去時の安全網も考えられます。plan に _iam_member の destroy が含まれたら、CI で警告を出す方法です。警告が出たら、消す前に get-iam-policy で実体を確かめる流れです。
まとめ
-
for_eachから要素を1つ外す = そのインスタンスの destroy。2段階 apply でも destroy は避けられない -
_iam_memberのような「付与という事実」のリソースは、宣言が2つでも実体は1つ。消す前にget-iam-policyで実体を数える -
removedブロックはインスタンスキーを取れない(movedは取れる) - 選べるのは「2 PR で CI に閉じる」か「1 PR で手を入れる」かの二択。環境と、CI 原則をどれだけ重く見るかで決まる
- 二重管理を禁じるルールは、既存の重複までは見つけてくれない。plan の JSON で機械的に探す
- AI が書いた差分ほど、差分の外にある既存コードまで読むほうがよいかもしれない