Claude Media
Claude CodeでTerraformのmovedブロックを書かせ、destroyを出さない

Claude CodeでTerraformのmovedブロックを書かせ、destroyを出さない

リソース名の変更やモジュール分割でTerraformがdestroyを計画しないよう、Claude Codeにmovedブロックを書かせ、planのdeleteが0件であることを完了条件にする手順です。

Claude CodeにTerraformのリソース名を変えさせると、何も指示しない限り、Terraformは「古いリソースを消して新しいリソースを作る」差分を計画します。リファクタのつもりの編集が、本番のデータベースを作り直すapplyに化けるわけです。防ぐ手段はmovedブロックで、完了条件は「planにdeleteが1件も出ない」に置けます。この記事では、その条件をClaudeへの依頼文・CLAUDE.md・検証コマンドに落とし込みます。

なぜリネームだけでdestroyが出るのか

Terraformはstateに「アドレス(aws_instance.aのような名前)ごとの実在リソース」を記録しています。HashiCorpの説明では、アドレスが変わった変更は既定で「既存リソースの破棄と、新しいアドレスでの作成」と解釈されます。movedブロックを書くと、新しいアドレスのplanを作る前に、Terraformがstateから旧アドレスのリソースを探して引き継ぎます。結果のplanは、最初から新しいアドレスで作られていた場合と同じ形になり、applyでも破棄されません。

つまり、名前を変えるコードの編集は、Claudeにとって数行の差分です。ところがplanの側では、リソースの作り直しになる場合があります。コードの差分が小さいほど、見落としやすい性質です。

movedブロックが使える変更は、公式の整理では次の5種類です。

対象

movedで扱えるリファクタ

  • リソースやモジュールの移動

    fromに旧アドレス、toに新アドレスを書きます。

  • リソース名の変更

    resourceブロックの名前ラベルを変え、旧名をmovedに残します。

  • 複数インスタンス化

    countやfor_eachを足すとき、既存のオブジェクトがどのキーになるかを書きます。

  • モジュール呼び出しの改名

    module "a"をmodule "b"に変えても、配下のリソースを引き継げます。

  • モジュールの分割

    肥大したモジュールを2つに分け、リソースを新しい側へ移します。

要件も先に押さえておきます。movedブロックはTerraform v1.1以降の機能で、公式ページはそれ以前の環境ではterraform state mvを使う、という趣旨で書いています。リファクタ前にterraform versionで版を確かめ、古い場合は依頼文にその旨を足します。

完了条件をplanのdelete 0件にする

Claude Codeの公式ドキュメントは、リファクタを「小さく、テストできる単位で進める」よう勧め、最後に変更後のテストを実行する流れを示しています。Terraformでテストに当たるのはplanです。コードのリファクタなら「テストが通る」が完了条件になるのと同じ形で、Terraformなら「planにdeleteが無い」を完了条件にします。

判定はTerraform開発の記事のjqと同じresource_changesで書けます。deleteを含む行が1件でもあれば失敗、0件なら成功にするには、jq -eを使います。

terraform plan -out=tfplan
terraform show -json tfplan | jq -e '
  [.resource_changes[]
   | select(.change.actions | index("delete"))]
  | length == 0'

jq -eは、最後の出力がfalseかnullのとき終了コード1を返します。deleteが0件ならtrueが出て終了コードは0、1件以上ならfalseで終了コード1です。次の最小のJSONで動きを試せます。

echo '{"resource_changes":[{"address":"aws_instance.a","change":{"actions":["delete"]}}]}' \
  | jq -e '[.resource_changes[] | select(.change.actions | index("delete"))] | length == 0'
echo "exit=$?"
false
exit=1

この例は検証用に作ったサンプルです。実際のplanではresource_changesにno-opやcreateの行も並びますが、判定に使うのはdeleteを含む行だけです。replace(作り直し)は["delete","create"]または["create","delete"]で表れ、どちらの順でもindex("delete")が拾います。

失敗したら何を直すのか

終了コード1になったときに見るのは、出てきたdeleteのアドレスと、それに対応するcreateのアドレスの組です。次のように、名前だけが違う組が並んでいれば、movedの書き忘れを疑えます。

