Skip to main content
Source

This page is generated from skills/eks-cost-intelligence/references/spot-graviton-adoption.md. Edit the source, not this page.

Spot and Graviton Adoption

Part of: eks-cost-intelligence Purpose: Checks for Graviton (arm64) adoption percentage, node groups/NodePools without arm64 in allowed architectures, workloads with explicit amd64 affinity, Spot vs On-Demand percentage, stateless multi-replica workloads on On-Demand, instance type diversity for Spot, and Node Termination Handler/Karpenter interruption handling


Overview

Spot and Graviton adoption is the second-highest weighted dimension (20 points max deduction). It evaluates whether the cluster takes advantage of Graviton processors (~20% cost savings at equivalent performance per AWS Graviton) and Spot instances (up to 90% discount) where workloads are eligible.

Checks Summary

#CheckDefault ThresholdSeverity Logic
1Graviton (arm64) adoption percentage< 50% of eligible nodesHIGH if below 50%
2Node groups/NodePools without arm64Any group missing arm64MEDIUM per group
3Workloads with explicit amd64 affinityAny workload pinned to amd64LOW–MEDIUM
4Spot vs On-Demand percentageNo Spot + stateless workloads existHIGH
5Stateless multi-replica workloads on On-DemandEligible workloads not on SpotBy waste $
6Instance type diversity for Spot< 5 instance types in Spot poolMEDIUM
7Node Termination Handler / interruption handlingMissing handler with Spot nodesHIGH

Pre-requisites

These checks require:

  • kubectl access to the cluster (for node labels, pod specs, affinity rules)
  • AWS CLI access for eks:DescribeNodegroup, ec2:DescribeInstances

No metrics sources are required — all checks use configuration and label inspection.


Check 1: Graviton (arm64) Adoption Percentage

What it detects

The ratio of nodes running on Graviton (arm64) architecture versus x86 (amd64), identifying clusters that are not leveraging Graviton's price-performance advantage.

Data collection

Via kubectl:

