← Back to homeaws

AWS Cost Engineering: Graviton, Spot, and the Discipline of Not Overpaying

← All writing

Nobody decides to overspend on AWS. It happens one reasonable-looking default at a time: an instance sized "to be safe," a staging environment that runs weekends, a NAT gateway quietly metering every byte your private subnets pull from the internet. Six months later the bill has doubled and no single line item looks wrong.

We build and operate web products for clients, which means we regularly inherit AWS accounts we didn't set up. The pattern is remarkably consistent: the bill is not high because someone made a big mistake. It is high because nobody was assigned to care, and AWS's defaults are tuned for availability and convenience, not thrift. The good news is that cloud cost is a tractable engineering problem — observable inputs, a small number of high-leverage interventions, a feedback loop measured in days. You don't need a FinOps department; you need an afternoon a month and the discipline to work the levers in the right order. This post is the checklist we actually use.

Illustration: server-shaped hot-air balloons, several gently deflating at night

Why cloud bills creep

Before optimizing anything, it helps to understand the failure modes. Almost every inflated bill we've reviewed traces back to four of them.

Unbounded defaults

AWS defaults are generous because generous defaults produce fewer support tickets. Volumes provisioned larger than needed, CloudWatch Logs groups with no retention policy (the default is never expire), DynamoDB tables on provisioned capacity nobody revisits, Lambda functions with more memory than the workload uses. None of these fail loudly; they just accrue. The most common single offender we see is log groups, which grow monotonically until someone notices.

# Find log groups with no retention policy set
aws logs describe-log-groups \
  --query "logGroups[?!retentionInDays].[logGroupName, storedBytes]" \
  --output table

Setting retention to 30 or 90 days on non-audit logs is a five-minute change that stops a permanently growing line item.

Orphaned resources

