AWSの「誰が作ったかわからない」リソースで月200万円溶けてた話と自動化で解決した記録
四半期レビューで気づいたら誰も使っていないEBSとEIPだけで月80万円超。「念のため残してある」が積み重なる、あの負のループに心当たりありませんか?Steampipe×Lambdaで断ち切った実装記録です。
先日、四半期レビューでコスト分析をしていたら、誰も使っていないEBSボリュームとEIPだけで月80万円以上溶けていることに気づいてぞっとした。しかも「誰が作ったのかわからない」スナップショットが数千個積み上がっていて、もはや手動での整理は不可能な状態になっていた。
同じような経験をしているエンジニア、結構多いんじゃないかと思う。AWSはリソースを作るのは簡単なのに、消すのは怖い。だから気づいたら「念のため残してある」リソースが積み重なっていく。うちのチームもそれで3年間ジリジリとコストが増え続けていた。
で、昨年末から本腰を入れて不要リソース棚卸しの自動化に取り組んで、現在6ヶ月ほど運用している。結論から言うと月200万円弱の削減になった。完璧ではないし、正直まだ改善中の部分もあるけど、実装の流れと踏んだ地雷を共有しておく。
なぜ手動棚卸しは続かないのか
最初は「半年に一度、Spreadsheetに書き出して整理しよう」という運用をしていた。これが全然機能しなかった。
まず棚卸し作業自体がだるい。AWSコンソールを何十画面も切り替えて、リソースIDとタグを照合して、「これ誰のやつ?」をSlackで聞いて回る。1回やるのに丸2日かかって、終わった頃には気力が尽きて「まぁいいや」ってなる。しかもその作業を半年後にまたやると、また同じリソースが増えている。
もう一つの問題は「消していいかどうかの判断」が難しいこと。タグがついていないリソースは誰が何のために作ったか追えない。Console Historyを見ようにも90日で消えるし、CloudTrailのログは重いし検索しにくい。結局「念のため残しておこう」という判断になる。
2026年に入ってから、このサイクルを断ち切るためにアーキテクチャから見直した。
自動化アーキテクチャの全体像
棚卸し自動化のコアは「検知→通知→承認→削除」の4ステップを自動でつなぐパイプラインだ。
graph TB
subgraph Scheduler["EventBridge Scheduler"]
EBS["毎日 0:00 JST"]
end
subgraph Collector["収集レイヤー"]
LAMBDA_SCAN["Lambda: resource-scanner"]
STEAMPIPE["Steampipe Cloud / self-hosted"]
CONFIG["AWS Config"]
end
subgraph Storage["データストア"]
S3_REPORT["S3: 棚卸しレポート"]
DDB["DynamoDB: resource-state"]
end
subgraph Analyzer["分析レイヤー"]
LAMBDA_ANALYZE["Lambda: cost-analyzer"]
CE["Cost Explorer API"]
ATHENA["Athena: コスト相関分析"]
end
subgraph Notify["通知・承認"]
SNS["SNS"]
SLACK["Slack Webhook"]
LAMBDA_APPROVE["Lambda: approval-handler"]
APIGW["API Gateway\n(承認エンドポイント)"]
end
subgraph Cleanup["削除実行"]
LAMBDA_DELETE["Lambda: resource-deleter"]
SSM["SSM Automation"]
CLOUDTRAIL["CloudTrail: 削除ログ"]
end
subgraph Governance["ガバナンス"]
ORGCONFIG["AWS Config Rules"]
SECURITYHUB["Security Hub"]
end
EBS --> LAMBDA_SCAN
LAMBDA_SCAN --> STEAMPIPE
LAMBDA_SCAN --> CONFIG
STEAMPIPE --> S3_REPORT
CONFIG --> S3_REPORT
S3_REPORT --> LAMBDA_ANALYZE
CE --> LAMBDA_ANALYZE
LAMBDA_ANALYZE --> DDB
LAMBDA_ANALYZE --> ATHENA
DDB --> SNS
SNS --> SLACK
SNS --> LAMBDA_APPROVE
LAMBDA_APPROVE --> APIGW
APIGW --> LAMBDA_DELETE
LAMBDA_DELETE --> SSM
SSM --> CLOUDTRAIL
ORGCONFIG --> SECURITYHUB
SECURITYHUB --> SNS
ポイントは「自動削除はしない」設計にしたこと。最初は自動削除まで組もうとしたんだけど、本番DBのスナップショットを自動削除したら笑えないので、人間の承認ステップを必ず挟む構成にした。ただし承認作業はSlackのボタン一押しで済むようにして、心理的ハードルを下げている。
Steampipe × Lambda で不要リソース検知を実装する
検知ロジックのコアはSteampipeだ。2026年時点でSteampipe Cloud(v0.23.x)はマルチアカウント対応が安定してきていて、うちはOrganizations配下の12アカウントを一元管理している。
SteampipeをLambda内でself-hosted実行する構成も試したけど、コンテナイメージサイズが2GB超えたり冷却時間が長かったりで、最終的には専用のEC2(t3.small)でSteampipe Cloudエージェントを常駐させてAPI経由で叩く構成に落ち着いた。Lambda単体でやろうとすると地味に詰まるので注意。
実際に使っているSQLクエリをいくつか挙げておく。
未アタッチEBSボリュームの検出:
-- 30日以上未アタッチかつ最終アタッチ記録なし
SELECT
volume_id,
size,
volume_type,
create_time,
tags ->> 'Owner' AS owner,
tags ->> 'Project' AS project,
state,
ROUND(size * 0.10 * 24 * 30, 2) AS estimated_monthly_cost_usd
FROM
aws_ebs_volume
WHERE
state = 'available'
AND create_time < NOW() - INTERVAL '30 days'
ORDER BY
size DESC;
未使用EIPの検出:
SELECT
public_ip,
allocation_id,
association_id,
tags ->> 'Owner' AS owner,
-- EIPは未アタッチで月約550円(ap-northeast-1)
3.65 AS monthly_cost_usd
FROM
aws_vpc_eip
WHERE
association_id IS NULL;
90日以上前の孤立スナップショット:
SELECT
snapshot_id,
volume_id,
volume_size,
start_time,
description,
tags ->> 'CreatedBy' AS created_by,
EXTRACT(DAY FROM NOW() - start_time) AS age_days,
ROUND(volume_size * 0.05, 2) AS monthly_cost_usd
FROM
aws_ebs_snapshot
WHERE
owner_id = '123456789012' -- 自アカウントID
AND start_time < NOW() - INTERVAL '90 days'
-- AMI紐付きスナップショットは除外
AND snapshot_id NOT IN (
SELECT UNNEST(block_device_mappings_ebs_snapshot_ids)
FROM aws_ec2_ami
WHERE owner_id = '123456789012'
)
ORDER BY
volume_size DESC
LIMIT 500;
これをLambdaでスケジュール実行してS3にJSON保存し、DynamoDBに「検知済みリソース」として登録する。DynamoDBにはリソースID、検知日時、推定月額コスト、オーナーのメール(Organizationsのタグから取得)を持たせている。
LambdaのリソーススキャナーはPython 3.13で実装した。Steampipeとの連携部分を抜粋すると:
import boto3
import requests
import json
from datetime import datetime, timezone
def scan_unused_resources(account_id: str) -> dict:
"""
Steampipe Cloud APIを叩いてリソース情報を取得
"""
STEAMPIPE_API_ENDPOINT = "https://your-steampipe-host/api/v1/query"
headers = {
"Authorization": f"Bearer {get_steampipe_token()}",
"Content-Type": "application/json"
}
queries = {
"unused_ebs": UNUSED_EBS_QUERY,
"unused_eip": UNUSED_EIP_QUERY,
"old_snapshots": OLD_SNAPSHOTS_QUERY,
"idle_nat_gateway": IDLE_NAT_QUERY,
"unused_load_balancer": UNUSED_ALB_QUERY,
}
results = {}
for resource_type, query in queries.items():
resp = requests.post(
STEAMPIPE_API_ENDPOINT,
headers=headers,
json={"query": query, "connection": f"aws_{account_id}"}
)
resp.raise_for_status()
results[resource_type] = resp.json()["rows"]
return results
def calculate_total_waste(scan_result: dict) -> float:
"""推定月間無駄コストの合計(USD)"""
total = 0.0
for resource_type, resources in scan_result.items():
for r in resources:
total += float(r.get("monthly_cost_usd", 0))
return total
初回スキャンで出てきた数字がこれだ。正直、声が出た。
| リソース種別 | 検出数 | 推定月額コスト |
|---|---|---|
| 未アタッチEBS | 347個 | ¥890,000 |
| 未使用EIP | 89個 | ¥62,000 |
| 孤立スナップショット | 2,831個 | ¥430,000 |
| 放置NATゲートウェイ | 12個 | ¥310,000 |
| 未使用ALB | 8個 | ¥48,000 |
| 合計 | 3,287個 | ¥1,740,000 |
特にNATゲートウェイが12個放置されていたのは完全に盲点だった。NATゲートウェイって存在を忘れやすいんだけど、固定費がかなり痛い。
Slack承認フローの実装
検知結果をSlackに通知して、ボタン一押しで承認→削除できる仕組みを作った。これが地味に効果が大きくて、「承認するのが面倒だから放置」という状態を防げている。
def build_slack_approval_message(resource: dict) -> dict:
"""Slack Block Kit形式の承認メッセージ生成"""
resource_id = resource["resource_id"]
monthly_cost = resource["monthly_cost_usd"]
owner = resource.get("owner", "不明")
age_days = resource.get("age_days", 0)
return {
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*不要リソース検知* :warning:\n"
f"リソースID: `{resource_id}`\n"
f"種別: {resource['resource_type']}\n"
f"推定月額: *${monthly_cost:.2f}* (¥{monthly_cost * 150:.0f})\n"
f"検知日からの経過: {age_days}日\n"
f"オーナータグ: {owner}"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "削除承認 ✅"},
"style": "danger",
"confirm": {
"title": {"type": "plain_text", "text": "本当に削除しますか?"},
"text": {"type": "mrkdwn", "text": f"`{resource_id}` を削除します"},
"confirm": {"type": "plain_text", "text": "削除する"},
"deny": {"type": "plain_text", "text": "キャンセル"}
},
"value": json.dumps({
"action": "approve_delete",
"resource_id": resource_id,
"approval_token": generate_approval_token(resource_id)
})
},
{
"type": "button",
"text": {"type": "plain_text", "text": "30日スヌーズ 💤"},
"value": json.dumps({
"action": "snooze",
"resource_id": resource_id,
"snooze_days": 30
})
},
{
"type": "button",
"text": {"type": "plain_text", "text": "除外リストに追加 🚫"},
"value": json.dumps({
"action": "exclude",
"resource_id": resource_id
})
}
]
}
]
}
「30日スヌーズ」と「除外リストに追加」ボタンを付けたのが地味に重要だった。最初は「削除承認」と「キャンセル」の2択だったんだけど、「キャンセル」を押したら翌日また通知が来る仕様にしたら、エンジニアがSlackを見なくなるという問題が発生した。「わかった、でも今じゃない」という選択肢を提供することで、通知疲れを防げている。個人的には、このUX改善が一番効いた変更だと思っている。
実際の削減効果の推移はこんな感じだ:
xychart-beta
title "不要リソース棚卸し自動化による月次コスト削減額推移(万円)"
x-axis ["2025-12月", "2026-1月", "2026-2月", "2026-3月", "2026-4月", "2026-5月"]
y-axis "削減額(万円)" 0 --> 220
bar [0, 68, 112, 145, 178, 198]
line [0, 68, 112, 145, 178, 198]
初月は設定と除外リストの整備で手間取ったので削減額は小さい。2ヶ月目から本格的に承認フローが回り始めて、5ヶ月目には月198万円の削減に達した。最近は「新規に積み上がるペース」より「削除するペース」が上回っているので、引き続き下がっていくはず……たぶん。
運用して気づいた落とし穴と対策
正直、最初の実装は甘かった。半年運用して踏んだ落とし穴をまとめておく。
落とし穴①:タグなしリソースのオーナー特定
CloudTrailのCreateVolume/RunInstancesイベントを遡ってオーナーを特定しようとしたんだけど、90日以上前のリソースはログが消えていた。結局、AWS Organizationsのアカウント情報とコスト配分タグを組み合わせて「アカウント単位のオーナー」として通知する方式に変えた。アカウント設計がきちんとしていれば概ね追えるようになった。
落とし穴②:スナップショットの依存関係
「孤立スナップショット」として検出したものの中に、実はAMIのベースになっているスナップショットが混じっていた。AMI側を先にDeregisterしないとスナップショットを削除できないし、そのAMIを参照しているLaunch Templateがあったりする。スナップショット削除フローには必ず依存関係チェックを挟むようにした。
def check_snapshot_dependencies(snapshot_id: str, ec2_client) -> dict:
"""スナップショットの依存関係を確認"""
dependencies = {
"has_ami": False,
"ami_ids": [],
"has_launch_template": False,
"safe_to_delete": True
}
# AMIとの紐付き確認
images = ec2_client.describe_images(
Filters=[{"Name": "block-device-mapping.snapshot-id", "Values": [snapshot_id]}],
Owners=["self"]
)["Images"]
if images:
dependencies["has_ami"] = True
dependencies["ami_ids"] = [i["ImageId"] for i in images]
dependencies["safe_to_delete"] = False
return dependencies
落とし穴③:NATゲートウェイの「使われていない」判定
NATゲートウェイは少量のトラフィックがあっても固定費がかかる。CloudWatchのBytesOutToDestinationメトリクスが7日間の平均で100MB/day以下なら「事実上使われていない」と判定するロジックにしたけど、バッチ処理が月1回しか走らないシステムで誤検知したことがある。閾値の設定は慎重にやる必要があって、ここはチームで議論してから決めたほうがいい。
落とし穴④:マルチアカウント環境での権限設計
12アカウントを横断してスキャンするためのIAMロール設計が最初うまくいかなかった。各アカウントにCostAuditRoleという専用ロールを作って、Organizationsのマスターアカウントからsts:AssumeRoleする構成にした。IAMポリシーは読み取り専用で、削除実行のみ別ロールに分けている。削除ロールへのAssumeRoleにはMFA条件を付けることも考えたけど、Lambdaで実行するには実用的じゃなかったので、削除承認トークンの検証で代替している。
このあたりのマルチアカウント設計については、以前書いたAWS Organizationsで10個のアカウントをまとめた話も参考になるかもしれない。
またコスト削減の全体戦略という意味では月額500万円の請求書を見て動いた、AWS費用を30%削減した3ヶ月の実装記録でも似たような文脈で整理しているので合わせて読んでみてほしい。
NATゲートウェイやEIPのコスト感については月80万円のデータ転送費をVPCエンドポイント導入で削減した実装記録も参考になる。
まとめ
6ヶ月の実装・運用で学んだことを整理すると:
- 手動棚卸しは絶対に続かない。 自動検知→Slack承認→自動削除のパイプラインで、人間の判断ステップだけ残す設計が正解だった
- Steampipeを使うとマルチアカウントの横断クエリがSQLで書けて、 検知ロジックのメンテが圧倒的に楽になる
- 「スヌーズ」「除外リスト」のオプションは必須。 ないと通知疲れで誰も見なくなる
- スナップショットの依存関係チェックは絶対に実装する。 AMI→Launch Template→Auto Scalingグループの連鎖を確認しないと大事故になる
- 初回スキャンで出てくる数字がえぐいのは当然。 段階的に処理して、チームの信頼を積み上げながら進める
次にやりたいのは、リソース作成時に「TTL」タグを付けさせる仕組みとの連携だ。インフラ作成→TTL過ぎたら自動的に棚卸し候補に入るというライフサイクル管理まで組み込めれば、「積み上がる前に検知できる」状態になる。これは正直まだ設計中で、CDK Aspectsで作成時に強制的にTTLタグを付けさせるのが現実的かと思っている。
皆さんのチームでは不要リソースの棚卸しどうやって回してる?半年に一回の作業になってたりしませんか。もし似たような課題を抱えているなら、まず最初のステップとしてSteampipeのセルフホストを立てて手動でクエリを叩くだけでも、「うちのAWSの闇」が可視化されて面白いと思う。