Migrate Kubernetes Workloads to Graviton with Karpenter
Progressively shift Kubernetes workloads to Graviton using Karpenter capacity spread and EKS Auto Mode — with zero downtime.
AWS Graviton processors deliver up to 40% better price-performance over comparable instances. The numbers are compelling, but migrating production workloads to a different CPU architecture isn't something you want to rush. You need a way to validate performance, control the rollout, and roll back if something goes wrong.
In a previous tutorial , I showed you how to use Karpenter with Spot instances to reduce your EKS compute costs. In this post, I'll show you a progressive migration strategy that lets you shift your workloads to Graviton gradually — starting with a single pilot pod, then splitting traffic 50/50, and finally completing the migration when you're confident. The same Karpenter primitives you already know (NodePools, topology spread constraints, taints) make this possible.
If you're using EKS Auto Mode , you're already running Karpenter — it's one of the managed components that EKS Auto Mode operates for you. Auto Mode automates the deployment, scaling, and upgrades of Karpenter, so you don't manage the Karpenter pods yourself. But you still configure NodePools and NodeClasses the same way you would with open-source Karpenter. That means everything in this post — NodePools, taints, capacity spread, topology spread constraints — works the same whether you're running self-managed Karpenter or EKS Auto Mode. The only difference is who's managing the Karpenter controller.
Let's walk through the strategy.
Why Not Just Switch?
You could update your NodePool to only allow
arm64 instances and call it a day. But in production, that's risky. Your application might have dependencies that behave differently on arm64. Performance characteristics might change. And if something goes wrong, rolling back means redeploying everything.A progressive migration gives you confidence at every step. You validate Graviton with a single pod first, then gradually increase the split. If you see issues at any point, you adjust the split — no redeployment needed. And because you're using Karpenter's capacity spread feature, the application itself doesn't need to change. You control the architecture distribution entirely through NodePool configuration. Workloads that aren't ready for Graviton stay on the existing nodes without any changes — you're in control of the migration timeline.
The Strategy: Four Phases
The migration happens in four phases. Each one builds on the previous, and you can pause or roll back at any point.
Phase 1: Pilot Deployment

Phase 1 - Pilot Deployment
Before touching your production workload, you deploy a single pilot pod on Graviton to validate that everything works. You create a new
multiarch NodePool alongside your existing one, with a taint to prevent accidental usage:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: multiarch
spec:
template:
metadata:
labels:
intent: custom
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values:
- amd64
- arm64
- key: eks.amazonaws.com/instance-category
operator: In
values:
- c
- m
- r
- key: eks.amazonaws.com/instance-generation
operator: Gt
values:
- "4"
taints:
- key: workload-type
value: multiarch
effect: NoScheduleA few things to notice here:
- The
intent: customlabel is the same label your existingcustomNodePool uses. This is intentional — it lets applications target both NodePools with a singlenodeSelector, which becomes important in Phase 3 when you want pods to spread across both. - The taint (
workload-type=multiarch) ensures that only pods with the matching toleration will land on this NodePool. Your existing workloads continue running on existing nodes without any changes. - Both
amd64andarm64architectures are supported, giving EKS Auto Mode flexibility to provision either.
Then you deploy a pilot — a single replica with the toleration and a node affinity targeting a Graviton instance family (like
c7g):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
apiVersion: apps/v1
kind: Deployment
metadata:
name: pilot
spec:
replicas: 1
template:
spec:
tolerations:
- key: workload-type
operator: Equal
value: multiarch
effect: NoSchedule
nodeSelector:
intent: custom
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: eks.amazonaws.com/instance-family
operator: In
values:
- c7g
containers:
- name: pilot
image: "your-multi-arch-image"
resources:
requests:
cpu: 6
memory: 12GiThe
nodeSelector with intent: custom matches both NodePools, but since only the multiarch NodePool has the taint and the pilot has the toleration, it will land there. The node affinity for c7g ensures EKS Auto Mode provisions a Graviton instance specifically.Verify the Pilot
Once the pilot is running, confirm it landed on a Graviton instance:
1
2
3
4
kubectl get pods -l app.kubernetes.io/name=pilot \
-o jsonpath='{.items[0].spec.nodeName}' | \
xargs kubectl get node -o \
jsonpath='{.metadata.labels.node\.kubernetes\.io/instance-type} ({.metadata.labels.kubernetes\.io/arch})'You should see something like
c7g.2xlarge (arm64), confirming the pod is running on Graviton.Compare Performance
This is the interesting part. Run a load test against the pilot and compare with your existing nodes baseline. You can use a tool like wrk2 to measure latency under load:
1
wrk -c1 -t1 -d 5m -U -R1 -s ./post.lua http://<pilot-service-endpoint>/your-endpointCompare the latency distribution (p50, p99, mean) against the numbers you got from your existing deployment. In most cases, you'll see similar or better performance on Graviton, especially for compute-intensive workloads. The key metrics to compare are:
- p50 and p99 latency
- Mean response time
- Requests per second at the same concurrency level
If the numbers look good, you're ready to move on. If not, investigate before proceeding — the pilot exists precisely for this validation step.
Phase 2: Setup Capacity Spread