# Count nodes by architecture
kubectl get nodes -o json | \
jq -r '
.items[] | {
name: .metadata.name,
arch: .status.nodeInfo.architecture,
instance_type: .metadata.labels["node.kubernetes.io/instance-type"],
capacity_type: (.metadata.labels["karpenter.sh/capacity-type"] //
.metadata.labels["eks.amazonaws.com/capacityType"] // "on-demand"),
nodegroup: (.metadata.labels["eks.amazonaws.com/nodegroup"] //
.metadata.labels["karpenter.sh/nodepool"] // "unknown")
}' | jq -s '
{
total: length,
arm64: [.[] | select(.arch == "arm64")] | length,
amd64: [.[] | select(.arch == "amd64")] | length,
arm64_pct: (([.[] | select(.arch == "arm64")] | length) * 100 / length),
by_nodegroup: (group_by(.nodegroup) | map({
nodegroup: .[0].nodegroup,
total: length,
arm64: [.[] | select(.arch == "arm64")] | length,
amd64: [.[] | select(.arch == "amd64")] | length
}))
}'

Via AWS CLI (node group level):

# List node groups and their instance types
CLUSTER="<cluster>"
for NG in $(aws eks list-nodegroups --cluster-name $CLUSTER --query 'nodegroups[]' --output text); do
echo "=== $NG ==="
aws eks describe-nodegroup --cluster-name $CLUSTER --nodegroup-name $NG \
--query '{name: nodegroup.nodegroupName, instanceTypes: nodegroup.instanceTypes, capacityType: nodegroup.capacityType}' \
--output json
done

# Check if instance types are Graviton (contain 'g' suffix in family)
# Graviton families: t4g, m6g, m7g, c6g, c7g, r6g, r7g, m6gd, c6gd, etc.
aws ec2 describe-instance-types \
--instance-types m6g.large m6i.large c6g.xlarge c6i.xlarge \
--query 'InstanceTypes[].{Type: InstanceType, Arch: ProcessorInfo.SupportedArchitectures[0]}' \
--output table

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Node",
api_version="v1"
)
# Parse .status.nodeInfo.architecture and .metadata.labels for each node

list_eks_resources(
cluster_name="<cluster>",
resource_type="nodegroups"
)
# Check instanceTypes in each nodegroup configuration

Analysis logic

total_nodes = count(all nodes)
arm64_nodes = count(nodes where .status.nodeInfo.architecture == "arm64")
amd64_nodes = count(nodes where .status.nodeInfo.architecture == "amd64")

graviton_pct = arm64_nodes / total_nodes × 100

If graviton_pct < 50%:
→ Generate HIGH severity finding
savings_estimate = amd64_nodes × avg_node_hourly_cost × 0.20 × 730
# Graviton typically provides ~20% cost savings at equivalent performance

Severity classification

Graviton AdoptionSeverity
0% (no Graviton at all)HIGH
1–49%HIGH
50–79%MEDIUM
80%+No finding (good adoption)

Key threshold: Below 50% Graviton adoption triggers a HIGH severity finding per Requirement 5.4.

Remediation

# Karpenter NodePool with Graviton preference
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: graviton-preferred
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["arm64"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["m", "c", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["5"]
# For Managed Node Groups — create a Graviton node group
aws eks create-nodegroup \
--cluster-name <cluster> \
--nodegroup-name graviton-workers \
--instance-types m7g.large m7g.xlarge c7g.large c7g.xlarge \
--scaling-config minSize=2,maxSize=10,desiredSize=3 \
--node-role <node-role-arn> \
--subnets <subnet-ids>

Check 2: Node Groups/NodePools Without arm64 in Allowed Architectures

What it detects

Node groups or Karpenter NodePools that are configured to only launch x86 (amd64) instances, missing the opportunity to use Graviton.

Data collection

Via kubectl (Karpenter NodePools):

# Check NodePool architecture requirements
kubectl get nodepools -o json | \
jq '.items[] | {
name: .metadata.name,
arch_requirement: (
.spec.template.spec.requirements[] |
select(.key == "kubernetes.io/arch") |
{operator: .operator, values: .values}
)
}'

# NodePools that explicitly exclude arm64 or only allow amd64
kubectl get nodepools -o json | \
jq '[.items[] |
select(
.spec.template.spec.requirements[] |
select(.key == "kubernetes.io/arch") |
((.operator == "In" and (.values | contains(["arm64"]) | not)) or
(.operator == "NotIn" and (.values | contains(["arm64"]))))
) | .metadata.name]'

Via AWS CLI (Managed Node Groups):

# Check instance types in each node group — identify non-Graviton-only groups
CLUSTER="<cluster>"
for NG in $(aws eks list-nodegroups --cluster-name $CLUSTER --query 'nodegroups[]' --output text); do
TYPES=$(aws eks describe-nodegroup --cluster-name $CLUSTER --nodegroup-name $NG \
--query 'nodegroup.instanceTypes' --output json)

# Check if any instance type is Graviton (contains 'g' before the dot)
HAS_GRAVITON=$(echo "$TYPES" | jq '[.[] | select(test("\\d+g[de]?\\."))] | length > 0')

if [ "$HAS_GRAVITON" = "false" ]; then
echo "NO_GRAVITON: $NG uses only x86 types: $TYPES"
fi
done

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="NodePool",
api_version="karpenter.sh/v1"
)
# Check spec.template.spec.requirements for kubernetes.io/arch

list_eks_resources(
cluster_name="<cluster>",
resource_type="nodegroups"
)
# Check instanceTypes for Graviton families

Analysis logic

For each NodePool:
arch_req = find requirement where key == "kubernetes.io/arch"

If arch_req.operator == "In" AND "arm64" NOT in arch_req.values:
→ Finding: NodePool restricted to amd64 only

If arch_req is missing (no architecture constraint):
→ OK (Karpenter will consider both architectures)

For each Managed Node Group:
instance_types = nodegroup.instanceTypes
has_graviton = any(type matches graviton pattern for type in instance_types)

If NOT has_graviton:
→ Finding: Node group uses only x86 instance types

Severity classification

ConditionSeverity
All node groups/NodePools are amd64-onlyHIGH
Some node groups/NodePools are amd64-onlyMEDIUM
Only system/addon node groups are amd64-onlyLOW

