LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Karpenter: Intelligent Node Autoscaling for Kubernetes

kuberneteskarpenterautoscalingawscost-optimizationdevopscloud

The Kubernetes Cluster Autoscaler has served clusters well for years, but it was designed around a fundamental constraint: it scales pre-defined Auto Scaling Groups (ASGs). When pods are pending, it picks an ASG, scales it up, waits for the node to join, and eventually the pod gets scheduled. This works, but it’s slow (2–10 minutes to get a node), inflexible (you’re locked into the instance types in each ASG), and wasteful (nodes are often over-provisioned to catch the next burst).

Karpenter is a CNCF graduated project (donated by AWS, now broadly adopted) that takes a completely different approach: it watches for unschedulable pods directly, computes the optimal instance type for those pods right now, and provisions that exact instance in under 60 seconds — often under 30. It doesn’t manage ASGs. It talks to the cloud provider API directly and consolidates nodes continuously to eliminate waste.

This guide covers Karpenter’s architecture, installation, NodePool configuration, spot instance handling, consolidation, and the patterns that make it genuinely transformative for cluster costs.

How Karpenter Differs from Cluster Autoscaler

The conceptual difference matters before diving into YAML.

Cluster Autoscaler:

  1. Detects pending pods
  2. Simulates each configured ASG to find one that would fit the pods
  3. Increments that ASG’s desired count
  4. Waits for the ASG to launch an instance and register with Kubernetes
  5. Kubelet starts, pods schedule

Karpenter:

  1. Detects pending pods
  2. Evaluates all instance types available in the region against pod requirements
  3. Picks the cheapest option that fits (right-sizing, not over-provisioning)
  4. Calls the EC2 API (or equivalent) directly to launch that instance with a pre-configured launch template
  5. Node registers via a fast-path bootstrap; pod schedules in ~30–60 seconds

The key differences:

Feature Cluster Autoscaler Karpenter
Instance selection Fixed ASG instance types All instance types in region
Provisioning time 2–10 minutes 30–90 seconds
Spot fallback Manual ASG configuration Automatic, multi-family
Bin-packing No Yes — consolidates underused nodes
Node deprovisioning Scale-down after 10+ min idle Aggressive consolidation
Configuration Per-ASG node groups Declarative NodePools
Overhead High (many node groups) Low (one controller)

Installing Karpenter on AWS EKS

Prerequisites

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Set environment variables
export CLUSTER_NAME="my-cluster"
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export AWS_REGION="us-east-1"
export KARPENTER_VERSION="1.1.0"
export KARPENTER_NAMESPACE="kube-system"

# Get your cluster endpoint
export CLUSTER_ENDPOINT=$(aws eks describe-cluster \
  --name "${CLUSTER_NAME}" \
  --query "cluster.endpoint" \
  --output text)

IAM Setup

Karpenter needs permissions to create and terminate EC2 instances on your behalf:

 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
36
37
38
# Create the Karpenter node IAM role
aws cloudformation deploy \
  --stack-name "Karpenter-${CLUSTER_NAME}" \
  --template-file "https://raw.githubusercontent.com/aws/karpenter-provider-aws/v${KARPENTER_VERSION}/website/content/en/preview/getting-started/getting-started-with-karpenter/cloudformation.yaml" \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides "ClusterName=${CLUSTER_NAME}"

# Grant the Karpenter node role access to join the cluster
eksctl create iamidentitymapping \
  --username system:node:{{EC2PrivateDNSName}} \
  --cluster "${CLUSTER_NAME}" \
  --arn "arn:aws:iam::${AWS_ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}" \
  --group system:bootstrappers \
  --group system:nodes

# Create the Karpenter controller IAM role (IRSA)
eksctl create iamserviceaccount \
  --cluster "${CLUSTER_NAME}" \
  --name karpenter \
  --namespace "${KARPENTER_NAMESPACE}" \
  --role-name "KarpenterControllerRole-${CLUSTER_NAME}" \
  --attach-policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/KarpenterControllerPolicy-${CLUSTER_NAME}" \
  --approve

# Tag the subnets and security groups so Karpenter can discover them
for SUBNET in $(aws ec2 describe-subnets \
  --filters "Name=tag:kubernetes.io/cluster/${CLUSTER_NAME},Values=owned,shared" \
  --query 'Subnets[].SubnetId' --output text); do
  aws ec2 create-tags --resources "${SUBNET}" \
    --tags "Key=karpenter.sh/discovery,Value=${CLUSTER_NAME}"
done