Phase 2 - Setup Capacity Spread
You've validated that Graviton performs well. Now you set up the infrastructure for progressive delivery. The idea is simple: assign different
capacity-spread values to different NodePools, then use topology spread constraints to distribute pods across those values.Update your existing existing NodePool to add capacity-spread value "1":
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Existing NodePool (custom)
requirements:
- key: capacity-spread
operator: In
values:
- "1"
- key: karpenter.sh/capacity-type
operator: In
values:
- on-demand
- key: kubernetes.io/arch
operator: In
values:
- amd64And your multiarch NodePool with capacity-spread value "2" (keeping the taint):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Multiarch NodePool (keeps the taint)
requirements:
- key: capacity-spread
operator: In
values:
- "2"
- key: karpenter.sh/capacity-type
operator: In
values:
- on-demand
- key: kubernetes.io/arch
operator: In
values:
- amd64
- arm64
taints:
- key: workload-type
value: multiarch
effect: NoScheduleWith one capacity-spread value on each NodePool, you get a 50/50 split. The math for adjusting the split is straightforward:
| Desired Split | Existing NodePool values | Multiarch NodePool values |
|---|---|---|
| 50/50 | "1" | "2" |
| 20% Graviton / 80% x86 | "1", "2", "3", "4" | "5" |
| 80% Graviton / 20% x86 | "1" | "2", "3", "4", "5" |
The application doesn't need to know about any of this. You're controlling the distribution entirely through NodePool configuration.
At this point, clean up the pilot deployment — it's served its purpose:
1
2
kubectl delete deployment pilot
kubectl delete service pilotPhase 3: Progressive Rollout

Phase 3 - Progressive Rollout
Now you update your main application deployment to use the capacity spread. You need four changes to your existing deployment manifest:
- Add tolerations — to allow pods to use the tainted multiarch NodePool
- Keep the nodeSelector —
intent: custommatches both NodePools since they share the same label - Add node affinity — specify both x86 and Graviton instance families (e.g.,
c6iandc7g) - Add topology spread constraints — distribute pods across capacity-spread values
Here's what the deployment looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
template:
spec:
tolerations:
- key: workload-type
operator: Equal
value: multiarch
effect: NoSchedule
nodeSelector:
intent: custom
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: eks.amazonaws.com/instance-family
operator: In
values:
- c6i # x86
- c7g # Graviton
topologySpreadConstraints:
- labelSelector:
matchLabels:
app.kubernetes.io/name: myapp
maxSkew: 1
topologyKey: capacity-spread
whenUnsatisfiable: DoNotSchedule
containers:
- name: myapp
image: "your-multi-arch-image"Let me break down how these pieces work together:
- The toleration opts the deployment into the multiarch NodePool. Without it, all pods would land on the
customNodePool (x86-only) since it doesn't have a taint. - The nodeSelector with
intent: custommatches both NodePools — this is why both NodePools share the same label. - The node affinity ensures pods only land on compatible instance families:
c6ifor Intel x86 andc7gfor Graviton3. This is important because your application might have specific requirements that are only met by these families. - The topology spread constraint with
topologyKey: capacity-spreadis the magic. It tells Kubernetes "spread my pods across different capacity-spread values." Since thecustomNodePool has value "1" and themultiarchNodePool has value "2", EKS Auto Mode provisions nodes from both NodePools to satisfy this constraint.
The combination gives you precise control: toleration opts in, nodeSelector targets both, affinity ensures compatibility, and topology spread distributes.
Verify the Distribution
Check that your pods are distributed across both architectures:
1
2
3
4
5
6
7
8
kubectl get pods -l app.kubernetes.io/name=myapp \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\n"}{end}' \
| while read pod node; do
arch=$(kubectl get node $node -o jsonpath='{.metadata.labels.kubernetes\.io/arch}')
instance=$(kubectl get node $node -o jsonpath='{.metadata.labels.node\.kubernetes\.io/instance-type}')
spread=$(kubectl get node $node -o jsonpath='{.metadata.labels.capacity-spread}')
echo "$pod -> $instance ($arch, capacity-spread: $spread)"
doneYou should see output like:
1
2
myapp-6b7d8f9c4-abc12 -> c6i.2xlarge (amd64, capacity-spread: 1)
myapp-6b7d8f9c4-def34 -> c7g.2xlarge (arm64, capacity-spread: 2)One pod on x86, one on Graviton — an even 50/50 split.
Adjusting the Split
If you want to change the ratio, you only touch the NodePool configurations. For example, to go 80% Graviton / 20% x86:
- Give the x86
customNodePool capacity-spread value "1" - Give the
multiarchNodePool capacity-spread values "2", "3", "4", "5"
Then trigger a rolling restart of your deployment to redistribute the pods. The deployment manifest itself doesn't change — the NodePools control the distribution.
Phase 4: Complete the Migration

