0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初めて Terraform の二重管理を踏んだ話

0
Posted at

結論

  • 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 のことです。

  1. app-a 導入時: app-a が共有シークレットを ESO 経由で使い始めたため、自分のファイルで ESO への付与を書いた。共有側のサービスはまだ ESO 使用なし。
  2. ESO の認証方式を変更: ESO の認証が KSA のプリンシパルを直接使う方式に変更。app-a のファイルの member も書き換えられた
  3. 共有側のサービスも 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つです。

  1. 対象が dev 環境であり、乖離が起きても復旧コストが小さい
  2. 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 が書いた差分ほど、差分の外にある既存コードまで読むほうがよいかもしれない
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?