Claude CodeでAWSの未使用リソースを洗い出す — describe系だけ許可し削除はTerraformへ
未アタッチのEBS、関連付けのないElastic IP、ターゲットのないロードバランサーをAWS CLIの読み取りコマンドだけで洗い出し、削除はTerraformの差分に回す権限設計を示します。
AWSの未使用リソースは、Claude Codeに「describe系のコマンドだけ」を許可して一覧にさせ、削除は.tfファイルの差分とplanとして出させると、人が承認する場所を1か所に絞れます。CLIの権限ルールで削除コマンドを止め、実際の削除はTerraformのレビューを通す。この分担が本記事の設計です。
対象は3種類です。どのアタッチ先にもつながっていないEBSボリューム、どこにも関連付けられていないElastic IP、ターゲットを持たないロードバランサー。請求額の内訳を分解する作業はAWS Billing MCPサーバーの記事が扱っています。ここではリソース単位の棚卸しを扱います。
権限は「読む」と「消す」を別の道に分ける
削除を2つの道のどちらで行うか
CLIで直接削除
aws ec2 delete-volumeなどをClaudeに実行させる。Terraformの状態とずれ、次のapplyでリソースが復活したり、差分が混乱したりします。
IaCの差分として削除
CLIは読むだけ。削除対象は.tfから外した差分とplan結果で示し、applyは人が実行します。
Claude Codeの権限ルールは、評価順がdeny、ask、allowの順で、最初に一致したものが結果を決めます。具体的なルールが優先されるわけではありません。権限設定のページには、Bash(aws *)のような広いdenyルールは、より狭いallowルール(例: Bash(aws s3 ls))に一致する呼び出しまで止める、と書かれています。したがって「awsは全部deny、describeだけallow」という書き方は成立しません。
成立するのは逆の組み立てです。allowで読み取りコマンドを1つずつ列挙し、削除系は個別にdenyします。
settings.jsonに書くルール
プロジェクトの.claude/settings.jsonに次を置きます。
{
"permissions": {
"allow": [
"Bash(aws ec2 describe-volumes *)",
"Bash(aws ec2 describe-addresses *)",
"Bash(aws elbv2 describe-load-balancers *)",
"Bash(aws elbv2 describe-target-groups *)",
"Bash(aws elbv2 describe-target-health *)",
"Bash(terraform state list *)",
"Bash(terraform state show *)",
"Bash(terraform plan *)"
],
"ask": [
"Bash(terraform apply *)"
],
"deny": [
"Bash(aws ec2 delete-volume *)",
"Bash(aws ec2 release-address *)",
"Bash(aws elbv2 delete-load-balancer *)",
"Bash(aws elbv2 delete-target-group *)",
"Bash(terraform destroy *)"
]
}
}末尾の*の前に半角スペースを入れている点が肝です。権限設定のページの説明では、Bash(ls *)はlsにも一致し、lsofには一致しません。スペースを抜くとdescribe-volumesの後ろに別の文字が続く別コマンドにまで一致します。
*はサブコマンドの後ろに置きます。Bash(aws * describe-volumes)のようにサブコマンドの前へ置くと、どのサブコマンドにも一致する意味になるため、起動時に警告が出る書き方です。
実行時に引っかかる3つの癖
権限設定のページの記述から、この構成で踏みやすい点を3つ拾います。
- パイプした先のコマンドも、それぞれ許可が必要です。区切りは
&&、||、;、|などで、ルールは各サブコマンドに個別に照合されます。aws ... | jq ...のjqは、組み込みの読み取り専用コマンド一覧(ls、cat、grep、wcなど)に載っていません。整形はjqに任せず、AWS CLIの--queryで済ませる設計にすると、許可ルールが増えません。 - 環境変数の接頭辞を付けると、許可ルールに一致しなくなります。
AWS_PROFILE=audit aws ec2 describe-volumesのように既知の安全な変数以外を代入すると、allowルールはその先に一致しません。プロファイルは--profile auditのフラグで渡します。 - denyは別の呼び方を止めません。
Bash(rm *)のような例と同じく、/usr/bin/awsの絶対パス呼び出しやsh -cの中身は、denyルールでは止まりません。削除を確実に防ぎたいなら、Claudeに使わせる認証情報側を読み取り専用のIAMポリシーに絞ります。権限ルールはClaudeの振る舞いを整える層、IAMは最終的な防壁と役割を分けると安全です。
サンドボックスでファイルやネットワークの範囲を絞る方法は、allowWrite/denyWriteの記事にあります。
3種類の洗い出しコマンド
出力は--queryで必要な列だけに絞ります。AWS CLIの全体像と--queryなどの共通オプションは、AWS CLIユーザーガイドの冒頭にあるとおりv2が現行系です。リファレンスの版は2.38.6でした。
未アタッチのEBSボリューム
describe-volumesのstatusフィルターは、creating | available | in-use | deleting | deleted | errorを取ります。availableはどのインスタンスにも付いていない状態です。describe-volumesの出力例にも、availableのボリュームをAttachmentsが空の配列で示す出力があります。
aws ec2 describe-volumes --profile audit \
--filters Name=status,Values=available \
--query 'Volumes[].{Id:VolumeId,GiB:Size,Type:VolumeType,AZ:AvailabilityZone,Created:CreateTime,Tags:Tags}' \
--output jsonCreatedとTagsを一緒に出す理由は、判断材料になるからです。作成日が古く、タグにOwnerも付いていないボリュームは候補として強く、直近のスナップショット復元用などは残す理由がある場合があります。
どこにも関連付けのないElastic IP
describe-addressesの出力には、関連付けの状態を示すAssociationId、InstanceId、NetworkInterfaceIdが入ります。3つともない行を、関連付けのないアドレスとして抜き出します。
aws ec2 describe-addresses --profile audit \
--query 'Addresses[?AssociationId==`null` && InstanceId==`null` && NetworkInterfaceId==`null`].{Ip:PublicIp,Alloc:AllocationId,Tags:Tags}' \
--output json3つすべてを条件にするのは、describe-addressesの出力例には、InstanceIdはあるがAssociationIdが出ない古い形式(Domain: standard)の行があるためです。1つの項目だけで判定すると、使われているアドレスを候補に混ぜる恐れがあります。
ターゲットのないロードバランサー
ロードバランサーは1コマンドでは判定できません。3段階に分けます。
ロードバランサーを使われ方で切り分ける
- 1
ロードバランサーを一覧する
aws elbv2 describe-load-balancersで、ARNと名前、種別、状態を取ります。 - 2
ターゲットグループの紐づきを見る
aws elbv2 describe-target-groupsの各行はLoadBalancerArnsを持ちます。describe-target-groupsの説明では、ターゲットグループが使えるロードバランサーは1つだけです。この配列が空なら、どのロードバランサーにも属さないターゲットグループです。 - 3
ターゲットの健全性を見る
aws elbv2 describe-target-health --target-group-arn <ARN>で、グループごとの登録ターゲットを確認します。応答が空なら、ターゲットは登録されていません。
describe-target-healthのunused状態は、理由コードを読み分けます。Target.NotRegisteredは未登録、Target.NotInUseは「ターゲットグループがどのロードバランサーにも使われていない、またはターゲットが有効でないアベイラビリティゾーンにある」、Target.InvalidStateは停止済みか終了済みのインスタンスを指します。「ターゲットがない」と「ターゲットがあるが全部停止している」は別の状況で、後者はunusedの理由で区別できます。
ここまでのコマンドが返すのは構成の事実です。リクエストが実際に来ているかは、これらのコマンドの出力にありません。リクエスト数はCloudWatchのメトリクスで人が確かめてから、削除の判断に進めます。
Claudeに渡す指示
CLAUDE.mdには、手順を実行順で書いておきます。
## 未使用AWSリソースの棚卸し
- AWS CLIは`--profile audit`を付け、describe系のコマンドだけを使う
- 出力は`--query`で絞る。jqやsedに渡さない
- 結果は`inventory/unused-YYYYMMDD.md`に表で書く
- 列: 種別 / ID / リージョン / 作成日 / タグ / Terraform管理か
- 削除コマンドは実行しない。削除は`.tf`の変更とplan結果で示す最後の列「Terraform管理か」は、Terraformの状態と突き合わせて埋めます。terraform state listが返すのはIDではなくリソースのアドレス(aws_ebs_volume.dataのような名前)なので、aws_ebs_volume、aws_eip、aws_lbのアドレスを絞り、terraform state show <アドレス>で各リソースのidを確認します。CLIの一覧にあるIDがどれかのidと一致すればTerraform管理、どれにも一致しなければ管理外です。
洗い出し結果の表は、たとえば次の形になります。IDは説明用のダミーです。
| 種別 | ID | リージョン | 作成日 | タグ | Terraform管理か |
|---|---|---|---|---|---|
| EBS | IDvol-0example0001 | リージョンap-northeast-1 | 作成日2025-03-02 | タグOwner=data-team | Terraform管理か管理(aws_ebs_volume.batch_tmp) |
| EIP | IDeipalloc-0example02 | リージョンap-northeast-1 | 作成日なし | タグなし | Terraform管理か管理外 |
| ターゲットグループ | IDtg-example-old | リージョンap-northeast-1 | 作成日なし | タグOwner=web | Terraform管理か管理外 |
作成日が出ないElastic IPのように、列を埋められない項目は空欄でなく「なし」と書かせると、後で読み返すときに取りこぼしと区別できます。
削除はTerraformの差分として出させる
Terraform管理のリソースだけ、Claudeに.tfを直させます。grepでボリュームIDやタグ名から該当するaws_ebs_volumeやaws_eip、aws_lbのブロックを探し、削除する差分を作ってplanを回す流れです。plan出力にはdestroyされるリソースだけが並ぶはずなので、想定外の項目が混ざっていたら、そこで止めます。
planを読む観点やCheckovでの事前スキャンは、Terraformを書く手順とCheckovの記事にまとめています。applyは上のsettings.jsonでaskに置いてあるため、毎回確認ダイアログが出ます。terraform destroyはdenyで、そもそも実行できません。
管理外のリソースは別の扱いです。
| 状態 | 次の一手 | Claudeにさせること |
|---|---|---|
| Terraform管理で未使用 | 次の一手.tfから削除してplanをレビュー | Claudeにさせること差分とplanの作成 |
| 管理外で持ち主が分かる | 次の一手持ち主に確認してから削除かimport | Claudeにさせること一覧に持ち主候補のタグを併記 |
| 管理外で持ち主不明 | 次の一手タグ付けして様子を見る | Claudeにさせること候補の一覧化まで |
管理外のリソースを消すコマンドは、この設計ではClaudeに持たせません。持ち主不明のものを機械的に消すことが、棚卸しでいちばん起きやすい事故だからです。
複数アカウント・リージョンを扱うとき
describe系はリージョンごとの結果を返します。リージョンを替えるときは--regionフラグを付けます。
アカウントを替えるときは、--profileでプロファイルを切り替えます。--profileも--regionと同じくサブコマンドの後ろに置くので、Bash(aws ec2 describe-volumes *)の許可ルールはそのまま使えます。アカウントごとに回す指示は、CLAUDE.mdに次のように書いておきます。
- 対象アカウントは audit-prod / audit-stg の2つ。プロファイルごとに順に実行する
- 結果は1つの表にまとめ、先頭の列にプロファイル名を入れる
- 環境変数でプロファイルを切り替えない(許可ルールに一致しなくなる)アカウントとリージョンの組み合わせが増えるほど、同じコマンドの繰り返しが増えます。それでも許可ルールは書き足さず、フラグの値だけを替えて回します。
この付け方も、許可ルールの*が受け止めます。ただしBash(aws ec2 describe-volumes *)は、フラグがサブコマンドの後ろにあるときの話です。aws --region ap-northeast-1 ec2 describe-volumesのようにグローバルフラグをサブコマンドより前に置くと、先頭のトークンが変わるため一致しません。常にサブコマンドの後ろへ付ける、と指示にも書いておきます。
AWS CLIをMCPサーバー経由で使う構成は、aws-api-mcp-serverの記事が読み取り専用モードを含めて説明しています。CLIをBashで直接叩く本記事の構成とは、権限を握る場所が違います。
まとめ
読み取りの許可は、コマンドを1つずつ列挙して書きます。denyの広いルールは例外を作れないため使えません。判定に使える項目は、ボリュームならstatus、Elastic IPなら3種の関連付け項目、ロードバランサーならターゲットグループとターゲットの健全性です。リクエストの有無だけは別の確認が要ります。
削除をIaCの差分に寄せる最大の利点は、レビューの場所が1つになることです。Claudeが作るのは一覧と.tfの差分まで。applyの確認ダイアログを押す人間が、最後の関門になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/permissions
- docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html
- docs.aws.amazon.com/cli/latest/reference/ec2/describe-volumes.html
- docs.aws.amazon.com/cli/latest/reference/ec2/describe-addresses.html
- docs.aws.amazon.com/cli/latest/reference/elbv2/describe-target-health.html