SECURITY_GROUP=$(aws eks describe-cluster \
  --name "${CLUSTER_NAME}" \
  --query "cluster.resourcesVpcConfig.clusterSecurityGroupId" \
  --output text)
aws ec2 create-tags --resources "${SECURITY_GROUP}" \
  --tags "Key=karpenter.sh/discovery,Value=${CLUSTER_NAME}"

Install with Helm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Install Karpenter
helm registry login public.ecr.aws --username AWS \
  --password $(aws ecr-public get-login-password --region us-east-1)

helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
  --version "${KARPENTER_VERSION}" \
  --namespace "${KARPENTER_NAMESPACE}" \
  --create-namespace \
  --set "settings.clusterName=${CLUSTER_NAME}" \
  --set "settings.interruptionQueue=${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
kubectl get pods -n kube-system -l app.kubernetes.io/name=karpenter

NodePool: Defining What Karpenter Can Provision

The NodePool resource is Karpenter’s core configuration. It defines constraints — which instance types, architectures, capacity types, and zones are allowed — and policies for node expiry and consolidation.

A Production-Ready NodePool

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# nodepools/general-purpose.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  # Template for nodes Karpenter provisions
  template:
    metadata:
      labels:
        karpenter.sh/nodepool: general-purpose
      # Taints can be added here if this pool is for specific workloads
      # taints:
      #   - key: dedicated
      #     value: general
      #     effect: NoSchedule

    spec:
      # Reference to EC2NodeClass (AWS-specific configuration)
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default

      requirements:
        # Allow both on-demand and spot
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]

        # Allow multiple architectures (Graviton is cheaper)
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]

        # Instance family restrictions — exclude GPU, bare metal, nano sizes
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]   # compute, general, memory optimized

        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["2"]             # Only gen 3+ instances

        # Minimum 2 vCPU, maximum 64 vCPU
        - key: karpenter.k8s.aws/instance-cpu
          operator: Gt
          values: ["1"]
        - key: karpenter.k8s.aws/instance-cpu
          operator: Lt
          values: ["65"]

        # Stay in the cluster's availability zones
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a", "us-east-1b", "us-east-1c"]

  # Limits: Karpenter won't provision beyond these totals
  limits:
    cpu: "1000"          # Total CPU across all nodes in this pool
    memory: 4000Gi       # Total memory limit

  # Disruption policy: how Karpenter removes nodes
  disruption:
    # Replace nodes after 720 hours (30 days) — forces OS/kernel updates
    expireAfter: 720h

    # Consolidation: aggressively bin-pack and remove underused nodes
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m   # How long to wait before consolidating

EC2NodeClass: AWS-Specific Configuration

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# nodeclasses/default.yaml
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  # AMI selection — use the latest EKS-optimized AMI for your cluster version
  amiSelectorTerms:
    - alias: al2023@latest   # Amazon Linux 2023, latest patch

  # Subnet selection via tags
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "my-cluster"

  # Security group selection
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "my-cluster"

  # IAM role for the nodes
  role: "KarpenterNodeRole-my-cluster"

  # Instance store: use NVMe ephemeral disks if available
  instanceStorePolicy: RAID0

  # User data runs on node boot (before kubelet starts)
  userData: |
    #!/bin/bash
    # Custom node bootstrap configuration
    cat <<EOF >> /etc/kubernetes/bootstrap.sh
    # Additional kubelet flags
    --max-pods=110
    EOF

  # EBS volume configuration
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 100Gi
        volumeType: gp3
        iops: 3000
        throughput: 125
        encrypted: true
        deleteOnTermination: true

  # Metadata options — disable IMDSv1 for security
  metadataOptions:
    httpEndpoint: enabled
    httpProtocolIPv6: disabled
    httpPutResponseHopLimit: 1    # Prevents SSRF from containers
    httpTokens: required          # Force IMDSv2

  tags:
    Team: platform
    ManagedBy: karpenter

Spot Instance Handling

Spot instances can be 60–90% cheaper than on-demand. Karpenter handles spot interruptions gracefully, but the configuration needs to be right.

Multi-Family Spot for Maximum Availability

The key to reliable spot is requesting many instance families simultaneously. If one family is interrupted or unavailable, Karpenter falls back to another:

 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
