DEV Community

Muskan _zop
Muskan _zop

Posted on

The cloud zombie index: every resource you're paying for that nothing uses

Every cloud account past its second birthday has a graveyard. Volumes detached from instances that were terminated in a hurry. Elastic IPs allocated for a demo. A load balancer whose last request was months ago. Snapshots of volumes that no longer exist. Nothing uses any of it, and all of it bills, every hour, at full price.

Search "find unused AWS resources" and you'll get the same answer forty times: a stack of describe commands. Run them, get a list of resource IDs, bookmark the tab, move on. Nothing gets deleted.

The list is missing the only column that makes anyone act.

The rule: no finding without a dollar

Here's the thing every one of those answers leaves out: each zombie type has a known monthly rate. An unattached gp3 volume is $0.08 per GB-month. An idle public IP is $3.65 a month. A load balancer with zero requests is $16.43 a month before it does a single thing. A snapshot is $0.05 per GB-month whether or not its parent volume still exists.

Which means you never have to stop at a list. You can multiply.

"Here are 43 unattached volumes" is homework. "You are paying $412/month for storage nothing can read" is a decision. Same data, different sentence, and only one of them survives contact with a sprint planning meeting.

So this index does both, for each zombie type: the command that finds them, and a version that prints the dollar figure directly.

Rates below are us-east-1 list prices (early 2026), using a 730-hour month. Other regions drift 10-30% higher, so check your region's pricing page, or better, let your bill be the source of truth.

The index

1. Unattached EBS volumes

The classic. An instance gets terminated, its secondary volumes had DeleteOnTermination=false (the default for volumes added after launch), and they've been sitting in available state (the politest word ever chosen for "orphaned") ever since.

Rate: gp3 $0.08/GB-mo, gp2 $0.10, io1/io2 $0.125 (plus provisioned IOPS on top), st1 $0.045, sc1 $0.015.

aws ec2 describe-volumes --filters Name=status,Values=available \
  --query 'Volumes[].[VolumeType,Size]' --output text |
awk '{gb[$1]+=$2}
END {
  n=split("gp3=0.08 gp2=0.10 io1=0.125 io2=0.125 st1=0.045 sc1=0.015 standard=0.05", p, " ")
  for (i=1;i<=n;i++) {split(p[i], kv, "="); rate[kv[1]]=kv[2]}
  for (t in gb) {c=gb[t]*rate[t]; total+=c; printf "%-9s %6d GB = $%8.2f/month\n", t, gb[t], c}
  printf "UNATTACHED STORAGE TOTAL = $%.2f/month\n", total
}'
Enter fullscreen mode Exit fullscreen mode

For io1/io2 the printed number is the floor: provisioned IOPS bill separately on top of the GB.

2. Idle public IPs

Since February 2024, AWS charges $0.005/hour ($3.65/month) for every public IPv4 address, attached or not. The attached ones are at least doing a job. The unattached ones are a subscription to a number.

aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==`null`].AllocationId' --output text |
wc -w | awk '{printf "Idle public IPs: %d = $%.2f/month\n", $1, $1*3.65}'
Enter fullscreen mode Exit fullscreen mode

Bonus trap: an Elastic IP attached to a stopped instance also bills the idle rate. Stopping the instance didn't stop the IP.

3. Load balancers with zero requests

An ALB costs $16.43/month at rest (an NLB the same, a Classic LB $18.25) before any traffic. The zombie ones are load balancers whose service was decommissioned, but the LB, its DNS name, and its hourly rate outlived it.

for arn in $(aws elbv2 describe-load-balancers \
    --query 'LoadBalancers[?Type==`application`].LoadBalancerArn' --output text); do
  lb=${arn#*:loadbalancer/}
  reqs=$(aws cloudwatch get-metric-statistics --namespace AWS/ApplicationELB \
    --metric-name RequestCount --dimensions Name=LoadBalancer,Value="$lb" \
    --start-time "$(date -u -d '7 days ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
    --period 604800 --statistics Sum --query 'Datapoints[0].Sum' --output text)
  case "$reqs" in None|0|0.0) echo "$lb: 0 requests in 7 days = \$16.43/month for nothing";; esac
done
Enter fullscreen mode Exit fullscreen mode

(macOS: swap the date calls for date -u -v-7d +%FT%TZ. For NLBs, use namespace AWS/NetworkELB and ProcessedBytes.)

4. Snapshots whose parent volume is gone