delete	aws_instance.a
create	aws_instance.b

この対は「aを消してbを作る」と読めます。aws_instance.aからaws_instance.bへのmovedブロックを足し、planを取り直します。ここでの対応づけは名前の類似から推測しているので、Claudeには「推測でmovedを書かず、設定ファイルの差分(旧名・新名)からfromとtoを決める」ことを求めます。

CLAUDE.mdにリファクタの手順を書く

毎回依頼文に条件を書くより、リポジトリのCLAUDE.mdに置いたほうが抜けません。既存のterraform plan手順の隣に、次の節を足す形です。

## リファクタ(リソース名の変更・モジュール分割)
 
- アドレスが変わる編集では、同じ変更の中で必ず moved ブロックを書く
  - from は編集前のアドレス、to は編集後のアドレス
  - 既存の moved ブロックは削除しない。旧アドレスの upgrade 経路が壊れる
- 編集後は terraform plan -out=tfplan を実行し、
  terraform show -json tfplan | jq -e でdeleteが0件か確かめる
- deleteが残る場合は apply の話をせず、対象アドレスを報告する
- terraform state mv は使わない。移動は moved ブロックで宣言する

依頼文は、完了条件を最後に置いて書きます。

infra/network の aws_instance.web を aws_instance.app にリネームして。
moved ブロックを同じ変更に入れ、plan の delete が 0 件になるまで直して。
0 件にならないときは、delete と create のアドレスを表にして報告して。
apply はしないで。

ケース別の書き方

リソース名を変える

最小の形は、名前ラベルを変えて旧名をmovedに残すものです。

resource "aws_instance" "b" {
  count = 2
 
  # (リソース固有の設定)
}
 
moved {
  from = aws_instance.a
  to   = aws_instance.b
}

countを持つリソースでも、リソース全体のアドレスを書けば全インスタンスが対象になります。aws_instance.a[0]とaws_instance.a[1]を個別に書く必要はありません。

movedブロックは、旧アドレスがstateに無い環境では無視されます。公式ページも、旧名のリソースを持たない新規の利用者には通常どおり新しいアドレスの作成が提案されると書いています。新規環境にmovedが残っていても害はありません。

countやfor_eachを足す

単独のリソースにfor_eachを足すと、既存のオブジェクトがどのキーになるかをmovedで指定する必要があります。

resource "aws_instance" "a" {
  for_each = local.instances
 
  instance_type = each.value.instance_type
  # (その他の設定)
}
 
moved {
  from = aws_instance.a
  to   = aws_instance.a["small"]
}

この例では、既存のオブジェクトはaws_instance.a["small"]として扱われ、破棄されません。countを足すだけの場合、Terraformは既存のオブジェクトをインスタンス0に移す提案を自動で行います。ただし公式ページは、変更の意図が後から読み取れるよう、対応するmovedブロックを明示的に書くことを勧めています。Claudeへの依頼でも「自動で動くとしてもmovedは明記する」と指定しておきます。

countからfor_eachへの切り替えや、キー名の変更もmovedで書けます。

moved {
  from = aws_instance.c[0]
  to   = aws_instance.c["small"]
}
moved {
  from = aws_instance.c[1]
  to   = aws_instance.c["tiny"]
}

インデックスからキーへの対応は、Claudeの推測に任せないでください。どの番号がどの用途のインスタンスかは、コードからは読み取れないことがあります。既存のplanやstateの出力を渡し、対応表を人が決めてからmovedを書かせます。

モジュールを分割する

肥大したモジュールをxとyに分ける場合、公式の手順は次のとおりです。

手順

モジュール分割の手順

  1. 1

    新しいモジュールを書く

    xとyに、移したいリソースのブロックだけをコピーします。

  2. 2

    元のモジュールを薄くする

    元のモジュールからリソースを消し、xとyを呼ぶだけの互換用の薄い設定にします。

  3. 3

    moved ブロックで対応づける

    元のモジュールに、旧アドレスからmodule.x.やmodule.y.配下の新アドレスへのmovedを書きます。

  4. 4

    planを取る

    既存の利用者がこのバージョンのモジュールに上げたとき、書いたmovedブロックがそれぞれ働き、新しいモジュール内で作られていたものとして扱われます。