36
37
38
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: spot-workers
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        # Spot only for this pool
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot"]

        # Wide instance family selection — more diversity = more availability
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values:
            - m5, m5a, m5n, m5d
            - m6i, m6a, m6in
            - m7i, m7a, m7g       # m7g is Graviton (arm64)
            - c5, c5a, c5n
            - c6i, c6a, c6in
            - c7i, c7a, c7g
            - r5, r5a, r5n
            - r6i, r6a

        # 4-32 vCPU range gives Karpenter lots of flexibility
        - key: karpenter.k8s.aws/instance-cpu
          operator: In
          values: ["4", "8", "16", "32"]

  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30s    # Aggressive for spot — reclaim quickly

Spot Interruption Handling

When AWS reclaims a spot instance, it sends a 2-minute warning. Karpenter watches for these via an SQS queue and proactively drains the node before the instance terminates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Create the interruption queue (referenced in helm install above)
aws sqs create-queue \
  --queue-name "${CLUSTER_NAME}" \
  --attributes '{
    "MessageRetentionPeriod": "300"
  }'

# Subscribe to EC2 spot interruption events
aws events put-rule \
  --name "KarpenterInterruptionRule" \
  --event-pattern '{
    "source": ["aws.ec2"],
    "detail-type": ["EC2 Spot Instance Interruption Warning",
                    "EC2 Instance Rebalance Recommendation",
                    "EC2 Instance State-change Notification"]
  }'

Karpenter then:

  1. Receives the 2-minute warning via SQS
  2. Cordons the node immediately
  3. Launches a replacement node (on-demand if spot is unavailable)
  4. Drains workloads to the new node
  5. All within the 2-minute window in most cases

Workloads That Should Use Spot

Not every workload is spot-appropriate. Use node selectors and tolerations to control placement:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Stateless workers — perfect for spot
apiVersion: apps/v1
kind: Deployment
metadata:
  name: image-processor
spec:
  template:
    spec:
      # Request spot nodes
      nodeSelector:
        karpenter.sh/capacity-type: spot
      # Tolerate spot interruptions gracefully
      terminationGracePeriodSeconds: 90
      containers:
        - name: processor
          image: my-image-processor:latest
          # Implement checkpoint/resume logic
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Stateful or latency-sensitive — force on-demand
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
spec:
  template:
    spec:
      nodeSelector:
        karpenter.sh/capacity-type: on-demand
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: karpenter.sh/capacity-type
                    operator: In
                    values: ["on-demand"]

Multiple NodePools for Different Workload Types

Real clusters have diverse workload requirements. Use multiple NodePools with weights and requirements to segment them:

 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
# nodepools/compute-optimized.yaml
# For CPU-heavy workloads: data processing, compilation, ML inference
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: compute-optimized
spec:
  template:
    metadata:
      labels:
        workload-type: compute
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      taints:
        - key: workload-type
          value: compute
          effect: NoSchedule
      requirements:
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c"]        # Compute-optimized only
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 2m
  weight: 50    # Higher weight = preferred when multiple pools match
 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
# nodepools/memory-optimized.yaml
# For Redis, Elasticsearch, in-memory databases
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: memory-optimized
spec:
  template:
    metadata:
      labels:
        workload-type: memory
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      taints:
        - key: workload-type
          value: memory
          effect: NoSchedule
      requirements:
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["r"]        # Memory-optimized: r5, r6i, r7g...
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]   # Databases need stability
  disruption:
    # Don't consolidate memory nodes as aggressively
    consolidationPolicy: WhenEmpty
    expireAfter: 720h
 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
# nodepools/gpu.yaml
# For ML training and inference
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu
spec:
  template:
    metadata:
      labels:
        workload-type: gpu
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: gpu
      taints:
        - key: nvidia.com/gpu
          effect: NoSchedule
      requirements:
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["p3", "p4d", "g4dn", "g5"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
  limits:
    cpu: "200"
    memory: 800Gi
    nvidia.com/gpu: "20"    # Limit total GPUs in pool

Workloads opt into a specific pool with tolerations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ML training job that uses GPU nodes
apiVersion: batch/v1
kind: Job
metadata:
  name: model-training
spec:
  template:
    spec:
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
        - key: workload-type
          value: gpu
          effect: NoSchedule
      nodeSelector:
        workload-type: gpu
      containers:
        - name: trainer
          image: my-training:latest
          resources:
            limits:
              nvidia.com/gpu: "1"

Consolidation: Eliminating Waste

Karpenter’s consolidation is one of its most powerful features. After the initial burst scales up nodes, Karpenter continuously evaluates whether nodes can be packed more efficiently and terminates the underused ones.

How Consolidation Works

Karpenter’s consolidation algorithm runs on a loop:

  1. Find nodes that are candidates (not hosting PDBs preventing disruption, not with do-not-disrupt annotation)
  2. Simulate evicting all pods from the candidate node
  3. Check if those pods could schedule on remaining nodes
  4. If yes, cordon and drain the candidate, terminate the instance

With WhenEmptyOrUnderutilized, Karpenter will replace two smaller underused nodes with one larger node if it’s cheaper — actively bin-packing your workloads.

Protecting Workloads from Disruption

Use PodDisruptionBudgets to ensure consolidation doesn’t violate availability requirements:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Karpenter respects PDBs during consolidation
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
spec:
  minAvailable: 2      # Always keep 2 replicas up
  selector:
    matchLabels:
      app: api

For workloads that must never be disrupted (long-running batch jobs):

1
2
3
4
5
6
# Annotate the pod to prevent Karpenter from disrupting it
apiVersion: v1
kind: Pod
metadata:
  annotations:
    karpenter.sh/do-not-disrupt: "true"   # Karpenter will not evict this pod

Consolidation Tuning

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
spec:
  disruption:
    # WhenEmpty: only consolidate completely empty nodes (safest)
    # WhenEmptyOrUnderutilized: actively re-pack pods (most savings)
    consolidationPolicy: WhenEmptyOrUnderutilized

    # How long a node must be consolidation-eligible before action is taken
    # Shorter = more aggressive savings, more pod disruption
    consolidateAfter: 1m

    # Node max age — forces replacement even if busy (keeps AMIs fresh)
    expireAfter: 720h   # 30 days

Scheduling Best Practices for Karpenter

Topology Spread Constraints

Tell Karpenter how to spread pods across zones and nodes for HA:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 6
  template:
    spec:
      topologySpreadConstraints:
        # Spread evenly across availability zones
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: api
        # No more than 2 replicas per node
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: api

Karpenter uses these constraints when deciding how many nodes to provision and where — it will provision nodes in the required zones to satisfy the spread.

Resource Requests: The Foundation of Good Scheduling

Karpenter’s bin-packing is only as good as your resource requests. Pods with no requests will be packed onto nodes with no regard for actual usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
containers:
  - name: api
    resources:
      requests:
        # Set requests at the p95 of actual usage
        # Use kubectl top pods or VPA recommendations
        cpu: "500m"
        memory: "512Mi"
      limits:
        # Set limits higher — allow bursting
        cpu: "2"
        memory: "1Gi"

Use the Vertical Pod Autoscaler in recommendation mode to get request suggestions based on actual usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  updatePolicy:
    updateMode: "Off"   # Recommendation only — don't auto-update
1
2
3
# Check VPA recommendations
kubectl describe vpa api-vpa
# Look for: Recommendation > Container Recommendations > Target

Drift Detection and Node Replacement

Karpenter detects when nodes have drifted from their NodePool or EC2NodeClass specification and replaces them automatically. This is invaluable for:

  • AMI updates: when you update the amiSelectorTerms to a new AMI, Karpenter automatically rolls nodes to the new AMI using a rolling replacement strategy
  • NodePool changes: update instance requirements, and Karpenter replaces nodes that no longer match
  • EC2NodeClass changes: update security groups or user data, nodes are rolled
1
2
3
4
5
6
7
# Trigger a manual drift-based replacement by annotating nodes
kubectl annotate node <node-name> \
  karpenter.sh/disruption=drifted

# Or force immediate node replacement (use carefully in production)
kubectl delete node <node-name>
# Karpenter provisions a replacement before the workloads are disrupted

Observability and Debugging

Key Metrics

Karpenter exposes Prometheus metrics on port 8000:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# prometheus scrape config
- job_name: karpenter
  kubernetes_sd_configs:
    - role: endpoints
      namespaces:
        names: [kube-system]
  relabel_configs:
    - source_labels: [__meta_kubernetes_service_name]
      action: keep
      regex: karpenter

Key metrics to dashboard:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Nodes provisioned over time
sum(karpenter_nodes_created_total) by (nodepool)

# Current node count by pool and capacity type
sum(karpenter_nodes_total) by (nodepool, capacity_type)

# Pod scheduling latency (time from pending to scheduled)
histogram_quantile(0.99, rate(karpenter_pods_startup_duration_seconds_bucket[5m]))

# Interruptions handled
sum(rate(karpenter_interruption_actions_performed_total[5m])) by (action)

# Consolidation actions
sum(rate(karpenter_disruption_actions_performed_total[5m])) by (disruption_action)

Debugging Unschedulable Pods

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Check why Karpenter isn't provisioning a node
kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter \
  --since=5m | grep -i "failed\|error\|unable\|cannot"

# Check the NodePool's current status
kubectl get nodepool general-purpose -o yaml | grep -A 20 status

# See what Karpenter computed for a pending pod
kubectl describe pod <pending-pod>
# Look for: Events, specifically "FailedScheduling" messages

# List all nodes Karpenter manages
kubectl get nodes -l karpenter.sh/nodepool

# See disruption decisions
kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter \
  | grep -i "disruption\|consolidat\|drift"

Common Issues

Pods stay pending despite Karpenter running:

  • Check that pod requirements (node selectors, tolerations) match the NodePool requirements
  • Verify subnet/security group tags are correct
  • Check IAM permissions with aws iam simulate-principal-policy
  • Look for limits in the NodePool that are already hit

Nodes launch but pods don’t schedule:

  • Instance profile / node IAM role not mapped to aws-auth ConfigMap
  • VPC CNI can’t assign enough IPs (check kubectl describe ds aws-node)

Consolidation too aggressive (too many pod restarts):

  • Add PodDisruptionBudgets to critical workloads
  • Increase consolidateAfter to give pods more time
  • Use karpenter.sh/do-not-disrupt: "true" on long-running jobs

Cost Analysis: Karpenter vs Cluster Autoscaler

Real-world data from teams that switched consistently shows 20–60% cost reduction. Here’s why:

Right-sizing at provision time: Cluster Autoscaler picks a pre-defined node type from an ASG. If you have a 4-CPU ASG and a pod requests 3.5 CPU, you waste 0.5 CPU per pod. Karpenter selects the exact instance size for the workload.

Spot diversity: A typical Cluster Autoscaler spot setup has 3–5 instance types per ASG. Karpenter considers all available instance types and selects based on current spot pricing — typically finding 20–40% cheaper spot options.

Aggressive consolidation: Cluster Autoscaler removes nodes after 10+ minutes of underutilization (configurable, but slow by default). Karpenter consolidates within minutes, continuously rebinning workloads.

No over-provisioned node groups: Teams running Cluster Autoscaler typically create many ASGs to cover different workload types. Each ASG needs buffer capacity. Karpenter’s flexible provisioning eliminates this overhead.

A rough estimation for a team spending $10,000/month on EC2:

Strategy Monthly Cost
Cluster Autoscaler (on-demand only) $10,000
Cluster Autoscaler (with spot, 3 families) $6,000
Karpenter (on-demand, right-sized) $7,500
Karpenter (spot + on-demand, many families) $2,500–$3,500

The combination of right-sizing and broad spot family selection typically yields the biggest savings.

Production Checklist

Before running Karpenter in production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# checklist.md
- [ ] IMDSv2 required in EC2NodeClass (httpTokens: required)
- [ ] EBS volumes encrypted in EC2NodeClass
- [ ] NodePool limits set (prevent runaway scaling)
- [ ] PodDisruptionBudgets on all critical workloads
- [ ] Spot interruption queue configured and monitored
- [ ] Karpenter pods running on dedicated on-demand nodes
       (use nodeAffinity to keep Karpenter off spot)
- [ ] expireAfter set (forces AMI updates)
- [ ] VPA recommendations reviewed for major workloads
- [ ] Karpenter metrics dashboard created
- [ ] Alert on karpenter_nodes_total approaching limit
- [ ] do-not-disrupt annotation on batch jobs
- [ ] Tested spot interruption behavior in staging

Keep Karpenter itself on stable on-demand nodes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Keep Karpenter off spot to avoid self-disruption
apiVersion: apps/v1
kind: Deployment
metadata:
  name: karpenter
  namespace: kube-system
spec:
  template:
    spec:
      nodeSelector:
        karpenter.sh/capacity-type: on-demand
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: karpenter.sh/nodepool
                    operator: DoesNotExist   # Run on a managed node group

Conclusion

Karpenter changes the economics of running Kubernetes in the cloud. By talking directly to the EC2 API with full knowledge of instance pricing and availability, it consistently makes better provisioning decisions than a human operator managing ASGs — faster, cheaper, and with less operational overhead.

The configuration surface is small: a NodePool describing what you want, an EC2NodeClass describing the AWS-specific details, and PodDisruptionBudgets protecting your critical workloads. Within those constraints, Karpenter handles everything else: instance selection, spot interruptions, node consolidation, drift detection, and AMI rolling.

If you’re running EKS (or AKS, GKE via the cloud-provider ports) and still using Cluster Autoscaler, migrating to Karpenter is one of the highest-ROI changes you can make. The 30–60% cost reduction pays for the migration effort within weeks.

Comments