Earlier I built the skeleton of costsweep, a Go CLI that finds wasted AWS spend, and wired up the Cost Explorer rightsizing scanner. That one's the heavyweight: it finds idle and oversized EC2 instances and comes with AWS's own dollar estimates. It has two blind spots, though. It covers only EC2, and it sees only instances with two weeks of CloudWatch history behind them.
The waste it misses is the boring, certain kind: an EBS volume that outlived its instance, an Elastic IP attached to nothing, a snapshot from a project that shipped a year ago. None show up in rightsizing, and AWS won't hand me a price for them, so I compute it myself. That's this post: three EC2 scanners and a pricing table small enough to audit by eye. By the end, the running total climbs from "the big instances" to the full $4,204/year.
The Problem with Pricing It Yourself
For rightsizing I cheated: I passed through AWS's savings numbers. For a detached volume there's no such number. AWS knows the volume costs $50/month, but no convenient API tells me; it appears, undifferentiated, in the storage line of the bill.
There is a Pricing API (service/pricing), and I looked hard at it. The trouble: it lives only in us-east-1, returns a ~30MB JSON blob per service that you filter client-side, and needs its own IAM permission. For the four or five resource types this tool flags, that's a lot of machinery to learn a handful of rates that change maybe twice a year. So I traded it for a small static price book, checked into the repo, that you can read and verify against the public pricing page yourself.
// ebsGiBMonth: USD/GiB/mo per EBS type, us-east-1.
var ebsGiBMonth = map[string]float64{
"gp3": 0.08,
"gp2": 0.10,
"io1": 0.125,
"io2": 0.125,
"st1": 0.045,
"sc1": 0.015,
"standard": 0.05,
}
// snapshotGiBMonth: EBS snapshot rate (standard), us-east-1.
const snapshotGiBMonth = 0.05
The Elastic IP rate is the one people forget. Since February 2024 AWS charges $0.005/hour for every public IPv4, attached or not. An idle one is pure waste at roughly 730 hours a month:
// elasticIPMonth: idle IPv4, $0.005/hr since Feb 2024 * ~730 hrs.
const elasticIPMonth = 0.005 * 730 // ≈ $3.65
Regions aren't all priced the same, so a small multiplier nudges the us-east-1 base rates outward. It's approximate on purpose: I want the right order of magnitude for a "should I bother deleting this" decision, not invoice parity:
func multiplier(region string) float64 {
if m, ok := regionMultiplier[region]; ok {
return m
}
return 1.10 // unknown region
}
// EBSMonthly estimates a volume's monthly cost. Unknown type -> gp2 (never 0).
func EBSMonthly(volType string, sizeGiB int, region string) float64 {
rate, ok := ebsGiBMonth[strings.ToLower(volType)]
if !ok {
rate = ebsGiBMonth["gp2"]
}
return rate * float64(sizeGiB) * multiplier(region)
}
The "never price at zero" fallback matters more than it looks. A Finding with MonthlyUSD: 0 is invisible in a report sorted by cost; it would drop a real volume because the type string was unfamiliar. Falling back to gp2, the historical default, over-counts an unknown type instead of dropping it.
Scanner One: Unattached Volumes
This is the easiest waste to verify in AWS. An EBS volume in the available state is attached to nothing; it survived its instance's termination and now bills the full per-GiB rate for storage no process can read. No ambiguity: if it's available, it's detached.
I let the EC2 API do the filtering with a status filter, and use the SDK's paginator so an account with hundreds of orphaned volumes doesn't truncate at the first page:
func (s *UnattachedVolumeScanner) Scan(ctx context.Context) ([]finding.Finding, error) {
var out []finding.Finding
p := ec2.NewDescribeVolumesPaginator(s.Client, &ec2.DescribeVolumesInput{
Filters: []ec2types.Filter{{
Name: aws.String("status"),
Values: []string{string(ec2types.VolumeStateAvailable)},
}},
})
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return nil, err
}
for _, v := range page.Volumes {
size := int(i32(v.Size))
monthly := pricing.EBSMonthly(string(v.VolumeType), size, s.Region)
out = append(out, finding.Finding{
Type: s.Name(),
Resource: str(v.VolumeId),
Region: s.Region,
MonthlyUSD: monthly,
Detail: fmt.Sprintf("%d GiB %s volume, unattached", size, v.VolumeType),
Action: "snapshot if needed, then delete the volume",
Severity: finding.Medium,
})
}
}
return out, nil
}
Those i32 and str helpers handle an AWS SDK reality: almost every field is a pointer, so the SDK can tell "zero" from "absent." I seldom care about that difference, so two tiny helpers collapse the nil checks into one place and keep the scanner body readable:
// deref helpers: SDK fields are pointers; collapse nil to zero.
func str(p *string) string {
if p == nil {
return ""
}
return *p
}
func i32(p *int32) int32 {
if p == nil {
return 0
}
return *p
}
Scanner Two: Idle Elastic IPs
Elastic IPs are cheap, $3.65/month each, which is why they pile up. A $3.65 charge isn't worth anyone's afternoon. But a team that's spun up and torn down environments for a few years collects a dozen, and now it's a real line.
The detection is a judgment call encoded in two pointer checks. An address associated with an instance (AssociationId) or a network interface (NetworkInterfaceId) is doing its job. Anything else is rent on nothing:
func (s *IdleAddressScanner) Scan(ctx context.Context) ([]finding.Finding, error) {
resp, err := s.Client.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{})
if err != nil {
return nil, err
}
var out []finding.Finding
for _, a := range resp.Addresses {
// assoc'd to instance/ENI = in use
if a.AssociationId != nil || a.NetworkInterfaceId != nil {
continue
}
out = append(out, finding.Finding{
Type: s.Name(),
Resource: str(a.PublicIp),
Region: s.Region,
MonthlyUSD: pricing.ElasticIPMonthly(s.Region),
Detail: "allocated Elastic IP associated with nothing",
Action: "release the address (aws ec2 release-address)",
Severity: finding.Low,
})
}
return out, nil
}
DescribeAddresses isn't paginated; it returns everything in one shot, so this one's a plain loop, no paginator.
Scanner Three: Stale Snapshots
Snapshots are the honest scanner, the one where I refuse to overclaim. A detached volume is waste. An old snapshot might be a long-term backup someone meant to keep, so I won't call it waste. I flag it for review at low severity and price it so you can see what the backlog costs.
Two details matter here. First, OwnerIds: ["self"]: leave it out and DescribeSnapshots starts paging through the entire public snapshot catalog, millions of entries. Second, the age cutoff uses an overridable Now, so the test stays deterministic:
// Now is overridable for deterministic snapshot-age tests.
var Now = time.Now
func (s *StaleSnapshotScanner) Scan(ctx context.Context) ([]finding.Finding, error) {
maxAge := s.MaxAge
if maxAge == 0 {
maxAge = 90 * 24 * time.Hour
}
cutoff := Now().Add(-maxAge)
var out []finding.Finding
p := ec2.NewDescribeSnapshotsPaginator(s.Client, &ec2.DescribeSnapshotsInput{
OwnerIds: []string{"self"}, // not public catalog
})
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return nil, err
}
for _, snap := range page.Snapshots {
if snap.StartTime == nil || snap.StartTime.After(cutoff) {
continue
}
ageDays := int(Now().Sub(*snap.StartTime).Hours() / 24)
size := int(i32(snap.VolumeSize))
out = append(out, finding.Finding{
Type: s.Name(),
Resource: str(snap.SnapshotId),
Region: s.Region,
MonthlyUSD: pricing.SnapshotMonthly(size, s.Region),
Detail: fmt.Sprintf("%d GiB snapshot, %d days old", size, ageDays),
Action: "confirm it's not a needed backup, then deregister/delete",
Severity: finding.Low,
})
}
}
return out, nil
}
Making Now a package variable instead of calling time.Now() inline pays off in the test below. Time-dependent logic you can't freeze is logic you can't test without time.Sleep, and tests that sleep get deleted.
Test Output
Taking interfaces in Part 1 buys this: every scanner is testable from a struct literal. The snapshot test freezes time, feeds in one 120-day-old snapshot and one 10-day-old one, and asserts that only the old one survives the cutoff:
func TestStaleSnapshotScannerRespectsAge(t *testing.T) {
fixed := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
Now = func() time.Time { return fixed }
defer func() { Now = time.Now }()
ec2c := &fakeEC2{snapshots: []ec2types.Snapshot{
{SnapshotId: aws.String("snap-old"), VolumeSize: aws.Int32(200), StartTime: aws.Time(fixed.Add(-120 * 24 * time.Hour))},
{SnapshotId: aws.String("snap-new"), VolumeSize: aws.Int32(200), StartTime: aws.Time(fixed.Add(-10 * 24 * time.Hour))},
}}
s := &StaleSnapshotScanner{Client: ec2c, Region: "us-east-1", MaxAge: 90 * 24 * time.Hour}
got, _ := s.Scan(context.Background())
if len(got) != 1 || got[0].Resource != "snap-old" {
t.Fatalf("expected only snap-old, got %+v", got)
}
}
The fakeEC2 it uses is the same one all three resource scanners share, a struct with three slices satisfying the EC2API interface from Part 1:
type fakeEC2 struct {
volumes []ec2types.Volume
addresses []ec2types.Address
snapshots []ec2types.Snapshot
}
func (f *fakeEC2) DescribeVolumes(_ context.Context, _ *ec2.DescribeVolumesInput, _ ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) {
return &ec2.DescribeVolumesOutput{Volumes: f.volumes}, nil
}
// ...DescribeAddresses and DescribeSnapshots likewise
All four scanner tests, plus the pricing tests, run in well under a second with no credentials:
$ go test ./...
ok github.com/rezmoss/costsweep/internal/finding 0.391s
ok github.com/rezmoss/costsweep/internal/pricing 0.821s
ok github.com/rezmoss/costsweep/internal/report 0.596s
ok github.com/rezmoss/costsweep/internal/scan 1.050s
And the pricing assertions read like a sanity check you'd do by hand: 100 GiB of gp3 at $0.08/GiB is $8, and gp2 had better cost more than gp3 or the gp2-to-gp3 migration advice is wrong:
func TestEBSMonthly(t *testing.T) {
if got := EBSMonthly("gp3", 100, "us-east-1"); !almost(got, 8.0) {
t.Errorf("gp3 100GiB = %v, want 8.0", got)
}
if EBSMonthly("gp2", 100, "us-east-1") <= EBSMonthly("gp3", 100, "us-east-1") {
t.Error("expected gp2 to cost more than gp3")
}
}
Next Step
Four scanners now feed Findings into one slice: rightsizing from Cost Explorer, plus unattached volumes, idle IPs, and stale snapshots from EC2. Missing is the part that makes the number land: sorting biggest-first, totaling to that annual figure, rendering it as a table for humans, JSON for dashboards, and Markdown for a PR comment, then a CI gate that fails a build when waste climbs past a threshold. That's next, where the $4,204/year prints.
The full source is on GitHub. costsweep -demo runs all of this on bundled sample data, no AWS account required.

Top comments (0)