Remediation

# Add arm64 to existing NodePool requirements
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"] # Allow both architectures
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["m", "c", "r"]
# For Managed Node Groups — add a Graviton node group alongside existing
# (cannot change instance types on existing node group)
aws eks create-nodegroup \
--cluster-name <cluster> \
--nodegroup-name <existing-name>-graviton \
--instance-types m7g.large m7g.xlarge c7g.large \
--scaling-config minSize=1,maxSize=10,desiredSize=2 \
--node-role <node-role-arn> \
--subnets <subnet-ids>

Check 3: Workloads With Explicit amd64 Affinity

What it detects

Workloads (Deployments, StatefulSets) that have explicit node affinity or node selectors pinning them to amd64 architecture, preventing them from being scheduled on Graviton nodes even when available.

Data collection

Via kubectl:

# Find workloads with explicit amd64 node selector
kubectl get deployments,statefulsets --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(
(.spec.template.spec.nodeSelector // {} | .["kubernetes.io/arch"] == "amd64") or
(.spec.template.spec.nodeSelector // {} | .["beta.kubernetes.io/arch"] == "amd64")
) |
"\(.metadata.namespace)/\(.metadata.name) (kind: \(.kind)) — nodeSelector pins to amd64"
'

# Find workloads with amd64 node affinity
kubectl get deployments,statefulsets --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(
.spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[]?.matchExpressions[]? |
select(.key == "kubernetes.io/arch" or .key == "beta.kubernetes.io/arch") |
select(.operator == "In" and (.values | contains(["arm64"]) | not))
) |
"\(.metadata.namespace)/\(.metadata.name) (kind: \(.kind)) — nodeAffinity pins to amd64"
'

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Deployment",
api_version="apps/v1",
namespace="all"
)
# Filter for spec.template.spec.nodeSelector["kubernetes.io/arch"] == "amd64"
# or nodeAffinity expressions restricting to amd64

list_k8s_resources(
cluster_name="<cluster>",
kind="StatefulSet",
api_version="apps/v1",
namespace="all"
)
# Same filtering logic

Analysis logic

For each Deployment/StatefulSet in non-system namespaces:
has_amd64_selector = nodeSelector contains "kubernetes.io/arch": "amd64"
OR "beta.kubernetes.io/arch": "amd64"

has_amd64_affinity = nodeAffinity.required contains matchExpression
where key is arch AND values only include "amd64"

If has_amd64_selector OR has_amd64_affinity:
# Check if workload genuinely needs x86 (known x86-only dependencies)
# Common legitimate reasons: specific binary dependencies, GPU workloads
→ Generate finding (potential Graviton candidate)

replicas = workload.spec.replicas
estimated_savings = replicas × per_pod_cost × 0.20 # ~20% Graviton savings

Severity classification

ConditionSeverity
> 10 workloads pinned to amd64MEDIUM
1–10 workloads pinned to amd64LOW
Workloads with high replica count (>5) pinned to amd64MEDIUM

Remediation

# Remove architecture constraint (allow scheduling on both)
# Before:
spec:
template:
spec:
nodeSelector:
kubernetes.io/arch: amd64

# After (remove the constraint or allow both):
spec:
template:
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: kubernetes.io/arch
operator: In
values: ["arm64"] # Prefer Graviton but allow amd64 fallback
# Remove amd64 nodeSelector from a deployment
kubectl patch deployment <name> -n <namespace> --type=json \
-p='[{"op": "remove", "path": "/spec/template/spec/nodeSelector/kubernetes.io~1arch"}]'

Note: Before removing architecture constraints, verify the workload's container images support multi-arch (linux/arm64). Check with: docker manifest inspect <image> | jq '.manifests[].platform'


Check 4: Spot vs On-Demand Capacity Percentage

What it detects

The ratio of nodes running on Spot instances versus On-Demand, identifying clusters that are not leveraging Spot's significant cost savings for eligible workloads.

Data collection

Via kubectl:

# Count nodes by capacity type
kubectl get nodes -o json | \
jq -r '
.items[] | {
name: .metadata.name,
capacity_type: (.metadata.labels["karpenter.sh/capacity-type"] //
.metadata.labels["eks.amazonaws.com/capacityType"] // "ON_DEMAND"),
instance_type: .metadata.labels["node.kubernetes.io/instance-type"],
nodegroup: (.metadata.labels["eks.amazonaws.com/nodegroup"] //
.metadata.labels["karpenter.sh/nodepool"] // "unknown")
}' | jq -s '
{
total: length,
spot: [.[] | select(.capacity_type == "spot" or .capacity_type == "SPOT")] | length,
on_demand: [.[] | select(.capacity_type == "on-demand" or .capacity_type == "ON_DEMAND")] | length,
spot_pct: (([.[] | select(.capacity_type == "spot" or .capacity_type == "SPOT")] | length) * 100 / (if length == 0 then 1 else length end)),
by_nodegroup: (group_by(.nodegroup) | map({
nodegroup: .[0].nodegroup,
capacity_type: .[0].capacity_type,
count: length
}))
}'

Via AWS CLI:

# Get capacity type from EC2 instances backing the cluster
CLUSTER="<cluster>"
INSTANCE_IDS=$(kubectl get nodes -o json | \
jq -r '.items[].spec.providerID' | sed 's|.*/||')

aws ec2 describe-instances \
--instance-ids $INSTANCE_IDS \
--query 'Reservations[].Instances[].{
Id: InstanceId,
Type: InstanceType,
Lifecycle: InstanceLifecycle,
State: State.Name
}' --output table
# InstanceLifecycle: "spot" for Spot, null/absent for On-Demand

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Node",
api_version="v1"
)
# Parse labels: karpenter.sh/capacity-type or eks.amazonaws.com/capacityType
# Values: "spot" / "on-demand" (Karpenter) or "SPOT" / "ON_DEMAND" (EKS MNG)

Analysis logic

total_nodes = count(all nodes)
spot_nodes = count(nodes with capacity_type in ["spot", "SPOT"])
on_demand_nodes = count(nodes with capacity_type in ["on-demand", "ON_DEMAND"])

spot_pct = spot_nodes / total_nodes × 100

# Check if stateless multi-replica workloads exist (see Check 5)
has_spot_eligible_workloads = (count of Spot-eligible workloads > 0)

If spot_pct == 0 AND has_spot_eligible_workloads:
→ Generate HIGH severity finding (Requirement 6.5)
savings_estimate = eligible_workload_cost × 0.60 # ~60% Spot discount average

If spot_pct > 0 AND spot_pct < 30 AND has_spot_eligible_workloads:
→ Generate MEDIUM severity finding (room for more Spot)

Severity classification

ConditionSeverity
0% Spot + eligible workloads existHIGH
1–29% Spot + more eligible workloadsMEDIUM
30%+ Spot or no eligible workloadsNo finding

Remediation

# Karpenter NodePool for Spot workloads
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: spot-workers
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["m", "c", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["5"]
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 30s
# For Managed Node Groups — create a Spot node group
aws eks create-nodegroup \
--cluster-name <cluster> \
--nodegroup-name spot-workers \
--capacity-type SPOT \
--instance-types m5.large m5.xlarge m5a.large m5a.xlarge c5.large c5.xlarge c5a.large \
--scaling-config minSize=0,maxSize=20,desiredSize=3 \
--node-role <node-role-arn> \
--subnets <subnet-ids>

Check 5: Stateless Multi-Replica Workloads on On-Demand Only

What it detects

Workloads that are good candidates for Spot instances (stateless, multiple replicas, have PodDisruptionBudgets) but are currently running exclusively on On-Demand nodes.

Spot eligibility criteria

A workload is considered Spot-eligible when ALL of the following are true:

CriterionRationale
Stateless (Deployment, not StatefulSet)Can tolerate interruption without data loss
Multiple replicas (replicas ≥ 2)Service remains available during Spot interruption
Has PodDisruptionBudget (PDB)Ensures graceful handling of node termination
Not in a system namespaceSystem workloads should remain on stable capacity

Optional positive signals (increase confidence):

  • Workload has topologySpreadConstraints (already spread across zones)
  • Workload has readiness/liveness probes (health-aware)
  • Workload image has multiple architecture support

Data collection

Via kubectl:

# Step 1: Find Deployments with replicas >= 2 in non-system namespaces
kubectl get deployments --all-namespaces -o json | \
jq '[.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.spec.replicas >= 2) |
{
namespace: .metadata.namespace,
name: .metadata.name,
replicas: .spec.replicas,
has_topology_spread: (.spec.template.spec.topologySpreadConstraints != null),
node_selector: .spec.template.spec.nodeSelector,
tolerations: [.spec.template.spec.tolerations[]?.key]
}]'

# Step 2: Check which of those have PDBs
kubectl get pdb --all-namespaces -o json | \
jq '[.items[] | {
namespace: .metadata.namespace,
name: .metadata.name,
selector: .spec.selector.matchLabels,
minAvailable: .spec.minAvailable,
maxUnavailable: .spec.maxUnavailable
}]'