Resources outlive the things that created them: unattached EBS volumes from terminated instances, old snapshots, idle Elastic IPs (which AWS charges for precisely because they're idle), load balancers pointing at empty target groups.

# Unattached EBS volumes — billed in full, doing nothing
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query "Volumes[].[VolumeId, Size, VolumeType, CreateTime]" \
  --output table

Orphans are pure waste — there is no trade-off analysis, just deletion. A quarterly sweep is enough.

Data transfer surprises

Compute and storage are priced legibly. Data transfer is not. Cross-AZ traffic between your services, egress to the internet, traffic through managed services that meter per gigabyte — these show up scattered across the bill under names that don't map to any architecture diagram. Teams routinely discover that a chatty service mesh, an unproxied image endpoint, or a replication job is one of their largest costs, invisible because nobody's mental model included "bytes crossing a boundary" as a billable event.

The NAT gateway tax

NAT gateways deserve their own entry because they're the canonical creep mechanism. You pay per hour per gateway and per gigabyte processed — in both directions. A private subnet pulling container images, calling third-party APIs, or (worst of all) talking to S3 through the NAT gateway pays a per-gigabyte toll for traffic that never needed metering.

The fixes are old but chronically unapplied:

  • Gateway VPC endpoints for S3 and DynamoDB are free. There is essentially no reason not to have them in every VPC; traffic to those services bypasses NAT entirely.
  • Interface endpoints (PrivateLink) have an hourly cost, so they only pay off past a traffic threshold — but for anything pulling ECR images constantly, they usually do.
  • One NAT gateway per AZ vs one total is an availability/cost trade-off; know which you chose and why.

The big levers, in rough order of impact

Not all optimizations are equal. Worked in this order, the first two or three usually capture most of the available savings.

LeverEffortTypical impactRisk
Right-sizing + Graviton migrationMediumOften the largest compute winLow (with testing)
Spot for stateless/batchMediumDeepest per-instance discount on EC2Medium (interruption handling)
Savings Plans / RIsLowMeaningful on steady-state baselineCommitment risk
Storage tiering (S3 lifecycle, gp2→gp3)LowCompounds over timeVery low
Non-prod schedulingLowProportional to idle hoursVery low

Right-sizing and Graviton

Start with right-sizing, because everything downstream — Spot bids, Savings Plan commitments — is anchored to instance size. AWS Compute Optimizer is free and gives per-instance recommendations from actual utilization; most fleets we review are provisioned for a launch-day traffic estimate that never materialized.

Then Graviton. AWS's ARM-based chips (the current instance families are the 7g and 8g generations — m8g, c8g, r8g and friends) deliver meaningfully better price-performance than comparable x86 instances, and by now the ecosystem objections are mostly historical: official ARM images exist for every mainstream runtime, and multi-arch Docker builds are one docker buildx flag.

The honest checklist for a Graviton move:

  1. Managed services first. Switching RDS or ElastiCache to a Graviton instance class is low-risk and requires no application changes.
  2. Lambda next. Setting the architecture to arm64 is one field, and it's cheaper per millisecond.
  3. Containers after that. Build multi-arch images, canary on ARM nodes, watch metrics, shift traffic.
  4. The long tail last. Native dependencies or vendor agents occasionally block a service. Skip it and move on — you don't need 100% coverage to capture most of the benefit.

Spot for stateless and batch

Spot instances are spare EC2 capacity sold at a steep discount — the deepest available on EC2 — with the caveat that AWS can reclaim them with a two-minute warning. That caveat scared people for years, but interruption rates for many pools are low and the tooling is mature. The engineering rule is simple: Spot is for workloads that can die mid-task without anyone caring — CI runners, batch jobs, queue workers, rendering, ETL, dev/staging environments. Kubernetes and ECS both handle the choreography: a node gets the interruption notice, gets drained, work reschedules elsewhere.

Two practices make Spot boring instead of exciting:

  • Diversify instance types. Give the allocator a wide menu (price-capacity-optimized allocation strategy across several families and sizes) — interruptions correlate within a pool, not across pools.
  • Keep a baseline on-demand. Mixed-instances policies in Auto Scaling groups (or Karpenter's capacity-type weighting on Kubernetes) let a small on-demand floor carry the base load while Spot handles the elastic remainder.

What Spot is not for: your primary database, stateful singletons, anything where a two-minute eviction means data loss.

Savings Plans vs Reserved Instances — and when not to commit

Once the fleet is right-sized and partially on Spot, commit to the baseline that remains. The options, in order of flexibility:

  • Compute Savings Plans — a dollars-per-hour commitment applying across instance families, regions, Fargate, and Lambda. Maximum flexibility, smallest discount of the committed options.
  • EC2 Instance Savings Plans — deeper discount, locked to an instance family in a region.
  • Reserved Instances — the older mechanism, still what you use for RDS, ElastiCache, OpenSearch, and Redshift, where Savings Plans don't apply.

Our default is Compute Savings Plans for compute — architecture changes (including Graviton migrations; the plan follows you) shouldn't strand a commitment — and RIs where Savings Plans don't reach.

More important is knowing when not to commit:

  • Before right-sizing. Committing to an oversized baseline locks in the waste. This is the sequencing mistake we see most.
  • Before a platform change. If a Kubernetes migration, a serverless rewrite, or a region move is on the roadmap, wait.
  • On volatile workloads. Commit to the floor you're confident about — a partial commitment with on-demand overflow beats an aggressive one you can't use.
  • One-year over three-year by default. The three-year discount is deeper, but for a product that might pivot, the flexibility is usually worth more than the delta.

Storage tiering: S3 lifecycle and gp2 → gp3

Storage optimizations are the closest thing to free money on this list because they carry almost no risk.

gp2 → gp3 first. gp3 volumes are cheaper per gigabyte than gp2 and decouple performance from size — you get a solid IOPS and throughput baseline regardless of volume size, instead of gp2's size-scaled IOPS. The migration is an online modify-volume operation with no downtime:

aws ec2 modify-volume --volume-id vol-0abc123def456 --volume-type gp3

In Terraform, it's a one-line change (plus explicit performance settings if you need more than baseline):

resource "aws_ebs_volume" "app_data" {
  availability_zone = "ap-south-1a"
  size              = 200
  type              = "gp3" # was gp2
  throughput        = 250   # MB/s, optional above baseline
  iops              = 4000  # optional above baseline

  tags = {
    Project     = "client-webapp"
    Environment = "production"
  }
}

Sweep the whole account; there is rarely a reason for gp2 to exist anymore.

S3 lifecycle rules next. Most buckets accumulate objects whose access pattern decays fast: logs, exports, build artifacts, user uploads past their active window. Transition them down the storage classes and expire what has a natural end of life:

resource "aws_s3_bucket_lifecycle_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    id     = "tier-then-expire"
    status = "Enabled"

    filter {
      prefix = "app-logs/"
    }

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }

    transition {
      days          = 90
      storage_class = "GLACIER_IR"
    }

    expiration {
      days = 365
    }

    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }
}

