使っていないAWSリソースの洗い出しと削除を、Claude Codeに手伝わせました。管理権限のあるアクセスキーを作業中だけ有効にして渡す形です。結果として月$100以上の削減になりました。請求の内訳を割って、どのリソースが課金されているかを機械的に照合する作業はAIが得意とするところで、人が目視で追うより網羅性が高くなります。
一方で、AIは自信を持って間違った結論を出す場面が2回ありました。片方は放置すればインスタンスが復旧不能になる操作でした。この記事では調査の手順と、人が確認しないと危ない箇所を書きます。
削除したのは次の5つです。
- 停止中インスタンスに紐づいたElastic IP … 停止していてもアイドル扱いで課金される。解放するとIPは戻らないので、共有済みかどうかの確認が必要だった
- 長期間停止したままのインスタンスとそのEBSボリューム … 数年前に止めたきりのデモ機。停止中でもボリュームは満額で課金が続く
- 600GBのgp2ボリューム … 停止中インスタンスに付いたまま。単体で最も大きい削減になった
- DBインスタンス削除後に残っていた手動スナップショット … DBは1台も動いていないのに、最終スナップショットだけが数年分残っていた
- 参照元のAMIが消えたEBSスナップショット … 6〜7年前のものが大半。AMIを登録解除するとスナップショットだけ取り残される
どれも「使っていないのに課金され続けていた」もので、稼働中のリソースを削ったり、新しく何かを最適化したわけではありません。裏を返せば、一度作って放置したものが積み上がっていただけです。金額はAWS公式の単価だけを載せます。総額や個別リソースの請求額は書きません。
記事中のIPアドレス・インスタンスID・セキュリティグループIDはすべてドキュメント用の架空の値です。 IPアドレスはRFC 5737で文書用に予約されている 203.0.113.0/24(TEST-NET-3)を使っています。example.com と同じ「サンプル専用」の扱いで、実在のホストには割り当てられません。
調査用のアクセスキーは作業中だけ有効にする
この作業にはAWSの管理権限が必要です。請求データの参照からリソースの削除まで行うので、読み取り専用では完結しません。AIエージェントに渡す認証情報としては強い部類に入ります。
そこで、専用のIAMユーザーを作り、そのアクセスキーを作業中だけ有効化する運用にしています。普段はキーを無効(Inactive)にしておき、作業を始めるときにコンソールから有効化、終わったらまた無効に戻します。キー自体を都度作り直すのではなく、有効・無効を切り替えるだけなので手間はほとんどかかりません。
キーを削除せず無効化で運用する利点は、~/.aws/credentials の設定を毎回書き換えなくて済むことです。無効の状態でAWSコマンドを実行すると、認証エラーで止まります。
aws sts get-caller-identity --query 'Arn' --output text
An error occurred (InvalidClientTokenId) when calling the GetCallerIdentity operation: The security token included in the request is invalid.
このエラーが返るなら、キーは無効化されている状態です。作業前の確認としても使えます。有効化されていれば、そのIAMユーザーのARNが返ります。
arn:aws:iam::123456789012:user/Claude
日常的に使っているAWSアカウントとは別のIAMユーザーにしておくと、この確認が「意図せず強い権限が有効になっていないか」のチェックも兼ねます。返ってきたARNが普段使いのユーザーなら、調査用キーは無効のままだと分かります。
権限の絞り込みについては、読み取りだけの調査フェーズと、削除を伴う実行フェーズでポリシーを分ける方法もあります。今回は同一キーで通しましたが、削除操作の頻度が低いなら調査用と実行用を分けたほうが安全です。いずれにせよ、常時有効な管理者キーをローカルに置いたままにはしないのが前提になります。
請求の内訳をサービス単位まで割る
コンソールのCost Explorerでも見られますが、CLIのほうが後の作業とつなげやすいので最初からCLIで取ります。
aws ce get-cost-and-usage --time-period Start=2026-07-01,End=2026-08-01 --granularity MONTHLY --metrics UnblendedCost --group-by Type=DIMENSION,Key=SERVICE --region us-east-1 --query 'ResultsByTime[].Groups[].[Keys[0],Metrics.UnblendedCost.Amount]' --output text | sort -t$'\t' -k2 -rn
Cost Explorerのエンドポイントは us-east-1 にあるので、東京リージョンで動かしていても --region us-east-1 を付けます。
ここで EC2 - Other という項目が上位に来ることがあります。これはEC2の本体(インスタンス稼働時間)ではない課金の寄せ集めで、中身を割らないと何にお金がかかっているか分かりません。もう一段掘ります。
aws ce get-cost-and-usage --time-period Start=2026-07-01,End=2026-08-01 --granularity MONTHLY --metrics UnblendedCost --filter '{"Dimensions":{"Key":"SERVICE","Values":["EC2 - Other"]}}' --group-by Type=DIMENSION,Key=USAGE_TYPE --region us-east-1 --query 'ResultsByTime[].Groups[].[Keys[0],Metrics.UnblendedCost.Amount]' --output text | sort -t$'\t' -k2 -rn
APN1-EBS:VolumeUsage.gp2 や APN1-EBS:SnapshotUsage といった使用タイプ別に分かれます。ここまで割ると「EBSのgp2ボリュームが最大の課金要因」のように、次に何を調べるかが決まります。
インスタンスを止めてもEBSとIPは課金が続く
棚卸しで見落としやすいのが、停止中のインスタンスに紐づくリソースです。インスタンスを停止すると稼働時間の課金は止まりますが、EBSボリュームは満額で課金が続きます。gp2は1GBあたり月$0.12なので、600GBのボリュームが停止中のインスタンスに付いていれば、何も動いていなくても月$72かかる計算です。
ボリュームの一覧とアタッチ先はこれで出せます。
aws ec2 describe-volumes --region ap-northeast-1 --query 'Volumes[].[VolumeType,Size,VolumeId,State,Attachments[0].InstanceId]' --output text | sort
IPアドレスも同様です。2024年からパブリックIPv4アドレスは保有しているだけで課金対象になりました($0.005/時、月あたり約$3.6)。使っていないIPを持ち続けると、それだけで費用が積み上がります。
「未割り当てのEIPは0件」は安全の証明にならない
ここが1つ目の落とし穴でした。未使用のElastic IPを探すとき、素直に書くとこうなります。
aws ec2 describe-addresses --region ap-northeast-1 --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output table
AssociationId が null のもの、つまりどこにも割り当てられていないEIPを抽出しています。これを実行して0件だったので、一度は「未使用のEIPは無い」と判断しました。
ところが請求には「アイドル状態のパブリックIPv4」という項目が立っていました。矛盾しています。
原因は、停止中のインスタンスに割り当てられたEIPも課金対象だという点です。EIPから見れば割り当て先は存在するので AssociationId は埋まっています。しかし紐づく先のインスタンスが停止していれば、そのIPは使われていないのでAWSはアイドル扱いで課金します。EIP単体を見ても分からず、紐づく先の状態まで追う必要があります。
for eip in $(aws ec2 describe-addresses --region ap-northeast-1 --query 'Addresses[].PublicIp' --output text); do
IID=$(aws ec2 describe-addresses --region ap-northeast-1 --filters "Name=public-ip,Values=$eip" --query 'Addresses[0].InstanceId' --output text)
ST=$(aws ec2 describe-instances --instance-ids $IID --region ap-northeast-1 --query 'Reservations[].Instances[].State.Name' --output text)
echo "$eip -> $IID ($ST)"
done
出力はこうなります。
203.0.113.10 -> i-0aaaaaaaaaaaaaaaa (running)
203.0.113.20 -> i-0bbbbbbbbbbbbbbbb (running)
203.0.113.30 -> i-0cccccccccccccccc (stopped) <- これが課金対象
ここに (stopped) が出てくるEIPが課金対象です。なおEIPを解放するとそのIPアドレスは戻ってきません。DNSに書いている、接続元制限のリストに入れている、といった用途があれば解放前に確認が必要です。
参照元が消えたスナップショットを機械的に洗い出す
スナップショットは古いものが溜まりやすく、しかも一覧を眺めても「これは消していいのか」が判断できません。判定の軸になるのは、そのスナップショットを参照しているAMIが存在するかです。
AMI作成時に自動生成されたスナップショットは、AMIを登録解除しない限り消せません。逆にAMIが先に消されると、スナップショットだけが取り残されます。この孤児は次のように洗い出せます。
USED=$(aws ec2 describe-images --owners 自分のアカウントID --region ap-northeast-1 --query 'Images[].BlockDeviceMappings[].Ebs.SnapshotId' --output text | tr '\t' '\n' | sort -u)
aws ec2 describe-snapshots --owner-ids 自分のアカウントID --region ap-northeast-1 --query 'Snapshots[].[SnapshotId,VolumeSize,StartTime]' --output text | while IFS=$'\t' read -r sid size date; do
echo "$USED" | grep -q "$sid" || echo "孤児: $sid ${size}GB ${date:0:10}"
done
AMIが参照しているスナップショットIDの一覧を作り、全スナップショットのうちその一覧に載っていないものを孤児として出しています。作成日も一緒に出しておくと、何年前のものかが分かって判断しやすくなります。
RDSでも同じことが起きます。describe-db-instances が空を返す(DBが1台も無い)のに請求にRDSが載っている場合、正体は手動スナップショットです。DBインスタンスを削除するとき、AWSは最終スナップショットの作成を促してきます。そこで作られた final-snapshot が何年も残り続けます。
aws rds describe-db-snapshots --region ap-northeast-1 --snapshot-type manual --query 'DBSnapshots[].[DBSnapshotIdentifier,AllocatedStorage,SnapshotCreateTime,Engine]' --output table
請求側では RDS:ChargedBackupUsage という使用タイプで出てくるので、この項目が立っていたらスナップショットを疑います。
誤診その1: 接続できたポートを「公開されている」と報告した
ここからがAIの間違いです。セキュリティグループの点検を兼ねて、外部からポートに繋がるかを確認させました。返ってきた報告は「MySQLのポートがインターネット全体に開いていて危険」というものでした。根拠として示されたのは接続テストの結果です。
nc -zv -w 5 example.com 3306
Connection to example.com port 3306 [tcp/mysql] succeeded!
確かに接続は成立しています。しかしセキュリティグループのルールを読むと、3306の許可元は単一のIPアドレス(/32)だけでした。そしてテストを実行していた回線が、まさにその許可されたIPだったという話です。正規の接続元から正規の接続をして成功しただけで、公開されている証拠にはなりません。
公開範囲を判定する一次情報は、接続テストの結果ではなくルール定義です。
aws ec2 describe-security-groups --group-ids sg-xxxxxxxx --region ap-northeast-1 --query 'SecurityGroups[].IpPermissions[].[IpProtocol,FromPort,ToPort,IpRanges[].CidrIp|join(`,`,@)]' --output table
許可元が 0.0.0.0/0 なら全世界公開、/32 なら単一IPです。これを読めば接続テストをする前に結論が出ます。許可リスト方式の設定を、許可されている場所からテストしても何も検証できていません。
この誤診の性質が厄介なのは、報告が具体的で、証拠らしきものが添えられていることです。コマンドの出力が貼られていると裏取りした気分になります。実際には「自分がどこから叩いているか」という前提が抜けていました。
誤診その2: ルートディスクを消す寸前だった
もう1つは実害が出る手前で止まった話です。大きなボリュームが停止中のインスタンスに付いていたので、「スナップショットを取ってボリュームを削除する」という方針で作業を進めようとしました。指示としては自然ですし、AI側もそのまま実行できる内容です。
実行前にボリュームの属性を確認したところ、これがそのインスタンスの唯一のルートディスクでした。
aws ec2 describe-instances --instance-ids i-xxxxxxxxxxxx --region ap-northeast-1 --query 'Reservations[].Instances[].[InstanceId,RootDeviceName,[BlockDeviceMappings[].[DeviceName,Ebs.VolumeId,Ebs.DeleteOnTermination]]]' --output text
i-xxxxxxxxxxxx /dev/xvda
/dev/xvda vol-xxxxxxxxxxxx True
RootDeviceName と BlockDeviceMappings の DeviceName が一致していれば、そのボリュームはOSが入っている起動ディスクです。
生のスナップショットを取ってからボリュームを削除すると、データ自体は保全されます。しかし起動可能な状態に戻すのは手作業です。スナップショットからボリュームを復元し、インスタンスにアタッチし、ブート設定を合わせる必要があります。スナップショットは「ディスクの中身のコピー」であって、「このディスクから起動する」という情報は持っていません。
正しい方法は create-image でAMIを作ることです。AMIはディスクのスナップショットに加えて、アーキテクチャ・ルートデバイス名・仮想化方式といった起動に必要なメタデータを持ちます。
aws ec2 create-image --instance-id i-xxxxxxxxxxxx --region ap-northeast-1 --name "backup-20260806" --description "削除前の最終バックアップ" --no-reboot
保管されるスナップショットは同じなので、保管コストは生のスナップショットと変わりません。それで復元性だけが上がります。停止中のインスタンスなら書き込みが無いので、--no-reboot を付けても静止点の心配はいりません。
AMIが完成したかは状態で確認します。pending の間はまだ使えません。
aws ec2 describe-images --image-ids ami-xxxxxxxxxxxx --region ap-northeast-1 --query 'Images[0].State' --output text
600GBクラスのボリュームだと完成まで数時間かかります。available になる前にボリュームを消すと、そこで復旧手段が無くなります。
AMIが完成したあとは、インスタンスを終了させればボリュームも一緒に消えます。先ほどの出力にあった DeleteOnTermination が True なら、終了時に自動削除される設定です。False のインスタンスも実在するので、事前に確認しておくと削除漏れを防げます。
AIに任せてよい範囲と、人が見る箇所
今回の作業を振り返ると、AIが強かったのは網羅性でした。請求項目を上から順に割り、リソースを全リージョン横断で数え、AMIとスナップショットを突き合わせて孤児を出す。こうした「漏れなく機械的に照合する」作業は、人が手でやると必ず抜けが出ます。他リージョンにリソースが残っていないかの確認なども、指示すれば数十秒で終わります。
一方で、間違えた2箇所には共通点があります。どちらも「取得した情報の解釈」で誤っており、コマンド自体は正しく動いていました。 接続テストは成功しているし、ボリューム一覧も正確です。誤りは、その結果が何を意味するかの判断にありました。出力が正しいぶん、報告は自信ありげになります。
そのため、人が見るべきはコマンドの正しさではなく次の点です。
- 削除・解放は元に戻せるか。 EIPは解放すると同じIPが戻りません。スナップショットも削除したら復元できません。「消す」操作は、実行前に復旧手段があるかを確認する
- その結論を出した前提は何か。 「接続できた」なら、どこから接続したのか。「未使用」なら、何をもって未使用と判定したのか。判定の前提を一段掘ると誤診が見つかります
- 消す対象がシステムの根幹か。 ルートディスク、稼働中インスタンスのボリューム、現役のバックアップ。これらは「不要そうに見える」だけで消すと復旧できません
逆に言えば、この3点さえ人が押さえれば、棚卸しそのものは任せて問題ありませんでした。危険なのは実行より判断で、判断の前提を確認する作業は人が引き受けるのが現実的です。