Phase 4 - Complete Migration
Once you're confident in Graviton, you have two paths.
Option 1: Go All-In on Graviton
Remove the capacity spread configuration, remove the taint from the multiarch NodePool, and update your deployment to target only Graviton instance families. Here's what the simplified multiarch NodePool looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: multiarch
spec:
template:
metadata:
labels:
intent: custom
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values:
- on-demand
- key: eks.amazonaws.com/instance-category
operator: In
values:
- c
- m
- r
- key: eks.amazonaws.com/instance-generation
operator: Gt
values:
- "4"
- key: kubernetes.io/arch
operator: In
values:
- amd64
- arm64No taint, no capacity-spread. And your deployment simplifies too — remove the tolerations and topology spread constraints, and update the affinity to target only Graviton:
1
2
3
4
5
6
7
8
9
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: eks.amazonaws.com/instance-family
operator: In
values:
- c7g # Only Graviton instancesThis gives you the full 30-40% cost savings from Graviton.
Option 2: Keep Multi-Arch with Spot
If your workloads can tolerate interruptions, keep both architectures and add Spot support to your NodePools. Update the capacity-type requirement in both NodePools:
1
2
3
4
5
- key: karpenter.sh/capacity-type
operator: In
values:
- spot
- on-demandThe architecture diversity improves Spot availability — you're drawing from a larger pool of instance types across both x86 and arm64. EKS Auto Mode will prefer Spot instances but fall back to On-Demand if Spot isn't available. The capacity spread still controls the architecture distribution, but now you're stacking Graviton savings on top of Spot savings. Total savings can reach 50-70% compared to x86 On-Demand.
Your application deployment stays the same — it continues to use the topology spread constraints with the capacity-spread key. No changes needed.
Why This Works Well in Practice
A few things make this approach production-friendly:
No application changes needed to adjust the split. The capacity-spread values live in your NodePool configurations. Your deployment manifest stays the same regardless of whether you're running 50/50, 80/20, or 100% Graviton.
The taint protects other workloads. The multiarch NodePool has a taint, so only workloads that explicitly opt in with tolerations will use it. Other applications in your cluster continue running on x86 without any changes. This is critical in production environments where you might have multiple applications at different stages of Graviton readiness.
You can reuse the same NodePools for multiple applications. Any workload that's ready for progressive delivery can use the same capacity-spread setup. You don't need to create new NodePools for each application — just add the toleration and topology spread constraint to each deployment as it's ready.
Rollback is instant. If you see issues at any point, adjust the capacity-spread values to shift traffic back to x86. No redeployment, no downtime.
Common Migration Patterns
The progressive approach I've described here is one of several patterns you can use:
Progressive Migration (what this post covers) — Start with a pilot, roll out with capacity spread, complete when confident. Best for production workloads where you want gradual validation with full control.
Blue/Green — Deploy to Graviton in a staging environment, validate thoroughly, then switch production traffic. Best when you have separate environments and want full validation before production.
Multi-Arch with Spot — Keep both architectures permanently and add Spot support. Best for cost-sensitive workloads that can tolerate interruptions. The architecture diversity improves Spot availability, and you get the combined savings from both Graviton and Spot.
Putting It All Together
Here's the sequence:
- Build multi-arch container images for your application
- Create a multiarch NodePool with a taint alongside your existing x86 NodePool
- Deploy a pilot pod on Graviton and validate performance against your x86 baseline
- Clean up the pilot and add capacity-spread values to both NodePools
- Update your deployment with tolerations, node affinity, and topology spread constraints
- Verify the distribution — confirm pods are spread across both architectures
- Monitor the 50/50 split for 24-48 hours, check AWS Cost Explorer for actual savings
- Complete the migration by going Graviton-only or multi-arch with Spot
The whole process can be done incrementally, at your own pace. You're never in a position where you have to commit to the full migration before you're ready.
Try It Yourself
If you want to walk through this hands-on, I've put together a workshop that takes you through the complete migration for Java, Python, Go, and .NET applications on EKS. It includes the full NodePool configurations, deployment manifests, performance testing setup, and cost analysis so you can see the savings firsthand.
Conclusion
Migrating to Graviton doesn't have to be an all-or-nothing decision. With Karpenter's capacity spread and topology spread constraints, you can progressively shift your workloads to Graviton while maintaining full control over the rollout. Start with a pilot, validate performance, split traffic, and complete the migration when you're confident. The infrastructure handles the complexity — you just adjust the knobs.
If you're already using Karpenter with Spot (as I covered in my previous tutorial ), adding Graviton to the mix is a natural next step. You get the cost savings from Graviton plus the additional savings from Spot, with better availability thanks to the architecture diversity. Customers consistently see 30-40% savings with Graviton alone, and when you combine it with Spot, total savings can reach 50-70%.
Resources
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article