# Step 3: Check which pods are running on On-Demand nodes
kubectl get pods --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.status.phase == "Running") |
{
namespace: .metadata.namespace,
pod: .metadata.name,
node: .spec.nodeName,
owner: (.metadata.ownerReferences[0].name // "none")
}' | jq -s '.'

# Cross-reference with node capacity types
kubectl get nodes -o json | \
jq '[.items[] | {
name: .metadata.name,
capacity_type: (.metadata.labels["karpenter.sh/capacity-type"] //
.metadata.labels["eks.amazonaws.com/capacityType"] // "ON_DEMAND")
}]'

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Deployment",
api_version="apps/v1",
namespace="all"
)

list_k8s_resources(
cluster_name="<cluster>",
kind="PodDisruptionBudget",
api_version="policy/v1",
namespace="all"
)

list_k8s_resources(
cluster_name="<cluster>",
kind="Node",
api_version="v1"
)

Analysis logic

spot_eligible_workloads = []

For each Deployment in non-system namespaces:
If replicas >= 2:
has_pdb = PDB exists matching this deployment's labels
pods_on_spot = count(pods on nodes with capacity_type == "spot")
pods_on_demand = count(pods on nodes with capacity_type == "on-demand")

If has_pdb AND pods_on_spot == 0 AND pods_on_demand > 0:
→ Spot-eligible workload running entirely on On-Demand
spot_eligible_workloads.append(workload)

