Claude Media
ClaudeにTerraformのドリフトを検出させ、取り込むか戻すか分類する

ClaudeにTerraformのドリフトを検出させ、取り込むか戻すか分類する

plan -refresh-onlyとplanのJSONからドリフトを拾い、取り込む・戻す・無視に分ける作業をClaude Codeに任せ、stateを書き換える操作はdenyで止める手順です。

Terraformのドリフトとは、実インフラの設定がstateや構成ファイルの記述とずれた状態のことです。Claude Codeに任せられるのは「どこがずれているかを拾い、取り込む・戻す・無視のどれが妥当そうかを根拠つきで並べる」ところまでです。どれにするかの決定と、stateへの書き込みは人の手元に残します。コードを書く側の検証ループはClaude CodeでTerraformのコードを書く手順にあるので、ここでは「書いていないのに差分が出た」ときの扱いに絞ります。

ドリフトの差は2か所に出る

ドリフトを分類する前に、差がどこに現れるかを分けておきます。手作業の変更でstateと実インフラがずれる、というのがHashiCorpの説明です。放置してplanやapplyを実行すると、Terraformは手作業の変更を元に戻そうとします。

比べる相手が違う差は2種類あります。

見分け方

plan出力に混ざる2種類の差

  • 外で起きた変更

    前回のapply以降に、Terraformの外で実インフラへ加わった変更です。planの冒頭に「Objects have changed outside of Terraform」という節で出ます。

  • これから起こす変更

    構成ファイルに合わせるために、applyが実インフラへ加える変更です。「Terraform will perform the following actions」の節に出ます。

前者がドリフトそのもので、後者はその結果として何が起きるかです。ドリフトの扱いを決めるには両方を並べて読む必要があります。1つ目の節だけを見ると「外の変更を受け入れるか」が決められず、2つ目だけを見ると、その差分が誰かの手作業の結果なのか、コードの編集の結果なのかが区別できません。

検出に使う2つのplan

検出に使うのは、通常のplanとリフレッシュのみのplanです。

リフレッシュのみのモード(-refresh-only)は、stateと出力値を実インフラの変更に合わせることだけを目的にします。インシデント対応のように、通常の流れの外で意図的に変更したあと、Terraformの記録をそれに揃える場面向けです。このフラグはv0.15.4以降で使えます。

終了コードを分岐に使うなら-detailed-exitcodeです。0が差分なし、1がエラー、2が差分ありを意味します。

terraform plan -refresh-only -detailed-exitcode -out=drift.tfplan
terraform plan -detailed-exitcode -out=change.tfplan

終了コード2が返っても、原因がドリフトなのか、手元で書き換えたコードなのかは分かりません。だから検出は、コードを編集していないブランチ(多くはmain)で走らせます。コードが動いていなければ、2の原因はほぼ外の変更に絞れます。

-outで保存したplanファイルには機密値が入ることがあります。取り扱いはTerraformのコードを書く手順の「stateとplanファイルの取り扱い」と同じで、コミットもチャット貼り付けもしません。

JSONで外の変更と結果を並べる

画面のplanを読ませる代わりに、保存したplanをJSONにすると、2種類の差が別々の配列に入ります。terraform show -jsonの出力にはresource_driftとresource_changesがあります。resource_driftは、直近のstateと保存済みの前回のstateを比べて検出した変更で、resource_changesと同じ構造です。

リソースごとに「外で何が起きたか」と「applyが何をするか」を1行にまとめるjqです。

