devShakib

Loading devShakib…

The Cloud Bill Is a Design Review in Disguise

Cloud cost optimization is architecture review in disguise. Read your AWS or GCP bill like a flame graph: spot egress, idle capacity, and design smells fast.

The bill tripled over a weekend and I nearly turned off logging to fix it. That was the wrong instinct, and it's the instinct almost everyone reaches for first when a cloud cost spike lands on a Tuesday morning.

Here's what actually happened. We'd shipped a small feature the Friday before, nothing dramatic. By Tuesday the daily spend was roughly 3x. My first move was to open the billing console and start hunting for a knob to turn down. Reserved instances? A cheaper storage tier? Kill the logs?

None of that was the fix. The fix was three lines of code that stopped us from re-fetching the same blob on every request. The bill wasn't a pricing problem. It was a design review, written in the only language finance and engineering both understand: money. Once I started reading invoices that way — as feedback on my architecture rather than a spreadsheet to trim — the cost problems mostly solved themselves, because they were never really cost problems. That reframe is the whole of what people now call FinOps, minus the branding: cloud cost optimization is architecture review with a dollar sign attached.

Reading your cloud bill like a profiler reads a flame graph

When you profile a slow endpoint, you don't stare at the total latency and despair. You look at where the time went. Which function dominates the flame graph? What's called a million times when it should be called once? The number at the top is a symptom; the shape underneath is the diagnosis.

A cloud bill is exactly the same, and almost nobody treats it that way. The invoice total is the p50 latency of your architecture. Useless on its own. The line items are the flame graph.

So the first move in any cost analysis is always the same: stop looking at the total and start looking at the distribution. Group spend by service, then by usage type, then by resource. On AWS this is the Cost and Usage Report; on GCP it's the billing export to BigQuery. What I'm hunting for isn't "the expensive thing." It's the surprising thing — the line item whose size doesn't match my mental model of the system.

That last one is the tell I care about most. A bill that grows in a shape you didn't design is the bill telling you your architecture has a behavior you didn't intend. The dollar figure is just the messenger.

A concrete habit: I pull the cost-and-usage data and sort by rate of change, not absolute size. The biggest line item is usually load-bearing and expected. The line item that doubled month-over-month is the one hiding a design bug. If your billing data lives in BigQuery, that sort is one query:

-- Which services are growing fastest month over month?
SELECT
  service.description AS service,
  SUM(IF(invoice.month = '202606', cost, 0)) AS last_month,
  SUM(IF(invoice.month = '202607', cost, 0)) AS this_month,
  SAFE_DIVIDE(
    SUM(IF(invoice.month = '202607', cost, 0)),
    NULLIF(SUM(IF(invoice.month = '202606', cost, 0)), 0)
  ) AS growth_ratio
FROM `billing_export.gcp_billing_export_v1`
GROUP BY service
ORDER BY growth_ratio DESC;

The row with the scary growth_ratio is where I start. Absolute size tells you what's expensive; rate of change tells you what's broken.

The usual suspects: egress, idle capacity, and forgotten resources

Before any philosophy, here are the three things that account for most of the "why is this so high" moments I've had in six years of shipping on Firebase, GCP, and AWS. They're boring. Boring is the point — the exotic optimizations get the blog posts, but the boring three get the money back.

1. Egress — the tax on chatty architecture

Data leaving a cloud provider, or crossing a region or availability-zone boundary, is priced to hurt. Most teams never think about data transfer cost until it's 40% of the bill. And egress is almost never a pricing issue — it's a topology issue. A service in one region calling a database in another. A CDN misconfigured so it re-fetches from origin instead of serving cache. A "microservice" boundary drawn straight through a chatty, high-volume data path so every call now crosses the network with a price tag on it. The fix is never a discount; it's moving the two chatty things closer together.

2. Idle capacity — paying for peak while running at trough

A cluster sized for Black Friday, running in February. Three environments (dev, staging, prod) all provisioned like prod. A database with 8 vCPUs sitting at 4% utilization because someone picked the instance size once and never revisited it. Idle capacity is the single most common line item that shrinks the moment you look at it, and the single most common one nobody looks at. Rightsizing tools flag it, but you don't need a tool — a utilization graph flatlining at single digits is the whole diagnosis.

3. Forgotten resources — the archaeology layer

Orphaned volumes from terminated instances. Snapshots retained forever by a policy no one remembers writing. Old load balancers, unattached elastic IPs, a NAT gateway humming away for a project that shipped and got deprecated over a year ago. None of these are large individually. Collectively, on one account I inherited, forgotten resources were about 11% of monthly spend — pure waste, zero users served.

Here's a rough sweep I run when I'm doing archaeology on an unfamiliar AWS account:

# Unattached EBS volumes (available = not attached to anything)
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[].{ID:VolumeId,GiB:Size,Created:CreateTime}' \
  --output table