per_pod_cost = estimate_pod_cost(workload)
monthly_savings = pods_on_demand × per_pod_cost × 0.60 # ~60% Spot savings

If len(spot_eligible_workloads) > 0 AND cluster_spot_pct == 0:
→ Generate HIGH severity finding (Requirement 6.5)

If len(spot_eligible_workloads) > 0 AND cluster_spot_pct > 0:
→ Generate MEDIUM severity finding (more workloads could use Spot)

Severity classification

ConditionSeverity
Eligible workloads on On-Demand + zero Spot in clusterHIGH
Eligible workloads on On-Demand + some Spot existsMEDIUM
Monthly savings > $500CRITICAL
Monthly savings $200–$500HIGH
Monthly savings $50–$200MEDIUM
Monthly savings < $50LOW

Spot pricing note: The discount used (60-65%) is a conservative average. Actual Spot discounts vary 40-90% by instance type, region, and AZ. For production assessments, query live Spot prices: aws ec2 describe-spot-price-history --instance-types <types> --product-descriptions "Linux/UNIX"

Remediation

# Add Spot toleration to eligible workloads
apiVersion: apps/v1
kind: Deployment
metadata:
name: <workload>
namespace: <namespace>
spec:
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: <workload>
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 90
preference:
matchExpressions:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
# Ensure PDB exists for Spot workloads
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: <workload>-pdb
namespace: <namespace>
spec:
maxUnavailable: 1
selector:
matchLabels:
app: <workload>

Check 6: Instance Type Diversity for Spot Availability

What it detects

Spot node groups or NodePools configured with too few instance types, which increases the risk of Spot interruptions and capacity unavailability. AWS recommends at least 5+ instance types across multiple families for Spot.

Data collection

Via kubectl (Karpenter NodePools):

