Unattached EBS volumes + snapshots → claude cleanup plan
Lists every `available` (unattached) EBS volume with size, age, and snapshot count, then asks claude to bucket each one into delete-now / archive-to-snapshot / keep with a dollar estimate.
Setup
- → brew install awscli jq
- → aws configure # or export AWS_PROFILE=prod
- → IAM: ec2:DescribeVolumes, ec2:DescribeSnapshots (read-only; no delete perms needed for the audit)
- → claude /login OR export ANTHROPIC_API_KEY=sk-…
Cost per run
<$0.01
The one-liner
$ REGION=${AWS_REGION:-eu-central-1}; NOW=$(date -u +%s); SNAPS=$(aws ec2 describe-snapshots --owner-ids self --region $REGION --query 'Snapshots[].VolumeId' --output json | jq 'group_by(.) | map({key: .[0], value: length}) | from_entries'); aws ec2 describe-volumes --region $REGION --filters Name=status,Values=available --query 'Volumes[].{id:VolumeId,size:Size,type:VolumeType,created:CreateTime,az:AvailabilityZone,tags:Tags}' --output json | jq --argjson snaps "$SNAPS" --argjson now $NOW -r '.[] | [.id, .size, .type, ((($now - (.created | fromdateiso8601)) / 86400) | floor), ($snaps[.id] // 0), ((.tags // []) | map(select(.Key=="Name")) | .[0].Value // "untagged")] | @tsv' | sort -k4 -rn | head -50 | claude -p "You are a FinOps engineer reporting to a CFO. Columns: volume_id, size_gib, type, age_days, snapshot_count, name_tag. gp3 is ~\$0.08/GiB-month, io2 ~\$0.125/GiB-month. For each row decide DELETE_NOW / ARCHIVE_THEN_DELETE / KEEP and give a one-line reason. End with a table sorted by monthly \$ saved, then total annualised savings."What each stage does
- [01] aws
SNAPS=$(aws ec2 describe-snapshots --owner-ids self ... | jq 'group_by(.) | map(…Builds a {volumeId: snapshotCount} map in one shot — `--owner-ids self` is mandatory or you get every public snapshot in AWS. - [02] aws
aws ec2 describe-volumes --filters Name=status,Values=available`status=available` is the canonical 'unattached' filter; `Attachments == []` is a noisy client-side check that misses attaching/detaching transient states. - [03] jq
jq --argjson snaps "$SNAPS" --argjson now $NOW -r '.[] | [.id, .size, .type, (((…Joins the snapshot map onto each volume, computes age_days from CreateTime, flattens to TSV so claude sees a tidy grid instead of 6KB of JSON noise. - [04] jq
sort -k4 -rn | head -50Sorts by age descending and caps at 50 rows — oldest unattached volumes are almost always the safest deletes and keeps the prompt under ~2k tokens. - [05] claude
claude -p "You are a FinOps engineer ... DELETE_NOW / ARCHIVE_THEN_DELETE / KEEP…Forces a 3-bucket taxonomy plus a dollar-ranked table — the CFO framing kills the usual hedging and produces an actionable memo.
Expected output (sample)
vol-0a1b2c3d4e 500 gp3 412 0 legacy-jenkins-data vol-0f5e6d7c8b 100 io2 287 2 staging-pg-old vol-09a8b7c6d5 30 gp2 91 1 untagged # claude memo: # vol-0a1b2c3d4e → DELETE_NOW (500 GiB gp3 unattached >1y, no snapshots — pure waste, $40/mo) # vol-0f5e6d7c8b → ARCHIVE_THEN_DELETE (io2 staging, 2 snaps exist, delete vol keep snaps, $12.50/mo) # vol-09a8b7c6d5 → KEEP (untagged, <90d — ping owner first) # Total annualised: ~$630
Caveats & tips
- Date math is portable here (`date -u +%s` works on BSD and GNU); CreateTime is parsed inside jq with fromdateiso8601 so no `date -v` vs `date -d` trap.
- `describe-snapshots --owner-ids self` is REQUIRED — without it you pull every public snapshot in the AWS marketplace and the jq map explodes.
- Volumes in `creating` or `error` state are skipped by the `available` filter; sweep them separately with `Name=status,Values=error`.
- Encrypted volumes count toward your KMS key usage — deleting frees that quota too, worth mentioning to security.