# Elastic IPs not associated with a running instance
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].{IP:PublicIp,Alloc:AllocationId}' \
  --output table

# Old snapshots owned by this account
aws ec2 describe-snapshots --owner-ids self \
  --query 'sort_by(Snapshots,&StartTime)[].{ID:SnapshotId,GiB:VolumeSize,When:StartTime}' \
  --output table

None of this is clever. It's a broom. But you'd be surprised how much of a "cost crisis" is just a floor that hasn't been swept in a year. Run the sweep, confirm each resource really is orphaned (an "available" volume can still be someone's manual backup), then delete with intent — not in a panic.

Where architecture choices quietly become line items

This is the heart of it. Every architectural decision has a price, and the bill is where that price finally becomes visible — often long after the decision was made, by which point everyone's forgotten it was a decision at all.

Some translations I've learned to read:

Architecture choiceHow it shows up on the bill
Chatty service-to-service calls across zonesInter-AZ / egress data transfer
No caching layer, DB as first responderOversized DB instance + high read IOPS
Polling instead of events / webhooksSustained compute + request charges that never sleep
Storing everything, deleting nothingStorage that grows forever, plus backup multiplier
Synchronous work that should be a queueOver-provisioned compute to absorb spikes
Logging at DEBUG in productionLog ingestion + storage as a top-5 line item
Fan-out reads with no paginationExplosive read units on Firestore / DynamoDB
N+1 queries behind an ORMRead IOPS and connection-pool pressure

That DEBUG-logging row bit me personally. On one project our observability bill quietly climbed past our compute bill. The cause wasn't a pricing change — it was a log.debug inside a hot loop, structured-logging every iteration, shipped and forgotten. We were paying, per month, real money to store the fact that a function ran. The "fix" wasn't a cheaper log tier. It was deleting a log line.

The point of the table isn't the table. It's the reflex: when a line item is big, ask what decision made it big? — not what's the cheapest version of this line item? The second question keeps the bad architecture and shaves 15%. The first question deletes the line item.

Rightsizing vs. re-architecting: when each one wins

There are two fundamentally different moves when a line item is too big, and confusing them wastes enormous amounts of time.

Rightsizing is turning the same design down to fit actual load. Smaller instance, fewer replicas, a cheaper storage class, a shorter retention window, a committed-use discount or savings plan on the baseline you'll always run. It's tuning. It's safe, fast, reversible, and it's the correct move when the architecture is right but the sizing is lazy.

Re-architecting is changing the design so the line item shrinks or disappears structurally. Add a cache so the DB stops being the first responder. Move a cross-region call in-region. Replace polling with events. Push static assets to a CDN so origin stops serving them. It's more work, more risk, and it's the correct move when the line item is large because the design is wrong.

The trap is reaching for rightsizing on a problem that needs re-architecting. You can rightsize a chronically-overloaded database from an 8xlarge to a 4xlarge and feel clever for a month — right up until traffic grows and you're back to the 8xlarge, now with a cache you still haven't built. You optimized the symptom and left the disease.

My rough decision rule:

  1. Is the line item large but proportional to real work being done? Rightsize it. This is most line items. Don't over-engineer.
  2. Is the line item large relative to the value it produces? Re-architect it. A background job costing more than your API is a design question, not a sizing one.
  3. Is the line item growing faster than your usage? Always re-architect. That's an architecture leak, and rightsizing just changes how fast the boat sinks.

Rightsizing pays once. Re-architecting pays every month forever, and compounds as you scale. But re-architecting also costs engineering time, which is the most expensive line item that never shows up on the invoice. Which brings me to the two sections people skip.

Autoscaling that saves money instead of just moving it around

Autoscaling gets sold as a cost tool. Often it isn't — it's a reliability tool wearing a cost costume, and if you set it up carelessly it can quietly cost you more than static provisioning while feeling responsible.

Autoscaling only saves money when three things are true:

Where it goes wrong:

The version that works is boring: scale on the metric that actually tracks user-facing load (usually not raw CPU — often concurrency or queue depth), set a sane minimum matched to real off-peak traffic, and use scheduled scaling for predictable patterns instead of pretending everything is reactive. If your traffic drops every night at 2am like clockwork, a schedule beats a reactive policy every time — it doesn't wait for the metric to prove what you already know.

And the highest-leverage autoscaling decision of all: scaling non-prod to zero overnight and on weekends. Dev and staging don't need to exist at 3am on a Saturday. On serverless platforms this is free by default — one reason a scale-to-zero runtime like Cloud Run or Lambda is often cheaper for spiky, low-baseline workloads than a cluster you keep warm. For anything with a fixed floor, a shutdown schedule is frequently a bigger, safer win than anything you'll do in production.

The savings that aren't worth your engineering time

