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.
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_ratioFROM `billing_export.gcp_billing_export_v1`GROUP BY serviceORDER 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.
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.
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.
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.
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 instanceaws ec2 describe-addresses \ --query 'Addresses[?AssociationId==null].{IP:PublicIp,Alloc:AllocationId}' \ --output table# Old snapshots owned by this accountaws ec2 describe-snapshots --owner-ids self \ --query 'sort_by(Snapshots,&StartTime)[].{ID:SnapshotId,GiB:VolumeSize,When:StartTime}' \ --output tableNone 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.
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 choice | How it shows up on the bill |
| --- | --- |
| Chatty service-to-service calls across zones | Inter-AZ / egress data transfer |
| No caching layer, DB as first responder | Oversized DB instance + high read IOPS |
| Polling instead of events / webhooks | Sustained compute + request charges that never sleep |
| Storing everything, deleting nothing | Storage that grows forever, plus backup multiplier |
| Synchronous work that should be a queue | Over-provisioned compute to absorb spikes |
| Logging at DEBUG in production | Log ingestion + storage as a top-5 line item |
| Fan-out reads with no pagination | Explosive read units on Firestore / DynamoDB |
| N+1 queries behind an ORM | Read 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.
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:
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 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.
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.
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:
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.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 tagslocals { required_tags = { service = var.service env = var.env owner = var.owner }}# Fail the plan if any required tag is blankresource "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.
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.
service, env, owner) at provisioning time, and glance at one dashboard monthly. Treat every big number as a design question, not a discount to chase.