# Check instance type diversity in Spot NodePools
kubectl get nodepools -o json | \
jq '.items[] |
select(.spec.template.spec.requirements[] |
select(.key == "karpenter.sh/capacity-type") |
.values | contains(["spot"])) |
{
name: .metadata.name,
instance_categories: ([.spec.template.spec.requirements[] |
select(.key == "karpenter.k8s.aws/instance-category") | .values] | flatten),
instance_families: ([.spec.template.spec.requirements[] |
select(.key == "karpenter.k8s.aws/instance-family") | .values] | flatten),
instance_sizes: ([.spec.template.spec.requirements[] |
select(.key == "karpenter.k8s.aws/instance-size") | .values] | flatten),
excluded_types: ([.spec.template.spec.requirements[] |
select(.key == "node.kubernetes.io/instance-type" and .operator == "NotIn") | .values] | flatten)
}'

Via AWS CLI (Managed Node Groups):

# Check instance type count in Spot node groups
CLUSTER="<cluster>"
for NG in $(aws eks list-nodegroups --cluster-name $CLUSTER --query 'nodegroups[]' --output text); do
CAPACITY=$(aws eks describe-nodegroup --cluster-name $CLUSTER --nodegroup-name $NG \
--query 'nodegroup.capacityType' --output text)

if [ "$CAPACITY" = "SPOT" ]; then
TYPES=$(aws eks describe-nodegroup --cluster-name $CLUSTER --nodegroup-name $NG \
--query 'nodegroup.instanceTypes' --output json)
COUNT=$(echo "$TYPES" | jq 'length')
echo "SPOT group $NG: $COUNT instance types — $TYPES"

if [ "$COUNT" -lt 5 ]; then
echo " WARNING: Fewer than 5 instance types (recommended minimum for Spot)"
fi
fi
done

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="NodePool",
api_version="karpenter.sh/v1"
)
# Check requirements for capacity-type == spot, then count instance diversity

list_eks_resources(
cluster_name="<cluster>",
resource_type="nodegroups"
)
# Filter for capacityType == SPOT, check instanceTypes array length

Analysis logic

For each Spot-enabled NodePool or node group:
If using Karpenter:
# Karpenter with broad categories (m, c, r) is inherently diverse
categories = requirements["karpenter.k8s.aws/instance-category"].values
If len(categories) >= 3:
→ Sufficient diversity (Karpenter will select from many types)
If len(categories) < 2 AND no instance-family specified:
→ Finding: limited instance diversity for Spot

# Check if specific instance types are overly restricted
If instance-family is specified AND len(families) < 3:
→ Finding: limited instance family diversity

If using Managed Node Groups:
instance_types = nodegroup.instanceTypes
If len(instance_types) < 5:
→ Finding: fewer than 5 instance types for Spot
severity = MEDIUM (availability risk)

# Check family diversity
families = unique([type.split(".")[0] for type in instance_types])
If len(families) < 2:
→ Finding: all Spot types from single family (concentration risk)
severity = MEDIUM

Severity classification

ConditionSeverity
Spot node group with only 1–2 instance typesHIGH
Spot node group with 3–4 instance typesMEDIUM
Spot NodePool with only 1 instance categoryMEDIUM
All Spot from single instance familyMEDIUM
5+ types across 2+ familiesNo finding

Remediation

# Update Managed Node Group with more instance types (requires recreation)
# Recommended: at least 5 types across 2+ families, similar vCPU/memory
aws eks create-nodegroup \
--cluster-name <cluster> \
--nodegroup-name spot-diverse \
--capacity-type SPOT \
--instance-types m5.large m5a.large m5.xlarge m5a.xlarge c5.large c5a.large c5.xlarge m6i.large c6i.large r5.large \
--scaling-config minSize=0,maxSize=20,desiredSize=3 \
--node-role <node-role-arn> \
--subnets <subnet-ids>
# Karpenter NodePool with broad instance diversity
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: spot-diverse
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["m", "c", "r"] # 3+ categories
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["4"] # Gen 5+ for better availability
- key: karpenter.k8s.aws/instance-size
operator: In
values: ["large", "xlarge", "2xlarge"]
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"] # Both architectures