Two footnotes that bite people: infrequent-access tiers have minimum storage durations and a minimum billable object size, so tiering millions of tiny objects can backfire; and abort_incomplete_multipart_upload belongs on every bucket, because failed multipart uploads are invisible in the console but fully billed. For genuinely unpredictable access patterns, S3 Intelligent-Tiering automates the decision for a small per-object monitoring fee.

Turn off non-prod out of hours

A staging environment that runs 24/7 is billed for roughly four times the hours anyone uses it. Scheduling non-prod off overnight and on weekends is unglamorous and effective: two EventBridge Scheduler rules invoking stop/start (or scaling an ASG/ECS service to zero) against a tag filter, or AWS's packaged Instance Scheduler solution if you'd rather not roll your own. RDS instances can be stopped too — with the caveat that AWS restarts them after seven days, so the scheduler needs to re-stop them. The only hard part is cultural: someone will complain the first morning staging is cold. Add a wake-up button (a Lambda behind a Slack command works fine) and the complaints stop.

Architecture-level savings

The levers above optimize the stack you have. The bigger, slower wins come from changing what the stack is.

Serverless vs always-on is a utilization question, not a fashion question. A service that's busy a few minutes per hour is a strong Lambda/Fargate candidate: you pay for execution, not existence. A service under sustained load inverts the math — always-on instances with a Savings Plan beat paying serverless premiums around the clock. Plot your utilization; the answer usually falls out.

Cache at the edge. Every request served by CloudFront is a request your origin didn't compute and — the underrated part — origin egress you didn't pay for at internet rates, since transfer from AWS origins to CloudFront is free. Caching static assets is table stakes; the real wins are semi-dynamic responses (product pages, API responses with short TTLs and stale-while-revalidate semantics) so the origin fleet can shrink.

Egress-aware design means treating "bytes crossing a billing boundary" as a first-class architectural constraint. Keep chatty services in the same AZ where the availability model allows it. Compress payloads between services, not just to browsers. Serve media through a CDN, never directly from S3 or an instance. And if you routinely ship large volumes to end users, know that some competing object stores price egress at zero — an argument worth being able to make before your traffic makes it for you.

FinOps-lite for a small team

You don't need a FinOps team. You need three artifacts and a recurring calendar slot.

A tagging policy you actually enforce. Three tags cover most needs: Project, Environment, Owner. Activate them as cost allocation tags in the Billing console (they don't apply retroactively — do it early) and enforce them via default_tags on the Terraform provider so nothing ships untagged. The goal: any line in the bill answers "whose is this?" in one query.

Budgets and anomaly alerts. AWS Budgets gives you threshold alerts; Cost Anomaly Detection is free and catches what budgets miss — the sudden step-change from a runaway job or a misconfigured retry loop. Set both up in the first hour of owning an account:

aws ce create-anomaly-monitor \
  --anomaly-monitor '{
    "MonitorName": "service-monitor",
    "MonitorType": "DIMENSIONAL",
    "MonitorDimension": "SERVICE"
  }'

A monthly cost review. Thirty minutes, one person. Cost Explorer grouped by service, then by the Project tag. Three questions: what grew, why, and was it supposed to? Most months the answer is boring. The discipline is showing up for the boring months so the interesting one gets caught early.

Tooling

Cost Explorer answers most questions interactively and via CLI:

aws ce get-cost-and-usage \
  --time-period Start=2026-05-01,End=2026-06-01 \
  --granularity MONTHLY \
  --metrics "UnblendedCost" \
  --group-by Type=DIMENSION,Key=SERVICE

CUR + Athena is the next tier. The Cost and Usage Report (now delivered via Data Exports) is the ground truth — every resource-hour, every byte, queryable with SQL once it lands in S3 with an Athena table over it. You reach for it when Cost Explorer's aggregations can't answer "which specific NAT gateway processed those terabytes." Enable it now, before you need it; the report itself is free and the S3 storage is trivial.

Infracost in CI moves cost review to where decisions actually happen: the pull request. It reads your Terraform diff and comments with the monthly cost delta, turning "that RDS instance was three sizes too big" from a month-end surprise into a code-review comment:

# .github/workflows/infracost.yml
on: [pull_request]
jobs:
  infracost:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: infracost/actions/setup@v3
        with:
          api-key: ${{ secrets.INFRACOST_API_KEY }}
      - run: infracost breakdown --path=. --format=json --out-file=/tmp/infracost.json
      - run: |
          infracost comment github \
            --path=/tmp/infracost.json \
            --repo=$GITHUB_REPOSITORY \
            --pull-request=${{ github.event.pull_request.number }} \
            --github-token=${{ github.token }} \
            --behavior=update

Add Compute Optimizer and Trusted Advisor's cost checks, and you've covered the tooling a small team realistically needs.

A worked example: reviewing a typical web-app stack

Here's the shape of a review we'd run on a common setup: a containerized web app on ECS with an ALB, RDS Postgres, ElastiCache Redis, S3 for uploads, CloudFront in front, and a staging environment mirroring production.

  1. Orient (30 min). Cost Explorer, last three months, grouped by service. Identify the top five line items — typically EC2/Fargate, RDS, data transfer, NAT gateway, and S3, in some order. Anything surprising in the tail (an old Elasticsearch domain, a forgotten EIP) goes on the kill list.
  2. Compute (half a day). Compute Optimizer on the ECS hosts and RDS. The app tier is usually sized for a launch estimate and runs at low utilization — downsize first. Then flip RDS and ElastiCache to Graviton instance classes, and put multi-arch container builds on the backlog.
  3. The NAT gateway line (an hour). Almost always inflated. Add the free S3 gateway endpoint; if image pulls are frequent, add interface endpoints for ECR and CloudWatch Logs. Confirm nothing in a private subnet is calling S3 the long way around.
  4. Storage (an hour). modify-volume every gp2 to gp3. Lifecycle rules on the uploads and log buckets. Retention on every log group. Abort-incomplete-multipart on every bucket.
  5. Staging (an hour). Schedule it off nights and weekends, and downsize it — staging almost never needs production-sized databases.
  6. Commit (30 min, two weeks later). Once the new baseline is visible, buy a one-year Compute Savings Plan covering the confident floor of compute spend, and RIs for the now-right-sized RDS and ElastiCache instances.
  7. Instrument. Tags enforced in Terraform, budgets and anomaly detection on, Infracost in CI, monthly review on the calendar.

Roughly two days of engineering, and the result is durable: the recurring machinery in steps 6–7 keeps the bill from re-inflating.

False economies

Cost work has its own failure mode: optimizing the wrong things. A few we've learned to refuse.

Chasing pennies with senior hours. An engineer-day spent shaving a trivial line item never pays back. Sort the bill descending and stay in the top five items; ignore anything that costs less per month than the meeting about it.

Spot for stateful services. The discount is real; so is the incident when your database node gets a two-minute eviction notice. One bad Spot decision can erase a year of savings.

Aggressive three-year commitments on a young product. Locking in maximum discounts on an architecture that's six months old is betting against your own roadmap. Commit to what you're sure of; rent the rest.

Tiering tiny objects to Glacier. Minimum object sizes, minimum storage durations, and retrieval costs can make cold storage more expensive for millions of small files. Aggregate first, tier second.

Building cost dashboards before turning off obvious waste. We've seen teams spend weeks on bespoke tooling while unattached volumes and 24/7 staging environments burned in the background. Delete the waste first; the dashboard can wait.

The meta-rule: cloud savings are only savings net of the engineering time spent capturing them. Treat every optimization as a small project with an ROI estimate, and be genuinely willing to conclude "not worth it."

Takeaways

  • Bills creep through defaults, orphans, and data transfer — not big mistakes. Audit for all three before optimizing anything.
  • Work the levers in order: right-size, migrate to Graviton, move interruptible work to Spot, then commit with Savings Plans. Committing before right-sizing locks in waste.
  • Prefer Compute Savings Plans over instance-locked commitments; default to one-year terms on anything that might change.
  • gp2→gp3, S3 lifecycle rules, log retention, and multipart-upload cleanup are near-zero-risk. Do them everywhere, once, this week.
  • Gateway VPC endpoints for S3 and DynamoDB are free. Every VPC with a NAT gateway should have them.
  • Schedule non-prod off out of hours; add a wake-up button so nobody resents it.
  • FinOps-lite is enough for a small team: three enforced tags, budgets plus anomaly detection, Infracost in CI, a monthly thirty-minute review.
  • Respect the false economies: no Spot for stateful services, no penny-chasing with senior hours, no dashboards before the obvious waste is gone.

Enjoyed the read? We build this stuff for clients too.

Start a project