AWS EKS Karpenter: Intelligent Kubernetes Node Autoscaling and Provisioning

Posted: | Last updated: | 9 minute read

AWS EKS Karpenter: Intelligent Kubernetes node autoscaling with dynamic provisioning and cost optimization

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:

  1. Pods enter pending state (no capacity)
  2. Karpenter sees unschedulable pods within 10 seconds
  3. Evaluates pod resource requests, node selectors, affinities, taints/tolerations
  4. Picks optimal instance type(s) from provisioner configuration
  5. Launches EC2 instance(s) directly via AWS APIs
  6. Instance bootstraps, joins cluster in ~30-45 seconds
  7. Scheduler places pods on new nodes
  8. 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

  1. 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.

  2. Use spot instances for fault-tolerant workloads — Set karpenter.sh/capacity-type: spot in pod node affinity. Karpenter handles interruptions gracefully. We run 70% of our batch jobs on spot with zero manual intervention.

  3. 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.

  4. Enable consolidation for cost savings — consolidationPolicy: WhenUnderutilized lets Karpenter replace 3 half-empty nodes with 1 full node. We’ve seen 25-35% cost reduction from consolidation alone.

  5. 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.

  6. 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.

  7. 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.

  8. 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.