Check 7: Node Termination Handler / Karpenter Interruption Handling

What it detects

Whether the cluster has proper Spot interruption handling configured. Without it, Spot instance terminations (2-minute warning) can cause ungraceful pod evictions and service disruption.

Data collection

Step 1: Check if Spot nodes exist

# Quick check for any Spot nodes
SPOT_COUNT=$(kubectl get nodes -o json | \
jq '[.items[] | select(
.metadata.labels["karpenter.sh/capacity-type"] == "spot" or
.metadata.labels["eks.amazonaws.com/capacityType"] == "SPOT"
)] | length')

echo "Spot nodes: $SPOT_COUNT"

Step 2: Check for AWS Node Termination Handler (NTH)

# Check for NTH deployment (common names and namespaces)
kubectl get deployment -n kube-system aws-node-termination-handler 2>/dev/null || \
kubectl get daemonset -n kube-system aws-node-termination-handler 2>/dev/null || \
kubectl get deployment --all-namespaces -l app.kubernetes.io/name=aws-node-termination-handler 2>/dev/null

# Check for NTH via Helm release
kubectl get configmap -n kube-system -l app.kubernetes.io/name=aws-node-termination-handler 2>/dev/null

Step 3: Check Karpenter interruption handling

# Karpenter natively handles Spot interruptions (v0.30+)
# Verify Karpenter is running and has the interruption controller
kubectl get deployment -n kube-system karpenter -o json 2>/dev/null | \
jq '{
name: .metadata.name,
replicas: .status.readyReplicas,
version: .metadata.labels["app.kubernetes.io/version"]
}'

# Check Karpenter settings for interruption handling
kubectl get configmap -n kube-system karpenter-global-settings -o json 2>/dev/null | \
jq '.data'

# For Karpenter v1+, interruption is always enabled (built-in)
# For older versions, check if aws.interruptionQueue is configured
kubectl get deployment -n kube-system karpenter -o json 2>/dev/null | \
jq '.spec.template.spec.containers[0].env[] | select(.name | test("INTERRUPTION"))'

Step 4: Check for SQS queue (Karpenter interruption queue)

# Karpenter uses an SQS queue for Spot interruption events
aws sqs list-queues --queue-name-prefix "karpenter" --output json 2>/dev/null

# Or check via Karpenter EC2NodeClass
kubectl get ec2nodeclasses -o json 2>/dev/null | \
jq '.items[].spec | {amiFamily, role: .role}'

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Deployment",
api_version="apps/v1",
namespace="kube-system"
)
# Look for "aws-node-termination-handler" or "karpenter"

list_k8s_resources(
cluster_name="<cluster>",
kind="DaemonSet",
api_version="apps/v1",
namespace="kube-system"
)
# Look for "aws-node-termination-handler" DaemonSet mode

Analysis logic

If spot_node_count == 0:
→ Skip this check (no Spot nodes, not applicable)

If spot_node_count > 0:
karpenter_installed = (karpenter deployment exists and is ready)
nth_installed = (aws-node-termination-handler deployment/daemonset exists)

If karpenter_installed:
# Karpenter v0.30+ has native interruption handling
karpenter_version = parse version from deployment labels
If karpenter_version >= "0.30":
→ Interruption handling is built-in (OK)
# Verify SQS queue exists for optimal handling
If no SQS queue configured:
→ LOW finding: SQS queue recommended for faster interruption response
Else:
If NOT nth_installed:
→ HIGH finding: older Karpenter without NTH

If NOT karpenter_installed AND NOT nth_installed:
→ HIGH finding: Spot nodes without any interruption handler
# Risk: pods get hard-killed on Spot termination without graceful drain

If nth_installed:
→ OK (interruption handling present)

Severity classification

ConditionSeverity
Spot nodes present + no interruption handler (no Karpenter, no NTH)HIGH
Older Karpenter (< v0.30) without NTHHIGH
Karpenter v0.30+ without SQS queueLOW
NTH or Karpenter v0.30+ with SQS presentNo finding

Remediation

# Install AWS Node Termination Handler via Helm
helm repo add eks https://aws.github.io/eks-charts
helm repo update

