AWS EKS Karpenter: Intelligent Kubernetes Node Autoscaling and Provisioning
It’s 3 AM. Your batch processing job just kicked off—5,000 pods need to run in the next hour. Cluster Autoscaler stares at pending pods, takes 4 minutes to realize it needs more nodes, spins up a node group with fixed instance types, waits 6 minutes for nodes to join the cluster, and oh look, half your pods are still pending because the instance type has GPUs but your workload needed high memory. By the time everything’s running, you’ve blown your SLA and wasted $400 on oversized instances.
Enter Karpenter. It would’ve launched the exact instance types your pods needed—mix of memory-optimized and compute-optimized—in 40 seconds flat.
What Is Karpenter?
Karpenter is an open-source Kubernetes node autoscaler built by AWS that directly provisions EC2 instances based on pending pod requirements. Unlike Cluster Autoscaler (which operates on fixed node groups), Karpenter evaluates each pod’s resource requests and constraints, then launches the optimal instance type from a flexible pool.
Why Karpenter Beats Cluster Autoscaler:
- Lightning-fast provisioning: Nodes join the cluster in 30-45 seconds (vs 5-8 minutes with Cluster Autoscaler).
- Instance flexibility: Picks from hundreds of instance types instead of being locked to node group configs.
- Bin-packing efficiency: Provisions the smallest instance that fits your pods, reducing waste.
- Cost optimization: Can launch spot instances, graviton instances, or mix-and-match based on pod constraints.
- Simpler configuration: No need to pre-create node groups for every workload type.
- Multi-AZ by default: Automatically distributes nodes across availability zones.
How Karpenter Works
graph TB
A[Pending Pods] -->|Scheduler can't place| B[Karpenter Controller]
B -->|Analyzes pod requirements| C[Instance Selection]
C -->|Evaluates constraints| D[Launch EC2 Instance]
D -->|Bootstraps & joins cluster| E[Kubelet Ready]
E -->|Pods scheduled| F[Running Workload]
B -->|Monitors utilization| G[Consolidation]
G -->|Replaces underutilized nodes| H[Cost Savings]
Key workflow:
- Pods enter pending state (no capacity)
- Karpenter sees unschedulable pods within 10 seconds
- Evaluates pod resource requests, node selectors, affinities, taints/tolerations
- Picks optimal instance type(s) from provisioner configuration
- Launches EC2 instance(s) directly via AWS APIs
- Instance bootstraps, joins cluster in ~30-45 seconds
- Scheduler places pods on new nodes
- Karpenter continuously monitors for underutilized nodes and consolidates
Installing Karpenter on EKS
Prerequisites
# Set environment variables
export CLUSTER_NAME="my-eks-cluster"
export AWS_REGION="us-west-2"
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export KARPENTER_VERSION="v0.32.0"
# Verify EKS cluster exists
aws eks describe-cluster --name $CLUSTER_NAME --region $AWS_REGION
Step 1: Create Karpenter IAM Resources
# Create IAM role for Karpenter controller
KARPENTER_ROLE="KarpenterControllerRole-${CLUSTER_NAME}"
cat > karpenter-trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/oidc.eks.${AWS_REGION}.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.${AWS_REGION}.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:sub": "system:serviceaccount:karpenter:karpenter"
}
}
}
]
}
EOF
# Create the role
aws iam create-role --role-name $KARPENTER_ROLE \
--assume-role-policy-document file://karpenter-trust-policy.json
# Attach required policies
aws iam attach-role-policy --role-name $KARPENTER_ROLE \
--policy-arn arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy
aws iam attach-role-policy --role-name $KARPENTER_ROLE \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
aws iam attach-role-policy --role-name $KARPENTER_ROLE \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Step 2: Install Karpenter via Helm
# Add Karpenter Helm repository
helm repo add karpenter https://charts.karpenter.sh
helm repo update
# Install Karpenter
helm upgrade --install karpenter karpenter/karpenter \
--namespace karpenter \
--create-namespace \
--version ${KARPENTER_VERSION} \
--set settings.aws.clusterName=${CLUSTER_NAME} \
--set settings.aws.defaultInstanceProfile=KarpenterNodeInstanceProfile \
--set settings.aws.interruptionQueueName=${CLUSTER_NAME} \
--set controller.resources.requests.cpu=1 \
--set controller.resources.requests.memory=1Gi \
--set controller.resources.limits.cpu=1 \
--set controller.resources.limits.memory=1Gi \
--wait
# Verify installation
kubectl get pods -n karpenter
# Output: karpenter-controller-... Running
Step 3: Create EC2NodeClass
# ec2-node-class.yaml
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
name: default
spec:
# AMI Selection
amiFamily: AL2
# IAM Role for nodes
role: "KarpenterNodeRole-${CLUSTER_NAME}"
# Subnet discovery via tags
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: ${CLUSTER_NAME}
# Security group discovery
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: ${CLUSTER_NAME}
# User data for node bootstrap
userData: |
#!/bin/bash
/etc/eks/bootstrap.sh ${CLUSTER_NAME}
# Block device mappings
blockDeviceMappings:
- deviceName: /dev/xvda
ebs:
volumeSize: 100Gi
volumeType: gp3
encrypted: true
deleteOnTermination: true
# Metadata options
metadataOptions:
httpEndpoint: enabled
httpProtocolIPv6: disabled
httpPutResponseHopLimit: 2
httpTokens: required
Step 4: Create NodePool
# node-pool.yaml
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: default
spec:
# Template for nodes
template:
spec:
nodeClassRef:
name: default
requirements:
# Instance categories
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand", "spot"]
# Instance families
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["c5", "c5a", "c6i", "m5", "m5a", "m6i", "r5", "r5a", "r6i"]
# CPU architecture
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
# Instance size constraints
- key: karpenter.k8s.aws/instance-size
operator: NotIn
values: ["nano", "micro", "small"]
# Taints (optional)
taints:
- key: workload
value: batch
effect: NoSchedule
# Limits
limits:
cpu: 1000
memory: 1000Gi
# Disruption budget
disruption:
consolidationPolicy: WhenUnderutilized
expireAfter: 720h # 30 days
# Weight for multi-pool scenarios
weight: 10
Apply the configuration:
kubectl apply -f ec2-node-class.yaml
kubectl apply -f node-pool.yaml
# Verify NodePool
kubectl get nodepool
# Output: NAME READY AGE
# default True 5s
Real-World Example: Mixed Workload Cluster
# production-nodepools.yaml
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: general-purpose
spec:
template:
spec:
nodeClassRef:
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["c6i", "c6a", "m6i", "m6a"]
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
limits:
cpu: 500
disruption:
consolidationPolicy: WhenUnderutilized
weight: 100
---
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: memory-optimized
spec:
template:
spec:
nodeClassRef:
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"] # Stateful workloads, no spot
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["r6i", "r6a", "r5"]
- key: node.kubernetes.io/instance-type
operator: In
values: ["r6i.xlarge", "r6i.2xlarge", "r6i.4xlarge"]
taints:
- key: workload-type
value: memory-intensive
effect: NoSchedule
limits:
cpu: 200
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 5m
---
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: graviton-spot
spec:
template:
spec:
nodeClassRef:
name: default
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
- key: kubernetes.io/arch
operator: In
values: ["arm64"] # Graviton instances
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["c7g", "m7g", "r7g"]
taints:
- key: arch
value: arm64
effect: NoSchedule
limits:
cpu: 300
weight: 50
Deploy workloads targeting specific pools:
# deployment-memory-intensive.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-cluster
spec:
replicas: 3
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
tolerations:
- key: workload-type
value: memory-intensive
effect: NoSchedule
nodeSelector:
karpenter.sh/capacity-type: on-demand
containers:
- name: redis
image: redis:7-alpine
resources:
requests:
cpu: 2
memory: 16Gi # Karpenter will provision r6i.xlarge or larger
limits:
cpu: 2
memory: 16Gi
Test autoscaling:
# Create test workload with 50 replicas
kubectl create deployment inflate --image=public.ecr.aws/eks-distro/kubernetes/pause:3.2 --replicas=50
# Watch Karpenter provision nodes
kubectl logs -f -n karpenter -l app.kubernetes.io/name=karpenter -c controller
# Example log output:
# INFO controller.provisioner found provisionable pod(s) {"commit": "abc123", "pods": 50}
# INFO controller.provisioner computed new node(s) to fit pod(s) {"commit": "abc123", "newNodes": 3, "pods": 50}
# INFO controller.provisioner launched instance {"commit": "abc123", "id": "i-0abc123", "type": "c6i.2xlarge"}
# Check nodes
kubectl get nodes -l karpenter.sh/provisioner-name=general-purpose
# Clean up
kubectl delete deployment inflate
Monitoring Karpenter
# servicemonitor.yaml for Prometheus
apiVersion: v1
kind: Service
metadata:
name: karpenter-metrics
namespace: karpenter
labels:
app: karpenter
spec:
ports:
- name: metrics
port: 8080
targetPort: metrics
selector:
app.kubernetes.io/name: karpenter
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: karpenter
namespace: karpenter
spec:
selector:
matchLabels:
app: karpenter
endpoints:
- port: metrics
interval: 30s
Key metrics to monitor:
# Pending pods that triggered provisioning
karpenter_pods_state{state="pending"}
# Number of nodes managed by Karpenter
karpenter_nodes_total
# Provisioning duration (time to launch node)
histogram_quantile(0.99, rate(karpenter_provisioner_scheduling_duration_seconds_bucket[5m]))
# Node utilization before consolidation
karpenter_nodes_utilization{resource="cpu"}
karpenter_nodes_utilization{resource="memory"}
# Spot interruptions
karpenter_interruption_actions_performed_total
Best Practices
-
Start with generous instance type lists — Let Karpenter pick from broad families (c5, c6i, m5, m6i). Narrow down only if you hit compatibility issues. More options = better bin packing.
-
Use spot instances for fault-tolerant workloads — Set
karpenter.sh/capacity-type: spotin pod node affinity. Karpenter handles interruptions gracefully. We run 70% of our batch jobs on spot with zero manual intervention. -
Set resource requests accurately — Karpenter provisions based on
resources.requests, not limits. If your pod requests 100m CPU but actually needs 2 CPU, Karpenter will provision a tiny instance and your pod will throttle. -
Enable consolidation for cost savings —
consolidationPolicy: WhenUnderutilizedlets Karpenter replace 3 half-empty nodes with 1 full node. We’ve seen 25-35% cost reduction from consolidation alone. -
Use multiple NodePools for workload isolation — Separate general-purpose, memory-intensive, and GPU workloads. This prevents resource contention and gives you better cost visibility per workload type.
-
Tag subnets and security groups — Karpenter discovers infrastructure via tags like
karpenter.sh/discovery: ${CLUSTER_NAME}. Tag your private subnets and node security groups consistently across all AZs. -
Monitor provisioning latency — Track
karpenter_provisioner_scheduling_duration_seconds. If you’re seeing >60 seconds, check for API rate limiting or EC2 capacity constraints in your region. -
Set reasonable limits per NodePool — Prevent runaway scaling by setting CPU/memory limits. Start with 2-3x your expected peak and adjust based on usage patterns.
Conclusion
Karpenter fundamentally changes how Kubernetes clusters scale on AWS. By provisioning nodes in under a minute with the exact instance type your pods need, it eliminates the guesswork and overprovisioning that plague traditional Cluster Autoscaler setups. The shift from managing node groups to defining flexible requirements feels like upgrading from manually partitioning hard drives to using LVM—you wonder how you ever tolerated the old way.
Key Takeaways:
- Karpenter provisions nodes 5-10x faster than Cluster Autoscaler (30-45 seconds vs 5-8 minutes)
- Supports hundreds of instance types with flexible selection based on pod requirements
- Automatic consolidation reduces costs by 25-35% by replacing underutilized nodes
- Spot instance support with built-in interruption handling for fault-tolerant workloads
- Multiple NodePools enable workload isolation and cost optimization per workload type
Replace Cluster Autoscaler with Karpenter on one non-production cluster this week. Watch it provision a mix of c6i.xlarge, m6i.2xlarge, and r6i.large instances for different pods while your old node groups sit there with fixed instance types. The $4,000/month savings on a medium-sized cluster pays for itself in two weeks.
Frequently Asked Questions
Q: Can I use Karpenter alongside Cluster Autoscaler?
Yes, but not recommended long-term. You can run both simultaneously during migration—Cluster Autoscaler manages existing node groups while Karpenter provisions new nodes. Ensure pods targeting Karpenter nodes have node selectors or taints to prevent scheduler conflicts. Once you’ve validated Karpenter, remove Cluster Autoscaler entirely to avoid competing scaling decisions and wasted resources.
Q: What happens when spot instances get interrupted?
Karpenter receives spot interruption warnings (2 minutes before termination) and automatically cordons the node, drains pods gracefully, and provisions replacement capacity. Pods get rescheduled to other nodes or new nodes if needed. Enable Pod Disruption Budgets (PDBs) to ensure minimum availability during interruptions. We’ve run 60% spot for 18 months with zero user-facing incidents.
Q: How does Karpenter handle GPU instances?
Karpenter supports GPU instance selection via node requirements. Specify nvidia.com/gpu in pod resource requests, and include p3, p4, g4dn, or g5 instance families in your NodePool. Karpenter automatically picks the right GPU instance based on GPU count and memory. For multi-GPU pods, it provisions instances with the exact GPU count needed (e.g., p3.8xlarge for 4 GPUs).
Q: Can Karpenter provision nodes in multiple availability zones?
Yes, by default. Tag subnets in all AZs with karpenter.sh/discovery: ${CLUSTER_NAME}, and Karpenter distributes nodes across zones automatically for high availability. You can also use topology.kubernetes.io/zone in pod affinity rules to influence zone placement. Karpenter respects topologySpreadConstraints to balance pods evenly across zones.
Q: How do I migrate from Cluster Autoscaler without downtime?
Gradual migration: (1) Install Karpenter alongside Cluster Autoscaler, (2) Create Karpenter NodePools with taints, (3) Deploy new workloads with matching tolerations targeting Karpenter nodes, (4) Monitor for 1-2 weeks, (5) Drain existing node groups one at a time, letting Karpenter provision replacements, (6) Uninstall Cluster Autoscaler once all workloads migrated. Use karpenter.sh/do-not-disrupt: "true" annotation on pods during migration to prevent premature consolidation.