terraform show -json change.tfplan | jq -r '
  (.resource_changes | map({key: .address, value: .change.actions})
    | from_entries) as $plan
  | .resource_drift[]?
  | [.address, (.change.actions | join("+")),
     ($plan[.address] // ["no-op"] | join("+"))]
  | @tsv'

このjqの動きは、同じ構造の最小のJSONで確かめられます。外の変更が4件あるサンプルに流すと、次の4行が出ます。列は「リソースのアドレス / 外で起きた変更 / applyがする操作」の順です。

aws_security_group.sg_ssh	update	no-op
aws_instance.example	update	update
aws_s3_bucket.logs	delete	create
aws_lb.web	update	no-op

この3列目が、分類の入口になります。

3列の組み合わせから候補を絞る

外の変更と、applyがする操作の組み合わせで、考えられる結論が変わります。表は候補を絞るためのもので、結論ではありません。

外の変更applyがする操作読み方最初の候補
updateapplyがする操作update読み方構成は古い値のまま。applyすると手作業が消える最初の候補取り込むか戻すかを人が決める
updateapplyがする操作no-op読み方構成がすでに新しい値と同じか、その属性がignore_changesで除外済み最初の候補stateの更新だけで済むかを確認する
deleteapplyがする操作create読み方外で消されたものを、applyが作り直す最初の候補戻す(再作成)か、管理から外す

1行目が最も判断を要します。たとえばセキュリティグループのルールが手作業で追加された場合、そのルールが障害対応の一時措置なら戻す、恒久的に必要なら取り込む、のどちらもあり得ます。この意図はplanの出力のどこにも書かれていません。Claudeに推測させず、変更履歴やチケットを人が確認する対象です。

2行目は、ドリフトがあっても構成との差はない状態です。この場合もstateは古いままなので、記録を揃えるなら人がリフレッシュのみのapplyを実行します。リフレッシュのみのapplyの扱いは、後の「取り込む」の手順で説明します。

分類ルールをCLAUDE.mdに置く

判断基準と報告の形をCLAUDE.mdに書いておくと、毎回の依頼が短くなり、報告の形も揃います。

# ドリフト対応ルール
 
## 検出
- main ブランチで `terraform plan -refresh-only -detailed-exitcode -out=drift.tfplan` を実行する
- 続けて `terraform plan -detailed-exitcode -out=change.tfplan` を実行する
- 終了コードが 2 のとき、change.tfplan の `terraform show -json` から resource_drift と resource_changes を抜き出す
 
## 報告の形
リソースごとに次の4項目を表にする。
1. アドレス
2. 外で変わった属性(before → after)
3. apply したときの操作(resource_changes の actions)
4. 取り込む / 戻す / 無視 のうち最初の候補と、その根拠
 
## してはいけないこと
- 意図の推測を事実として書かない。「手作業と思われる」と書き、確認先を挙げる
- `terraform apply`、`terraform refresh`、`terraform import`、
  `terraform state` は実行しない
- `lifecycle { ignore_changes }` を、頼まれていないのに追加しない

4項目目に「根拠」を入れているのは、候補だけが出てくると、読む側が確かめる手がかりを失うためです。「意図は推測しない」の一文は、1行目の組み合わせ(update / update)で効きます。

3つの結論ごとの手順

取り込む: コードを直してから差分ゼロを確かめる

外の変更を恒久的なものとして残すなら、構成ファイルを実インフラの新しい値に合わせます。Claudeに頼むのは、この編集と、直したあとの再planです。planのresource_changesがno-opになれば、構成とstateと実インフラが揃っています。

stateを先に更新する場合の入口はterraform apply -refresh-onlyです。承認すると、実インフラは変えずにstateだけを書き換えます。確認プロンプトに「There is no undo」と出る操作なので、人が実行します。stateだけを実インフラに合わせて構成を直さずにいると、次のplanまたはapplyで手作業の変更が元に戻ります。

Terraformの外で作られていて、まだstateにないリソースを取り込む場合は、import blockを使います。import blockを構成に書いてapplyするとインポートされ、resource blockはplanの-generate-config-outで生成することもできます。生成機能は実験的な扱いです。同じ実オブジェクトを複数のアドレスにインポートすると想定外の動作になることがある、という注意書きがあるため、取り込み先のアドレスは人が確かめます。

戻す: Claudeはplanの提示まで

外の変更が意図外なら、構成どおりに戻すのが答えです。実行するのはapplyで、Claudeの権限の外に置きます。Claudeの仕事は、applyで何が変わるかをresource_changesで示し、削除と作り直しを含むかどうかを先に言うことです。含む場合は特にそうです。

無視: 別のプロセスが管理する属性だけに使う

外の変更が、別のプロセスによる想定内のものなら、lifecycleのignore_changesが使えます。オートスケーリングが台数を動かす場合が典型です。

resource "aws_autoscaling_group" "web" {
  # ...
  lifecycle {
    ignore_changes = [desired_capacity]
  }
}

ignore_changesに挙げた属性は、作成時のplanでは考慮され、更新時のplanでは無視されます。HashiCorpはこれを、Terraformと別のプロセスが1つのオブジェクトの管理を分け合う、まれな場合の仕組みとして説明しています。「差分が出てうるさいから消す」ために使うと、本物のドリフトが見えなくなります。

管理から外す: state rmよりremovedブロック

オブジェクトを残したままTerraformの管理だけをやめたい場合、terraform state rmは実オブジェクトとの結び付きを消しますが、実オブジェクトは壊しません。ただしその後のplanには、「忘れられた」リソースを作る操作が出ます。HashiCorpはstate rmの代わりにremovedブロックを使うことを案内しています。通常のplanとapplyの流れでレビューできるためです。

stateを書く操作はdenyで止める

ドリフト対応では、stateを書き換える操作が複数出てきます。許可と拒否は、次のように分けます。

{
  "permissions": {
    "allow": [
      "Bash(terraform plan *)",
      "Bash(terraform show *)",
      "Bash(jq *)"
    ],
    "deny": [
      "Bash(terraform apply *)",
      "Bash(terraform refresh *)",
      "Bash(terraform import *)",
      "Bash(terraform state *)",
      "Bash(terraform destroy *)"
    ]
  }
}

見落としやすいのはterraform refreshです。このコマンドはterraform apply -refresh-only -auto-approveの別名で、確認なしにstateを書き換えます。現在は非推奨で、-refresh-onlyを付けたapplyが勧められています。terraform apply *をdenyに入れても、terraform refreshは別のコマンド名として評価されるため、個別に書いておく必要があります。

terraform importも同様です。allowに無ければ確認プロンプトは出ますが、denyに入れると確認すら出さずに止まります。

*はサブコマンドの後ろに置きます。denyが優先される点、そして-chdir付きや絶対パスの呼び出しがルールをすり抜ける点は、Terraformのコードを書く手順の権限設定と同じです。書き込み範囲そのものを絞るなら、allowWrite/denyWriteの出番です。

依頼文の例

設定とCLAUDE.mdを置いたあとの依頼は短くて済みます。

main でドリフトを検出して、結果を表で報告して。
各行に「取り込む / 戻す / 無視」の最初の候補と根拠を付けて。
意図が分からないものは「要確認」にして、確認先を書いて。
state を変える操作は実行しないで。

Claudeの報告を受けたあと、人が決めた内容を指示し直します。「取り込む」と決めたリソースのコード修正、「無視」と決めた属性のignore_changes追加、といった編集の依頼です。

つまずきやすい点

落とし穴

stateだけ更新して構成を直さない

リフレッシュのみのapplyでstateを実インフラに合わせても、構成ファイルは古い値のままです。次の通常のplanで、同じ差分が今度は「これから起こす変更」として現れます。取り込むと決めたなら、コードの修正までを1セットにします。

先にignore_changesを足してしまう

ドリフトの報告を見ずにignore_changesを足すと、その属性の外の変更は以後のplanで無視されます。本来は戻すべき手作業まで見えなくなるので、CLAUDE.mdで禁止しておくのが安全です。

同じオブジェクトを2回インポートする

取り込みの途中で、別のアドレスに同じ実オブジェクトを紐付けてしまうことがあります。先にallow済みのterraform show -jsonで、stateにある既存のアドレスを確認する運用にします。terraform state listも読み取りだけですが、terraform stateはdenyに入れているため、Claudeには使わせません。

検出ジョブが「差分あり」だらけになる

コードの変更がマージ前の状態で検出を走らせると、終了コード2の原因が混ざります。検出は、コードが固定されたブランチだけで走らせます。定期実行を自前で組まなくても、HCP Terraformにはドリフト検出の機能があります。

まとめ

ドリフト対応でClaudeに任せて効果が大きいのは、resource_driftとresource_changesを突き合わせて、判断の材料を表にするところです。取り込むか戻すかの決定と、stateを書く操作は、意図を知っている人に残します。この境界をterraform refreshとterraform importまで含めて権限に書いておけば、検出を頻繁に回しても、記録が静かに書き換わることはありません。セキュリティ面のスキャンはCheckovの指摘を直させる手順、MCP経由の操作はTerraform MCPサーバーの使い方で扱っています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. developer.hashicorp.com/terraform/cli/commands/plan
  2. developer.hashicorp.com/terraform/tutorials/state/resource-drift
  3. developer.hashicorp.com/terraform/internals/json-format
  4. developer.hashicorp.com/terraform/cli/commands/refresh
  5. developer.hashicorp.com/terraform/cli/commands/state/rm
  6. developer.hashicorp.com/terraform/language/meta-arguments/lifecycle
  7. developer.hashicorp.com/terraform/language/import
  8. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn