What does a Graviton migration actually save, and how should you measure it?

Moving a backend service to AWS Graviton means running it on Arm-based (arm64) EC2 instances instead of x86. Teams do it because Graviton instances usually carry a lower hourly price than comparable x86 instances. A lower hourly price does not guarantee lower cost, though. What you pay for is completed work, so the metric that matters is cost per unit of work at your latency target. A unit of work can be a million requests, a batch job, or a GB processed.

Use this formula:

cost per unit = hourly price ÷ (sustained throughput at target p99 × utilization ceiling × 3600)

Measure throughput at the latency percentile you actually operate to, because peak throughput hides tail-latency collapse. The utilization ceiling is your headroom policy, for example 70%.

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs:

  • x86 price: $1.00/h; arm64 price: $0.80/h
  • x86 throughput: 1,000 req/s at p99
  • Utilization ceiling: 0.7
  • Load: equal to what 10 x86 instances serve today

Every figure in the table below is illustrative. The arm64 throughput values are assumed scenarios, and the other columns are calculated from the assumed inputs above with the formula. None of them are measured results.

Scenario (illustrative) arm64 req/s at p99 (assumed) Cost per 1M requests (calculated) Instances needed (calculated) Fleet $/h (calculated)
x86 baseline n/a $0.397 10 $10.00
arm64, throughput assumed 10% higher 1,100 $0.289 10 $8.00
arm64, throughput assumed 25% lower 750 $0.423 14 $11.20

In the third illustrative row, a 20% lower hourly price still produces a fleet that costs 12% more. The lower assumed throughput forces four extra instances to serve the same load.

Price gaps and throughput ratios differ by Graviton generation, instance family, and region. Re-measure for each workload rather than extrapolating from another team's results.

Why do some workloads run faster or slower on arm64?

On x86 instances, a vCPU is usually one hyperthread, so two vCPUs share one physical core's execution units. On Graviton, each vCPU is a full physical core. A CPU-bound service that keeps every vCPU busy does not lose throughput to SMT contention on Graviton. That is why highly parallel workloads, such as request handlers, stateless APIs, and batch transforms, often gain the most at the same vCPU count. Single-threaded or latency-bound code sees less of this benefit.

The runtime determines how much of that hardware you actually use:

Runtime arm64 maturity Main risk
JVM Mature JIT and intrinsics Older JDKs have fewer arm64 optimizations
Go Native first-class target Assembly fast paths in some packages exist only for amd64
Node.js V8 is well supported Native addons must be rebuilt
Python Interpreter is fine Missing aarch64 wheels force source builds or slower fallbacks
.NET Native arm64 support in modern releases Older frameworks and native P/Invoke libraries

The larger risk is code below the runtime. Libraries with hand-written AVX2 or AVX-512 paths, and no NEON equivalent, fall back to generic scalar C on arm64. Crypto, compression, JSON parsing, image processing, and vector math are the usual cases. If 30% of CPU time sits in such a library and it runs at half speed, the effective slowdown can easily cancel the hourly discount (illustrative estimate).

Before migrating, profile the service on x86. Workloads at risk show hot frames in native extensions, SIMD-heavy libraries, or vendored binaries.

Which services should you migrate first, and when should you avoid it?

Migration effort is mostly a fixed cost per service: auditing dependencies, adding a multi-arch build, updating CI, and running a validation benchmark. Savings scale with the number of instances you move. So the same week of engineering work pays back quickly on a service with dozens of nodes and may never pay back on one running two.

Factor Migrate first Defer
State Stateless, horizontally scaled Stateful, hard to roll back
Runtime JVM, Go, Node.js, Python with pure-code dependencies Heavy C/C++ extensions, hand-tuned x86 SIMD
Fleet size High instance count Small fleet where fixed effort exceeds savings
Third-party binaries All available for arm64 Vendor binaries, APM, security, or observability agents without arm64 builds

Stateless services are the safest starting point. You can run arm64 and x86 replicas side by side, shift traffic gradually, and roll back without migrating data. Managed runtimes hide most of the architecture difference, so the remaining risk sits in the few native packages you can list in an inventory. A missing agent is a hard blocker. Shipping without your security or tracing coverage is not an acceptable trade for a lower hourly rate.

A platform team should own the program. It owns the base images, CI runners, and build pipelines that every service reuses, and that shared work lowers the per-service cost. Service teams then handle validation for their own workloads. Plan the timing around your commitments. Compute Savings Plans apply across instance families. EC2 Instance Savings Plans and Standard Reserved Instances are tied to a family, so x86 commitments you have already bought can sit unused after you migrate. Schedule the largest moves for when those terms expire.

How do you inventory dependencies before switching to arm64?

Start with every image that runs on a node, not just the ones your team builds. That includes application base images, service mesh sidecars, log shippers, monitoring agents, init containers, and DaemonSets installed by Helm charts you rarely revisit. Any one of them can block the node pool.

For each image, check which platforms its manifest lists with docker buildx imagetools inspect <image> or docker manifest inspect <image>. You want a linux/arm64 entry. A single-platform image is the dangerous case. The runtime pulls it on an arm64 node without complaint, and the failure appears only when the process starts.

Next, look inside the images you build. Dockerfiles often COPY or curl a prebuilt binary, such as a CLI tool, a database driver, or a vendored health-check utility, and nothing in the build flags it. Run file <path> or readelf -h <path> on each binary. file reports "x86-64" or "aarch64", and readelf shows the Machine field.

Language packages need the same scrutiny. Python packages with C extensions, native Node.js addons, and Ruby gems with native code either need a published arm64 wheel or prebuilt artifact, or they fall back to compiling from source. That fallback can fail on a slim image that has no compiler. It can also succeed silently but much more slowly, which lengthens your builds.

A missed dependency usually shows up as exec format error in the container logs, followed by CrashLoopBackOff. When you see that error, check the architecture of the entrypoint binary first.

#!/usr/bin/env bash
set -euo pipefail

kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.initContainers[*]}{.image}{"\n"}{end}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' \
  | sort -u > images.txt

while read -r img; do
  if docker buildx imagetools inspect "$img" | grep -q 'linux/arm64'; then
    echo "OK       $img"
  else
    echo "MISSING  $img"
  fi
done < images.txt

IMAGE=registry.example.com/team/api:1.4.2
BIN=/usr/local/bin/healthcheck
cid=$(docker create "$IMAGE")
docker cp "$cid:$BIN" ./healthcheck.bin
docker rm "$cid" > /dev/null
file ./healthcheck.bin                  # expect "ARM aarch64", not "x86-64"
readelf -h ./healthcheck.bin | grep Machine   # expect "AArch64"

kubectl logs -n payments api-7d9f8c6b5-x2k4q --previous | grep 'exec format error'

A bash audit that lists every image in the cluster, uses docker buildx imagetools inspect to flag images that lack a linux/arm64 manifest, uses file and readelf to check the architecture of a vendored binary, and checks a crashing pod's logs for exec format error.

How do you build multi-arch images with docker buildx?

docker buildx build with --platform linux/amd64,linux/arm64 --push builds one image per architecture and publishes a manifest list under a single tag. Each node's container runtime then pulls the variant that matches its CPU. How buildx produces the arm64 variant determines your build time.

Approach How it works Build speed Extra infrastructure Best fit
QEMU emulation Runs arm64 binaries on an amd64 builder through binfmt Slow, especially for compile-heavy steps None Small images, interpreted languages
Native arm64 runners Separate builder nodes per architecture, joined into one buildx builder Fast arm64 CI runners to maintain Large native builds (C++, Rust)
Cross-compilation FROM --platform=$BUILDPLATFORM for the build stage, then TARGETOS/TARGETARCH passed to GOOS/GOARCH Near native None Go and other toolchains that cross-compile cleanly

Cross-compilation avoids emulation because only the final stage, usually a COPY into a minimal base image, runs under the target platform. Native toolchain work happens on the host architecture.

A common failure starts on a developer's Apple Silicon laptop. A plain docker build produces a single arm64 image. If someone pushes that to a shared tag, every amd64 node that pulls it fails with exec format error. To prevent this, allow only CI to push to production repositories. Have the pipeline run docker buildx imagetools inspect and fail if either platform is missing from the manifest list.

Multi-arch builds also add development cost. You pay for more CI minutes, especially with emulation, and registry storage grows because each tag carries a second set of architecture-specific layers.

#!/usr/bin/env bash
set -euo pipefail

IMAGE="registry.example.com/team/orders-api"
TAG="${GIT_SHA:?GIT_SHA must be set by the CI system}"

docker run --privileged --rm tonistiigi/binfmt --install arm64

docker buildx create --name multiarch --driver docker-container --use 2>/dev/null \
  || docker buildx use multiarch

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag "$IMAGE:$TAG" \
  --push .

raw="$(docker buildx imagetools inspect --raw "$IMAGE:$TAG")"
for platform in linux/amd64 linux/arm64; do
  os="${platform%/*}"; arch="${platform#*/}"
  if ! jq -e --arg os "$os" --arg arch "$arch" \
      '[.manifests[]?.platform | select(.os == $os and .architecture == $arch)] | length > 0' \
      <<<"$raw" >/dev/null; then
    echo "ERROR: $IMAGE:$TAG is missing $platform" >&2
    exit 1
  fi
done
echo "OK: $IMAGE:$TAG includes linux/amd64 and linux/arm64"

A CI publish script that uses docker buildx to push a single linux/amd64 and linux/arm64 manifest list, then fails the pipeline if either platform is missing from the pushed tag.

How do you roll out and benchmark arm64 on Kubernetes or ECS?

Run both architectures side by side instead of switching over all at once. On Kubernetes, add an arm64 node group and pin the canary Deployment to it with a node selector or required node affinity on kubernetes.io/arch: arm64. Give the baseline Deployment amd64 so neither one lands on the wrong nodes by accident. On ECS, register a second capacity provider backed by Graviton instances and run a separate service whose task definition sets the ARM64 CPU architecture.

Canary topology:

Load balancer → x86 target group (95%) → amd64 pods/tasks Load balancer → arm64 target group (5%) → arm64 pods/tasks Both groups → shared metrics, labeled by arch

Compare error rates and p50/p99 latency per architecture over the same time window, then shift weight in steps.

Benchmarking methodology:

  • Load: Replay production-like traffic. Synthetic loops that hit one endpoint miss cache and allocation behavior.
  • Warm-up: Discard the JIT warm-up period on the JVM, .NET, or V8 before you record anything. Cold-start numbers measure the compiler, not the CPU.
  • Fixed utilization: Compare throughput at a fixed CPU utilization, such as requests per second at 60%. Comparing utilization at fixed load hides headroom.

After the cutover, re-tune your autoscaling thresholds. A Graviton vCPU is a physical core, while an x86 vCPU is usually a hyperthread, so "70% CPU" means a different amount of remaining capacity on each.

Mixed-fleet costs: Every release now needs testing on two architectures. Expect bugs that only show up on amd64-to-arm64 transitions, such as a single-arch sidecar or init container, or a hardcoded -amd64 image tag that pulls successfully and then crashes with exec format error.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-arm64
spec:
  replicas: 2  # small fraction of the baseline replica count
  selector:
    matchLabels:
      app: checkout
      arch: arm64
  template:
    metadata:
      labels:
        app: checkout
        arch: arm64   # lets dashboards split error rate and p50/p99 by architecture
    spec:
      affinity:
        nodeAffinity:
          # Hard requirement: never schedule onto amd64 nodes by accident
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: kubernetes.io/arch
                    operator: In
                    values: ["arm64"]
      containers:
        - name: app
          # Multi-arch manifest list tag, never a hardcoded -amd64 tag
          image: registry.example.com/checkout:1.42.0
          resources:
            requests:
              cpu: "1"
              memory: 1Gi
        - name: log-shipper
          # Sidecars must be multi-arch too, or this pod fails with exec format error
          image: registry.example.com/log-shipper:3.1.0

A canary Deployment that uses required node affinity on kubernetes.io/arch to run only on arm64 nodes and carries an arch label so its metrics can be compared against the amd64 baseline.

What is the full cost of a Graviton migration, and what are the alternatives?

Those bugs are part of the cost, and they are not the only cost.

The full cost picture

  • Commitments. Reserved Instances and EC2 Instance Savings Plans are tied to an instance family. If you move off that family before the term ends, you keep paying for the old commitment. Compute Savings Plans are more flexible across families.
  • CI minutes. Multi-arch builds run twice, or run slowly under emulation.
  • Image storage. Each tag now holds two images, which roughly doubles registry storage and transfer.
  • Mixed-fleet upkeep. You need two sets of AMIs or node groups, plus architecture-aware scheduling, for as long as both architectures run.
  • Engineering time. Engineer-weeks per service are usually the largest cost.

Illustrative estimate (assumed numbers, not a benchmark). Assume $20,000/month in compute, a 15% net price-performance gain, $300/month in added CI and storage, and 6 engineer-weeks at $4,000/week.

  • Net monthly savings = spend × gain − added recurring cost = 20,000 × 0.15 − 300 = $2,700
  • Payback = one-time effort ÷ net monthly savings = 24,000 ÷ 2,700 ≈ 8.9 months

Engineering time dominates this calculation. A shared multi-arch pipeline and a reusable canary template turn the second service into a configuration change instead of a new project. That reduces the per-service cost, which shortens payback for each later service.

Alternatives

Option Effort Beats a migration when
Rightsize x86 Low Utilization is low; you fix overprovisioning first
Newer x86 generation Low Native or proprietary dependencies block arm64
Spot capacity Medium Workloads are stateless and tolerate interruption
Commitment changes Low Usage is steady but not yet covered by commitments
Code optimization Variable Profiling shows hot paths that waste CPU on any architecture

These options combine with a migration. Rightsize first so you don't migrate waste.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to graviton migration cost savings, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.