Snapshots outlive everything: the volume gets deleted, the AMI gets deregistered, the snapshot stays. Rate: $0.05/GB-month on the standard tier.

live=$(aws ec2 describe-volumes --query 'Volumes[].VolumeId' --output text)
aws ec2 describe-snapshots --owner-ids self \
  --query 'Snapshots[].[SnapshotId,VolumeId,VolumeSize]' --output text |
awk -v live="$live" '
  BEGIN {n=split(live, a, /[ \t]+/); for (i=1;i<=n;i++) alive[a[i]]=1}
  !($2 in alive) {orph++; gb+=$3}
  END {printf "Orphaned snapshots: %d, ~%d GB = up to $%.2f/month\n", orph, gb, gb*0.05}'
Enter fullscreen mode Exit fullscreen mode

Two honesty notes. Snapshots are incremental, so size × $0.05 is an upper bound; the EBS:SnapshotUsage line on your bill is the exact truth. And before deleting, check nothing still references the snapshot: aws ec2 describe-images --owners self --filters Name=block-device-mapping.snapshot-id,Values=snap-xxxx.

5. NAT gateways routing nothing

$32.85/month each, plus $0.045 per GB processed. The zombie variant: the VPC's workloads moved or died, the NAT gateway didn't.

aws ec2 describe-nat-gateways --filter Name=state,Values=available \
  --query 'NatGateways[].NatGatewayId' --output text |
wc -w | awk '{printf "NAT gateways: %d = $%.2f/month before a single GB\n", $1, $1*32.85}'
Enter fullscreen mode Exit fullscreen mode

Triage the list with the same CloudWatch pattern as #3 (namespace AWS/NATGateway, metric BytesOutToDestination ≈ 0 over 7 days). And a half-zombie worth knowing: S3 and DynamoDB traffic flowing through a NAT gateway is paying $0.045/GB for a path that a gateway VPC endpoint provides free.

6. Storage behind stopped instances

"I stopped it" is not "I stopped paying." A stopped instance stops billing compute, but every attached volume keeps billing at full rate, and so does its Elastic IP (see #2).

ids=$(aws ec2 describe-instances --filters Name=instance-state-name,Values=stopped \
  --query 'Reservations[].Instances[].InstanceId' --output text)
[ -n "$ids" ] && aws ec2 describe-volumes \
  --filters Name=attachment.instance-id,Values="$(echo $ids | tr ' \t' ',,')" \
  --query 'Volumes[].[VolumeType,Size]' --output text |
awk '{s+=$2} END {printf "Storage behind stopped instances: %d GB = $%.0f-%.0f/month\n", s, s*0.08, s*0.10}'
Enter fullscreen mode Exit fullscreen mode

This one is cross-cloud in the worst way: an Azure VM that's deallocated keeps billing its managed disks and Standard public IP, and a stopped GCP VM keeps billing its persistent disks. Every cloud lets you switch off the meter you can see while three smaller meters keep spinning.

7. Databases nobody connects to

The most expensive zombie per head. A db.t3.medium is ~$50/month, a db.m5.large ~$125, an r5.xlarge ~$365, all single-AZ. Multi-AZ doubles it. A zero-connection Multi-AZ m5.large is $250/month of pure zombie.

for db in $(aws rds describe-db-instances \
    --query 'DBInstances[].DBInstanceIdentifier' --output text); do
  conns=$(aws cloudwatch get-metric-statistics --namespace AWS/RDS \
    --metric-name DatabaseConnections --dimensions Name=DBInstanceIdentifier,Value="$db" \
    --start-time "$(date -u -d '14 days ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
    --period 1209600 --statistics Maximum --query 'Datapoints[0].Maximum' --output text)
  case "$conns" in None|0|0.0) echo "$db: 0 connections in 14 days";; esac
done
Enter fullscreen mode Exit fullscreen mode

Fourteen days, not one: a database that's only touched by a weekly job looks dead on any shorter window. That's also the caveat for this whole category: idle needs a time series, not a describe call. Unattached is a fact; idle is a judgment.

The long tail

Smaller meters, same pattern: abandoned S3 multipart uploads bill as storage but never appear in the console's object listing (aws s3api list-multipart-uploads --bucket <b>; the free fix is a lifecycle rule with AbortIncompleteMultipartUpload). CloudWatch dashboards beyond the free three are $3/month each, and alarms watching deleted resources are $0.10/month each, trivial until you find nine hundred of them. An EKS cluster with no nodes still bills its control plane at $73/month. Every one of these has the same two properties: a known rate, and zero users.

Run it everywhere, then add it up

All of the above is per-region, per-account. Wrap anything in:

for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  echo "== $r"
  aws ec2 describe-volumes --region "$r" --filters Name=status,Values=available \
    --query 'length(Volumes)' --output text
done
Enter fullscreen mode Exit fullscreen mode

…and repeat per profile for each account. Then put the numbers in one place. A typical sweep of a mid-size, few-years-old account lands somewhere like this:

Zombie Found $/month
Unattached volumes 34 (2.1 TB mixed gp2/gp3) $180
Idle public IPs 11 $40
Load balancers, 0 requests 4 $66
Orphaned snapshots 640 GB up to $32
NAT gateways, no traffic 2 $66
Storage behind stopped instances 1.4 TB $115
RDS, 0 connections (db.m5.large) 1 $125
Total ≈ $624/month (~$7,500/year)

Your numbers will differ; that's exactly why the commands print dollars instead of IDs. An afternoon of copy-paste, zero performance risk, and you walk into the next planning meeting with a single figure instead of seven lists.

What the afternoon doesn't solve

Three honest limits of the CLI sweep:

  1. Zombies are a flow, not a stock. Delete everything today and the same processes (hurried terminations, abandoned experiments, per-region blindness) grow the graveyard back. The sweep you ran in March says nothing about June.
  2. Idle detection needs history. Unattached volumes are a one-liner; idle databases, low-traffic load balancers, and GPU endpoints need 14-30 days of metrics and an uptime gate before "idle" is a safe verdict. That's a pipeline, not a command.
  3. The dollar has to be right. List prices drift per region, licensing and Multi-AZ standby costs are fiddly, and a wrong number in front of finance burns the whole method's credibility.

AWS's own free helpers cover slices of this: Trusted Advisor's idle checks (on Business support), Compute Optimizer, and GCP's Recommender. Each is per account, per console, with its own format and no combined dollar figure.

This is the actual problem ZopNight exists for: the same hunt, running continuously across AWS, GCP, and Azure, with a monthly dollar on every finding. It also reaches the zombies the commands above can't: abandoned multipart uploads, idle dashboards and alarms, GPU-idle endpoints, deallocated Azure VMs still paying for disks. It waits for 30 days of metrics before calling anything idle, and if it can't verify a price it abstains rather than print a number it can't stand behind. The fix applies right from the finding.

If you'd rather run the commands above on a monthly calendar reminder, genuinely, that works too. The only wrong option is the bookmarked list.

FAQ

What is a zombie resource in cloud computing?

A resource that still bills but has no consumer: an unattached volume, an idle IP address, a load balancer with no requests, a snapshot whose parent volume is gone, a database with zero connections. They accumulate because deletion is scarier than paying, cleanup has no owner, and consoles show one region at a time.

Do stopped instances still cost money?

Yes. Stopping an EC2 instance stops compute billing, but attached EBS volumes bill at full rate and an associated Elastic IP starts billing the idle rate. Azure deallocated VMs keep billing managed disks and Standard public IPs; stopped GCP VMs keep billing persistent disks. The only free stopped instance is a terminated one (once its volumes and snapshots are dealt with).

How much does an unattached EBS volume cost?

gp3 is $0.08 per GB-month and gp2 is $0.10 (us-east-1), so a forgotten 500 GB gp2 volume is about $50/month, which is $600/year for storage nothing can read. io1/io2 volumes also keep billing their provisioned IOPS while unattached.

Is it safe to delete unattached volumes, IPs, and snapshots?

With a ritual, yes. For volumes: snapshot first ($0.05/GB-month is cheap insurance), tag with a delete-after date, then delete. For snapshots: check AMI references before deleting. For Elastic IPs: confirm no DNS record still points at the address, because releasing an IP that's still in someone's zone file hands your traffic to a stranger.

What free tools find unused AWS resources?

Trusted Advisor's idle-resource checks (requires Business/Enterprise support), Compute Optimizer for over-provisioning, and Cost Explorer's resource views. Each covers a slice, per account, and none prints a combined monthly dollar figure, which is why the commands in this post exist.

How often should you sweep for zombies?

Monthly at minimum; the population regrows through normal engineering activity. Teams that only sweep after a billing shock typically find 6-12 months of accumulation. The alternative to the calendar reminder is continuous scanning with a priced finding per resource, which is the job tools like ZopNight do.

Top comments (0)