Cloud waste detection is the first step in FinOps. This guide walks through a systematic 15-point audit using native cloud tooling. Run this audit weekly — most waste is recoverable within 24 hours of detection.
Even stopped instances can incur charges if attached EBS volumes are present. Verify every "stopped" instance.
$ aws ec2 describe-instances \ --filters "Name=instance-state-name,Values=stopped" \ --query 'Instances[*].[InstanceId,InstanceType,Tags[?Key==`Name`].Value|[0],BlockDeviceMappings[*].Ebs.VolumeId]' \ --output table ┌─────────────────┬──────────────┬─────────────────────┬────────────────┐ │ InstanceId │ Type │ Name │ Attached-Vol │ ├─────────────────┼──────────────┼─────────────────────┼────────────────┤ │ i-0a1b2c3d4e │ r5.xlarge │ legacy-db-backup │ vol-0abc123 │ │ i-0a1b2c3d4f │ t3.medium │ test-env-2023 │ vol-0def456 │ │ i-0a1b2c3d4g │ m5.large │ old-web-server │ none │ └─────────────────┴──────────────┴─────────────────────┴────────────────┘ ⚠ i-0a1b2c3d4e — r5.xlarge @ $0.483/hr stopped but still charged (EBS attached) ⚠ i-0a1b2c3d4f — t3.medium @ $0.042/hr — test environment from 2023, unused
Volumes attached to terminated instances generate charges with zero value. This is one of the most common sources of silent waste.
$ aws ec2 describe-volumes \
--filters "Name=status,Values=available" \
--query 'Volumes[*].[VolumeId,VolumeType,Size,CreateTime,Tags[?Key==`Name`].Value|[0]]' \
--output table
┌──────────────┬──────────┬──────┬──────────────────────┬───────────────────┐
│ VolumeId │ Type │ GiB │ CreateDate │ Name │
├──────────────┼──────────┼──────┼──────────────────────┼───────────────────┤
│ vol-0abc123 │ gp3 │ 100 │ 2023-06-15T09:00:00 │ old-server-root │
│ vol-0def456 │ st1 │ 500 │ 2022-11-01T14:22:00 │ data-archive-old │
│ vol-0ghi789 │ gp3 │ 50 │ 2024-01-10T11:00:00 │ unnamed-$%# │
└──────────────┴──────────┴──────┴──────────────────────┴───────────────────┘
⚠ 3 orphaned volumes × avg $0.08/GiB/mo = $52.80/mo wasted
⚠ vol-0ghi789 — no tags, no name, likely test artifact
AWS charges $0.005/hr (~$3.65/mo) for Elastic IPs not associated with a running instance. Every unassociated EIP is a monthly leak.
$ aws ec2 describe-addresses \
--query 'Addresses[?Association.InstanceId==null].[PublicIp,AllocationId]' \
--output json | jq '.'
[
{ "PublicIp": "52.15.67.89", "AllocationId": "eipalloc-abc123" },
{ "PublicIp": "18.218.90.12", "AllocationId": "eipalloc-def456" },
{ "PublicIp": "3.19.45.67", "AllocationId": "eipalloc-ghi789" }
]
⚠ 3 unattached EIPs × $3.65/mo = $10.95/mo wasted
→ Release with: aws ec2 release-address --allocation-id eipalloc-xxxxx
Use AWS Compute Optimizer (or Azure Advisor / GCP Recommender) to find instances with CPU < 40% over 30 days. Each downsizing saves 30–60% per instance.
$ aws compute-optimizer get-instance-recommendations \
--account-id 123456789012 \
--query 'instanceRecommendations[*].[accountId,instanceArn,currentInstanceType,recommendedInstanceType,findings]' \
--output table
┌─────────────────┬──────────────────────┬────────────────────┬──────────────────────┬───────────┐
│ Account │ InstanceArn │ CurrentType │ RecommendedType │ Finding │
├─────────────────┼──────────────────────┼────────────────────┼──────────────────────┼───────────┤
│ 123456789012 │ i-0abc123def │ r5.4xlarge │ r5.xlarge │ OVERDIZED│
│ 123456789012 │ i-0def456ghi │ m5.2xlarge │ m5.large │ OVERDIZED│
│ 123456789012 │ i-0ghi789jkl │ t3.large │ t3.micro │ OVERDIZED│
└─────────────────┴──────────────────────┴────────────────────┴──────────────────────┴───────────┘
Estimated monthly savings (3 instances):
$847.20 → $318.40 (save $528.80/mo)
$ aws ec2 describe-snapshots \
--owner-ids self \
--query 'Snapshots[*].[SnapshotId,VolumeSize,StartTime,Tags[?Key==`Name`].Value|[0]]' \
--output table | sort -k3
┌──────────────┬──────┬────────────────────┬─────────────────┐
│ SnapshotId │ GiB │ Created │ Name │
├──────────────┼──────┼────────────────────┼─────────────────┤
│ snap-abc123 │ 500 │ 2022-03-15 │ old-backup-1 │
│ snap-def456 │ 200 │ 2022-07-01 │ (none) │
│ snap-ghi789 │ 100 │ 2024-02-20 │ current-web │
└──────────────┴──────┴────────────────────┴─────────────────┘
⚠ snap-def456 — no tags, 200 GiB from 2022. Verify before deletion.
Use S3 Storage Lens or cost allocation reports to find buckets with objects older than the appropriate tier threshold.
| Access Pattern | Correct Tier | Wrong Tier | Misplaced Cost |
|---|---|---|---|
| Quarterly access | Glacier | Hot | ~$23/TB/yr overpay |
| Monthly access | Cool/IA | Hot | ~$20/TB/yr overpay |
| Unused (>90 days) | Glacier | Standard | ~$21/TB/yr overpay |
$ aws s3api list-objects-v2 \
--bucket my-log-bucket \
--query 'Contents[?LastModified < `2024-01-01`].[Key,Size,StorageClass]' | \
jq '.[] | "Objects over 1yr old: \(.[0]) — \(.[1]) bytes — \(.[2])"
Objects over 1yr old: logs/2022/Q4/app.log — 4.2 GB — STANDARD
Objects over 1yr old: logs/2022/Q4/error.log — 0.8 GB — STANDARD
Objects over 1yr old: exports/2023/backups/ — 120 GB — STANDARD
→ Apply lifecycle rule: S3 bucket → Management → Lifecycle → Move to Glacier after 365d
$ aws ce get-reservation-coverage \
--time-period Start=2024-01-01,End=2024-12-31 \
--metrics Coverage_percentage,OnDemandCost,TotalRunningHours \
--granularity MONTHLY \
--query 'CoverageByInstanceType[].[InstanceType,Coverage.RunningHours,Coverage.CoverageHoursPercentage]'
┌────────────────┬────────────────┬────────────────────┐
│ InstanceType │ RunningHours │ Coverage% │
├────────────────┼────────────────┼────────────────────┤
│ t3.micro │ 43,800 │ 12% │
│ t3.small │ 43,800 │ 8% │
│ m5.large │ 43,800 │ 82% │
└────────────────┴────────────────┴────────────────────┘
⚠ t3.micro + t3.small — 88–92% on-demand. Buying 1-yr SP would save ~30%.