helm install aws-node-termination-handler eks/aws-node-termination-handler \
--namespace kube-system \
--set enableSpotInterruptionDraining=true \
--set enableRebalanceRecommendation=true \
--set enableScheduledEventDraining=true
# For Karpenter — ensure SQS queue is configured (Terraform)
resource "aws_sqs_queue" "karpenter_interruption" {
name = "karpenter-${var.cluster_name}"
message_retention_seconds = 300
sqs_managed_sse_enabled = true
}

resource "aws_cloudwatch_event_rule" "spot_interruption" {
name = "karpenter-spot-interruption-${var.cluster_name}"
description = "Spot interruption events for Karpenter"
event_pattern = jsonencode({
source = ["aws.ec2"]
detail-type = ["EC2 Spot Instance Interruption Warning"]
})
}

resource "aws_cloudwatch_event_target" "spot_interruption" {
rule = aws_cloudwatch_event_rule.spot_interruption.name
target_id = "KarpenterInterruptionQueueTarget"
arn = aws_sqs_queue.karpenter_interruption.arn
}

Scoring Contribution

The Spot/Graviton adoption dimension has a maximum deduction of 20 points.

Deduction calculation

deduction = 0

For each finding in this dimension:
If severity == CRITICAL: deduction += 20 × 0.6 = 12
If severity == HIGH: deduction += 20 × 0.3 = 6
If severity == MEDIUM: deduction += 20 × 0.15 = 3
If severity == LOW: deduction += 20 × 0.05 = 1

actual_deduction = min(deduction, 20) # Cap at maximum

Dimension status

ConditionStatus
All checks completedASSESSED
No Spot nodes and no eligible workloadsASSESSED (no findings)
Cannot access node labels or AWS APISKIPPED

If the dimension is fully SKIPPED, it contributes zero deduction and is excluded from the score denominator.


Decision Tree

START

├─ Gather node inventory (architecture + capacity type labels)

├─ GRAVITON CHECKS
│ ├─ Calculate arm64 vs amd64 percentage (Check 1)
│ │ └─ If < 50% → HIGH finding
│ ├─ Identify node groups/NodePools without arm64 (Check 2)
│ │ └─ For each amd64-only group → MEDIUM finding
│ └─ Find workloads pinned to amd64 (Check 3)
│ └─ For each pinned workload → LOW/MEDIUM finding

├─ SPOT CHECKS
│ ├─ Calculate Spot vs On-Demand percentage (Check 4)
│ │ └─ If 0% Spot + eligible workloads → HIGH finding
│ ├─ Identify Spot-eligible workloads on On-Demand (Check 5)
│ │ ├─ Criteria: stateless + replicas ≥ 2 + has PDB
│ │ └─ For each eligible workload on OD → finding by waste $
│ ├─ Check instance type diversity for Spot (Check 6)
│ │ └─ If < 5 types or single family → MEDIUM finding
│ └─ Check interruption handling (Check 7)
│ └─ If Spot nodes + no handler → HIGH finding

└─ Aggregate findings → Calculate dimension deduction (capped at 20)

Common Scenarios

Scenario A: All On-Demand, No Graviton

  • Check 1: HIGH (0% Graviton)
  • Check 2: MEDIUM (all groups amd64-only)
  • Check 4: HIGH (0% Spot + eligible workloads)
  • Check 5: Multiple findings (eligible workloads on OD)
  • Check 7: Skipped (no Spot nodes)
  • Expected deduction: 12–20 points (likely capped at 20)

Scenario B: Karpenter with Spot + Graviton

  • Check 1: No finding (>80% Graviton)
  • Check 2: No finding (NodePools allow arm64)
  • Check 4: No finding (>30% Spot)
  • Check 6: No finding (broad instance categories)
  • Check 7: No finding (Karpenter v1 handles interruptions)
  • Expected deduction: 0 points

Scenario C: Spot Without Proper Handling

  • Check 4: No finding (Spot exists)
  • Check 6: MEDIUM (only 3 instance types)
  • Check 7: HIGH (no NTH, no Karpenter interruption queue)
  • Expected deduction: 6–9 points