Every cost discussion eventually meets the same tempting trap: a 3% saving that takes two weeks of senior engineering time to capture, needs ongoing babysitting, and adds a layer of complexity that will confuse the next person who reads the system.

I've done this. I once spent the better part of a sprint building a clever spot-instance orchestration to shave a compute bill, and the maintenance burden plus the occasional 3am reliability surprise cost more — in engineer-hours and stress — than the money it saved. It was, in hindsight, a hobby I billed to the company.

So I put a filter on every optimization now:

The honest rule: engineering time is your most expensive resource, and it doesn't appear on the AWS invoice, so it's the easiest cost to forget. A savings idea has to clear the bar of "worth more than what else this engineer could build this week." Most don't. Fix the three big structural things, sweep the forgotten resources, and then stop. Diminishing returns arrive fast, and past that point you're optimizing your ego, not your bill.

Cost guardrails: budget alerts, anomaly detection, and tagging you'll actually maintain

The best cost optimization is the one you never have to do because the system told you early. But guardrails only work if they're the kind you'll actually keep, not the elaborate governance framework you set up once and quietly abandon.

What I actually maintain, in order of value:

  1. **A budget alert on anomalous change, not just an absolute threshold.** An absolute cap ("alert at $5,000") tells you nothing until you're near the cliff. An alert on "spend is up 30% week-over-week" catches the regression the day it ships — which is exactly when it's cheap to fix, because the offending change is still fresh in everyone's memory. AWS Cost Anomaly Detection and GCP budget alerts both do this; wire them to Slack, not email, or they'll be ignored.
  2. Tagging, but a schema simple enough to survive. Everyone designs a beautiful 12-dimension tagging taxonomy. Nobody maintains it. I use three tags: service, env, and owner. That's it. Three tags that are actually populated beat twelve that are half-empty and lying to you. Enforce them at provisioning time (Terraform, policy-as-code) or they will not exist.
  3. Untagged spend as its own alert. If untagged resources climb above a small percentage of the bill, that's the signal your discipline is slipping. It's a leading indicator of the archaeology layer forming again.
  4. One dashboard, reviewed on a cadence. Spend by service and env, month over month. I look at it monthly. Not because I love spreadsheets — because five minutes a month catches things that would otherwise become a Tuesday-morning tripling.

Enforce those three tags in Terraform so an untagged resource simply can't be created:

# A locals block every module merges into its resource tags
locals {
  required_tags = {
    service = var.service
    env     = var.env
    owner   = var.owner
  }
}

# Fail the plan if any required tag is blank
resource "null_resource" "tag_gate" {
  lifecycle {
    precondition {
      condition     = alltrue([for v in values(local.required_tags) : v != ""])
      error_message = "service, env, and owner tags are all required."
    }
  }
}

The meta-point: a guardrail you won't maintain is worse than no guardrail, because it gives you false confidence. Design your cost controls for the tired, busy version of yourself, not the motivated one setting them up.

A month-by-month teardown of a real cloud bill I cut in half

Let me make this concrete. Numbers are lightly rounded and anonymized, but the shape is real — a backend I inherited that was costing roughly $4,200/month and, everyone agreed, "just costs that because we're growing."

It wasn't growth. It was three design smells wearing a growth costume.

Month 0 — the diagnosis. I grouped the bill by service and rate of change instead of size. Three things jumped out:

Three line items, three architecture questions.

Month 1 — the egress smell. The API and its object storage had drifted into different regions during an earlier migration, so every asset fetch crossed a region boundary with an egress charge. On top of that, the CDN was set to no-cache for a class of responses that were, in fact, perfectly cacheable. Moving storage back in-region and fixing the cache headers took two days and cut egress by roughly 70%. No pricing negotiation. A topology fix and a caching header.

Month 2 — the database smell. The DB was huge because it was doing all the work: every request hit it directly, including a handful of hot, rarely-changing lookups fetched thousands of times a minute. We put a small cache in front of the hottest reads. Read IOPS collapsed, the instance was suddenly oversized for real rather than defensively, and we rightsized it down two tiers. The re-architecture (cache) enabled the rightsizing (smaller instance) — in that order. Doing the rightsizing first would have just caused an outage.

Month 3 — the logging smell and the broom. We killed DEBUG logging in production (a one-line change plus a retention policy), which alone knocked the observability line item down by more than half. Then I ran the archaeology sweep: unattached volumes, an orphaned NAT gateway from a dead service, snapshots retained since the dawn of time. Cleanup, no code.

The end state was roughly $2,050/month — a hair under half — and, more importantly, an architecture that no longer grew its bill in a shape nobody designed. Every one of those wins was a design review that happened to be denominated in dollars. The invoice found the bugs my code review missed, because the invoice runs in production and never lies about what the system actually does.

Key takeaways