互換用の元のモジュールには、次のように書きます。

moved {
  from = aws_instance.a
  to   = module.x.aws_instance.a
}
 
moved {
  from = aws_instance.c
  to   = module.y.aws_instance.c
}

モジュール参照はmovedを書いたモジュールのインスタンスを基準に解決されます。元のモジュールがmodule.originalという子モジュールなら、module.x.aws_instance.aはmodule.original.module.x.aws_instance.aとして解決されます。また、モジュールがmovedで触れるのは、自分自身と子モジュールのオブジェクトだけです。

countやfor_eachで呼ばれたモジュール内のリソースを指すときは、インスタンスキーを明示します。module.new[2].aws_instance.exampleのような形です。

state mvをClaudeに打たせない

terraform state mvは、コマンドを打った時点でstateを書き換える操作です。movedは設定ファイルに宣言として残り、次のplanに現れるので、applyの前にレビューできます。

Terraform開発の記事の権限設定は、terraform state *をdenyに入れています。リファクタの依頼でも同じ設定が効くので、Claudeが「手早く済ませるため」にstate mvを試みても、実行前に止まります。止まったときの代替がmovedだとCLAUDE.mdに書いておけば、次の一手がmovedに定まります。

古いmovedブロックを消さない

数回のリファクタを経たモジュールには、movedブロックが積み上がります。Claudeに「不要なコードを整理して」と頼むと、これらを消してしまうことがあります。

公式ページは、movedブロックの削除を破壊的変更と位置づけています。旧アドレスを参照している設定があると、削除後のplanは既存オブジェクトの移動ではなく削除を計画するからです。HashiCorpは、過去のバージョンのmovedブロックはすべて残すことを強く勧めています。社内の非公開モジュールで、全利用者が新しい版でapplyを済ませたと確実に言える場合に限り、消しても安全な場合がある、という書き方です。

同じオブジェクトを2回改名するときは、movedを連鎖させて履歴を残します。

moved {
  from = aws_instance.a
  to   = aws_instance.b
}
 
moved {
  from = aws_instance.b
  to   = aws_instance.c
}

こう書くと、aws_instance.aにあった環境もaws_instance.bにあった環境も、どちらも最終的にaws_instance.cとして扱われます。aからcへ直接書き換えると、bの段階にいる環境が引き継げなくなります。依頼文に「既存のmovedブロックは書き換えず、連鎖で足す」と入れておくと、Claudeがこの書き換えをしません。

よくあるつまずき

  • データソースとの変換: movedでresourceブロックをdataブロックに変えることはできません。
  • リソース種別の変更: リソースの名前はmovedでいつでも変えられますが、aws_aからaws_bのように種別を変えられるかはプロバイダー次第です。プロバイダーのドキュメントで、移動できる種別の組み合わせを確かめます。
  • 新規環境でのplan: 旧アドレスを持たない環境ではmovedは無視されるので、新規の環境のplanだけを見ても、movedが正しく書けているかは分かりません。旧アドレスのstateを持つ環境(ステージングなど)でplanを取ります。
  • 別のモジュールの中身へのmoved: モジュールが書けるmovedは、自分自身と子モジュールのオブジェクトだけです。兄弟のモジュールのリソースは、その親のモジュールが宣言します。

まとめ

リファクタの依頼では、「コードが変わった」よりも「planにdeleteが出ない」を終了条件にするのが、リソースを守る分かれ目です。CLAUDE.mdには、アドレスが変わるときはmovedを同じ変更に入れること、既存のmovedは消さないこと、state mvは使わないことの3点を置けば足ります。jq -eの検査はCIにそのまま移せるので、手元でClaudeが回す検証と、レビュー時に機械が回す検証を同じコマンドに揃えられます。CIではCheckovでのスキャンと同じジョブに並べられます。

参照した一次ソース

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

  1. developer.hashicorp.com/terraform/language/modules/develop/refactoring
  2. developer.hashicorp.com/terraform/cli/commands/plan
  3. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn