How to Set Up Karpenter on EKS: NodePools, Spot and Consolidation (2026)

Oct 31, 2026 · 14 min · Ajay Kumar

This guide is for platform engineers who already run an EKS cluster, on fixed managed node groups or the Cluster Autoscaler, and want Karpenter to choose and replace nodes instead. At the end you will have the Karpenter 1.15.1 controller (released on 23 October 2026) running on a small managed node group with EKS Pod Identity credentials, Spot interruption handling through SQS, one EC2NodeClass pinned to an AL2023 AMI release, one NodePool that prefers Spot and falls back to on-demand, consolidation with disruption budgets, and the Cluster Autoscaler switched off. The commands follow the official getting started and migration docs for 1.15, adapted for an existing cluster. I rendered the 1.15.1 Helm chart locally and checked every NodePool and EC2NodeClass field below against the v1.15.1 CRD schemas; I did not apply them to a live account for this post, so run them in a non-production cluster first. Prerequisites: AWS CLI 2.36.21 or later, kubectl, Helm 3.8 or later (for OCI charts), IAM rights to create roles and CloudFormation stacks, and an EKS cluster on Kubernetes 1.31 to 1.37. Karpenter's compatibility matrix requires 1.15.x for Kubernetes 1.37.

Karpenter, EKS Auto Mode or Cluster Autoscaler?

Cluster Autoscaler scales node groups you defined in advance. Karpenter looks at pending pods, picks instance types that fit them, launches them directly through EC2, and later removes or replaces nodes to cut cost. EKS Auto Mode is Karpenter run by AWS, with locked-down Bottlerocket nodes and a per-instance fee; if you are on Auto Mode, do not install Karpenter yourself. I compared the three providers' managed modes in EKS vs GKE vs AKS in 2026. Self-managed Karpenter makes sense when you want custom AMIs, SSH or SSM access to nodes, or no management fee. For the wider autoscaling picture, see Kubernetes Autoscaling Explained.

How Karpenter 1.15 provisions and removes nodes Pending pods no node fits requests Karpenter controller batches pods, simulates NodePools and EC2NodeClass requirements EC2 CreateFleet price-capacity-optimized Spot first on-demand fallback if the NodePool allows instance boots, joins SQS queue Spot 2-minute warning, health and state events Disruption controller consolidation, drift, repair expiry and interruption drain too NodeClaim becomes a Node registers, Ready, startup taints removed kube-scheduler binds the pending pods pre-spins a replacement Graceful methods (consolidation, drift, repair) respect budgets (default 10% of nodes) and PDBs. Forceful methods (expiry, interruption) do not wait for budgets. Spot gives a 2-minute notice.
Control flow as described in the Karpenter v1.15 NodePool and disruption docs; the SQS queue and its EventBridge rules come from the getting-started CloudFormation template.

Step 1: Set the variables and pin versions

Every later command reads these. If you open a new shell, set them again.

export CLUSTER_NAME="my-cluster"          # your existing EKS cluster
export AWS_DEFAULT_REGION="eu-west-1"
export AWS_PARTITION="aws"                # aws-cn or aws-us-gov in those partitions
export KARPENTER_NAMESPACE="kube-system"  # keeps Karpenter in the high-priority API FlowSchemas
export KARPENTER_VERSION="1.15.1"
export AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
export K8S_VERSION="$(aws eks describe-cluster --name "${CLUSTER_NAME}" \
  --query cluster.version --output text)"

# Resolve the current EKS-optimized AL2023 release for this Kubernetes version
# and keep only its tag (vYYYYMMDD), so the NodeClass pins an exact AMI release.
export ALIAS_VERSION="$(aws ssm get-parameter \
  --name "/aws/service/eks/optimized-ami/${K8S_VERSION}/amazon-linux-2023/x86_64/standard/recommended/image_id" \
  --query Parameter.Value | xargs aws ec2 describe-images --query 'Images[0].Name' --image-ids \
  | sed -r 's/^.*(v[[:digit:]]+).*$/\1/')"

echo "${K8S_VERSION} ${ALIAS_VERSION}"   # expect a version like 1.36 and a tag like v2026MMDD

Installing into kube-system matters more than it looks: the docs note that it puts Karpenter's API calls into the system-leader-election and kube-system-service-accounts FlowSchemas, so it is not throttled with ordinary workloads. Elsewhere you must create your own FlowSchemas.

Step 2: Create the node role, controller policies and SQS queue

Karpenter publishes a CloudFormation template per release. Download it at your pinned tag and deploy it.

curl -fsSL "https://raw.githubusercontent.com/aws/karpenter-provider-aws/v${KARPENTER_VERSION}/website/content/en/preview/getting-started/getting-started-with-karpenter/cloudformation.yaml" \
  -o karpenter-cfn.yaml

aws cloudformation deploy \
  --stack-name "Karpenter-${CLUSTER_NAME}" \
  --template-file karpenter-cfn.yaml \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides "ClusterName=${CLUSTER_NAME}"

# Accounts that have never launched Spot need this role once. "has been taken" is fine.
aws iam create-service-linked-role --aws-service-name spot.amazonaws.com || true

For 1.15.1 the stack creates KarpenterNodeRole-${CLUSTER_NAME} (with AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryPullOnly and AmazonSSMManagedInstanceCore), six controller policies named KarpenterController{NodeLifecycle,IAMIntegration,EKSIntegration,Interruption,ResourceDiscovery,ZonalShift}Policy-${CLUSTER_NAME}, an SQS queue named after the cluster, and EventBridge rules for scheduled changes, Spot interruptions, rebalance recommendations, instance state changes and capacity reservation interruptions. The EC2 actions in those policies are scoped by the kubernetes.io/cluster/${CLUSTER_NAME} and karpenter.sh/nodepool tags, so anyone who can write those tags on instances can steer Karpenter. I would restrict ec2:CreateTags on those keys for every other principal in the account.

Step 3: Give the controller AWS credentials with EKS Pod Identity

EKS Pod Identity needs no OIDC provider: the role trusts pods.eks.amazonaws.com, and an association binds it to the karpenter service account.

# Install the Pod Identity agent add-on (skip if the cluster already has it)
aws eks create-addon --cluster-name "${CLUSTER_NAME}" --addon-name eks-pod-identity-agent

cat > karpenter-controller-trust.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "pods.eks.amazonaws.com" },
    "Action": ["sts:AssumeRole", "sts:TagSession"],
    "Condition": {
      "StringEquals": {
        "aws:RequestTag/kubernetes-namespace": "${KARPENTER_NAMESPACE}",
        "aws:RequestTag/kubernetes-service-account": "karpenter"
      }
    }
  }]
}
EOF

aws iam create-role --role-name "KarpenterControllerRole-${CLUSTER_NAME}" \
  --assume-role-policy-document file://karpenter-controller-trust.json

for p in NodeLifecycle IAMIntegration EKSIntegration Interruption ResourceDiscovery ZonalShift; do
  aws iam attach-role-policy --role-name "KarpenterControllerRole-${CLUSTER_NAME}" \
    --policy-arn "arn:${AWS_PARTITION}:iam::${AWS_ACCOUNT_ID}:policy/KarpenterController${p}Policy-${CLUSTER_NAME}"
done

aws eks create-pod-identity-association --cluster-name "${CLUSTER_NAME}" \
  --namespace "${KARPENTER_NAMESPACE}" --service-account karpenter \
  --role-arn "arn:${AWS_PARTITION}:iam::${AWS_ACCOUNT_ID}:role/KarpenterControllerRole-${CLUSTER_NAME}"

If you plan to run Karpenter on Fargate, use IRSA instead: Pod Identity does not support Fargate pods. The migration guide's serviceAccount.annotations.eks\.amazonaws\.com/role-arn Helm value is the IRSA path.

Step 4: Let Karpenter nodes join and be discovered

Nodes launched by Karpenter use the node role from Step 2, which the cluster does not trust yet. With access entries that is one call; an EC2_LINUX entry maps the role to system:node:{{EC2PrivateDNSName}} automatically.

# Access entries need authentication mode API or API_AND_CONFIG_MAP
aws eks describe-cluster --name "${CLUSTER_NAME}" \
  --query cluster.accessConfig.authenticationMode --output text
# If that prints CONFIG_MAP:
# aws eks update-cluster-config --name "${CLUSTER_NAME}" --access-config authenticationMode=API_AND_CONFIG_MAP

aws eks create-access-entry --cluster-name "${CLUSTER_NAME}" \
  --principal-arn "arn:${AWS_PARTITION}:iam::${AWS_ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}" \
  --type EC2_LINUX

# Tag the subnets your node groups use, so the EC2NodeClass can select them
for NODEGROUP in $(aws eks list-nodegroups --cluster-name "${CLUSTER_NAME}" --query 'nodegroups' --output text); do
  aws ec2 create-tags --tags "Key=karpenter.sh/discovery,Value=${CLUSTER_NAME}" \
    --resources $(aws eks describe-nodegroup --cluster-name "${CLUSTER_NAME}" \
      --nodegroup-name "${NODEGROUP}" --query 'nodegroup.subnets' --output text)
done

# Tag the cluster security group (use your node group's SGs instead if it has its own)
aws ec2 create-tags --tags "Key=karpenter.sh/discovery,Value=${CLUSTER_NAME}" \
  --resources "$(aws eks describe-cluster --name "${CLUSTER_NAME}" \
    --query cluster.resourcesVpcConfig.clusterSecurityGroupId --output text)"

Tag private subnets only. A public subnet in the selector is how nodes end up with public IPs you did not ask for.

Step 5: Install Karpenter 1.15.1 with Helm

Install the CRDs from their own chart, because Helm never upgrades CRDs that ship inside a chart's crds/ directory; the upgrade guide recommends this. Then install the controller.

helm registry logout public.ecr.aws || true   # stale ECR Public logins cause 403s

helm upgrade --install karpenter-crd oci://public.ecr.aws/karpenter/karpenter-crd \
  --version "${KARPENTER_VERSION}" --namespace "${KARPENTER_NAMESPACE}" --create-namespace

helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
  --version "${KARPENTER_VERSION}" --namespace "${KARPENTER_NAMESPACE}" --skip-crds \
  --set "settings.clusterName=${CLUSTER_NAME}" \
  --set "settings.interruptionQueue=${CLUSTER_NAME}" \
  --set settings.featureGates.spotToSpotConsolidation=true \
  --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

kubectl get pods -n "${KARPENTER_NAMESPACE}" -l app.kubernetes.io/name=karpenter

Expect two controller pods in Running. The chart defaults to two replicas with required anti-affinity on kubernetes.io/hostname and a node affinity of karpenter.sh/nodepool DoesNotExist, so Karpenter never runs on nodes it manages; your managed node group needs at least two nodes. When I rendered the chart, --set settings.featureGates.spotToSpotConsolidation=true produced FEATURE_GATES containing SpotToSpotConsolidation=true alongside the default ReservedCapacity=true. To verify the chart's signature first, the docs give a cosign verify public.ecr.aws/karpenter/karpenter:1.15.1 command pinned to the release workflow.

Step 6: Create the EC2NodeClass

The EC2NodeClass is the AWS half: AMI, subnets, security groups, IMDS and disks. Save this as ec2nodeclass.yaml and apply it with envsubst.

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  role: "KarpenterNodeRole-${CLUSTER_NAME}"   # created by the CloudFormation stack
  amiSelectorTerms:
    - alias: "al2023@${ALIAS_VERSION}"       # pinned AMI release; avoid @latest in production
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "${CLUSTER_NAME}"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "${CLUSTER_NAME}"
  metadataOptions:
    httpEndpoint: enabled
    httpProtocolIPv6: disabled
    httpPutResponseHopLimit: 1   # pods off the host network cannot reach IMDS
    httpTokens: required          # IMDSv2 only
  blockDeviceMappings:
    - deviceName: /dev/xvda       # AL2023 root volume
      ebs:
        volumeSize: 50Gi
        volumeType: gp3
        encrypted: true
        deleteOnTermination: true
  tags:
    team: platform                # propagated to instances, volumes and ENIs
envsubst < ec2nodeclass.yaml | kubectl apply -f -
kubectl get ec2nodeclass default    # READY should be True; if not, kubectl describe it

Why pin the alias: the docs warn that with @latest, "a new AMI release will cause Karpenter to drift all out-of-date nodes in the cluster". Pinning turns AMI rollout into a reviewed change: bump ALIAS_VERSION in Git, and drift replaces nodes within your budgets. A hop limit of 1 blocks pod access to node credentials; EKS Pod Identity uses its own link-local agent, so it is unaffected.

Step 7: Create a NodePool for Spot with on-demand fallback

The NodePool is the Kubernetes half: what pods may land on and how nodes are retired. When a NodePool allows several capacity types, Karpenter prioritises reserved, then spot, then on-demand, and caches an unavailable offering for 3 minutes before trying it again. Save this as nodepool.yaml.

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general
spec:
  template:
    metadata:
      labels:
        workload-tier: general
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]                 # add arm64 once your images are multi-arch
        - key: kubernetes.io/os
          operator: In
          values: ["linux"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]     # Spot first, on-demand only as fallback
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["4"]                     # 5th generation and newer
        - key: karpenter.k8s.aws/instance-cpu
          operator: Lte
          values: ["32"]                    # cap the blast radius of one node
        - key: karpenter.k8s.aws/instance-family
          operator: Exists
          minValues: 5                      # keep at least 5 families for Spot depth
      expireAfter: 720h                     # replace every node within 30 days
      terminationGracePeriod: 24h           # a drain can never block for longer than this
  limits:
    cpu: "1000"                             # strings avoid GitOps diff noise
    memory: 4000Gi
  disruption:
    consolidationPolicy: Balanced           # 1.14+: skip moves that save little and disrupt a lot
    consolidateAfter: 5m
    budgets:
      - nodes: "10%"                        # never more than 10% of nodes at once, any reason
      - nodes: "0"                          # no consolidation or drift in office hours (UTC)
        schedule: "0 8 * * mon-fri"
        duration: 10h
        reasons: ["Underutilized", "Drifted"]
kubectl apply -f nodepool.yaml
kubectl get nodepool general        # READY should be True

Pods that must not run on Spot do not need a second NodePool. A pod-level nodeSelector of karpenter.sh/capacity-type: on-demand narrows the same pool to on-demand for that pod. If you need a fixed ratio, the scheduling docs show a two-pool topology spread trick. Karpenter sizes nodes from pod requests, so a pod without requests is packed as if it needs nothing; Kubernetes Requests and Limits Explained covers setting them.

Step 8: Tune consolidation and disruption budgets

The disruption docs define three consolidation policies. WhenEmpty removes only empty nodes. WhenEmptyOrUnderutilized removes or replaces any node if it lowers cost; it is the default. Balanced, added in 1.14, scores each move by savings against the share of pods it disrupts and skips marginal ones. I would start production pools on Balanced and watch the ConsolidationApproved events and karpenter_consolidation_score metric before tightening.

Budgets limit graceful disruption only. If you set none, Karpenter uses one budget of nodes: 10%. Several budgets combine to the most restrictive, schedules are cron in UTC, and schedule and duration must appear together. In the NodePool above, empty nodes can still be removed during office hours, because the zero budget lists only Underutilized and Drifted. Expiration and Spot interruption are forceful and ignore budgets, which is why terminationGracePeriod is set: without it, a pod with a blocking PDB can hold an expired node half-drained indefinitely.

For individual workloads, add the annotation to the pod template:

spec:
  template:
    metadata:
      annotations:
        karpenter.sh/do-not-disrupt: "30m"   # or "true"; blocks voluntary disruption, not expiry

Spot-to-spot consolidation is alpha and off by default; Step 5 enabled it. Karpenter only replaces a single Spot node with a cheaper Spot node when at least 15 instance types qualify, so it does not race to the cheapest, most-interrupted pool.

Step 9: Test scale-up and scale-down

cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 0
  selector:
    matchLabels: { app: inflate }
  template:
    metadata:
      labels: { app: inflate }
    spec:
      terminationGracePeriodSeconds: 0
      containers:
        - name: inflate
          image: public.ecr.aws/eks-distro/kubernetes/pause:3.7
          resources:
            requests: { cpu: "1" }
EOF

kubectl scale deployment inflate --replicas 10
kubectl get nodeclaims -w     # columns: TYPE, CAPACITY, ZONE, NODE, READY, AGE
kubectl logs -n "${KARPENTER_NAMESPACE}" -l app.kubernetes.io/name=karpenter -c controller -f

A NodeClaim should appear with CAPACITY set to spot (or on-demand if Spot was unavailable), gain a NODE name, and turn READY true when the node joins. Then delete the deployment and watch the node go once consolidateAfter (5 minutes here) has passed:

kubectl delete deployment inflate
kubectl get nodes -l karpenter.sh/nodepool=general -w

Step 10: Migrate from Cluster Autoscaler

Run both briefly, then hand over. Make sure PodDisruptionBudgets exist first; the migration guide warns that without them, shrinking node groups "will cause workloads to be unavailable".

# 1. Stop Cluster Autoscaler so it no longer resizes node groups
kubectl scale deploy/cluster-autoscaler -n kube-system --replicas=0

# 2. Shrink each managed node group to the floor that runs Karpenter, CoreDNS
#    and other critical add-ons. Do it a few nodes at a time on big clusters.
export NODEGROUP="$(aws eks list-nodegroups --cluster-name "${CLUSTER_NAME}" --query 'nodegroups[0]' --output text)"
aws eks update-nodegroup-config --cluster-name "${CLUSTER_NAME}" \
  --nodegroup-name "${NODEGROUP}" \
  --scaling-config "minSize=2,maxSize=2,desiredSize=2"

# 3. Watch evicted pods land on Karpenter nodes
kubectl get pods -A --field-selector=status.phase=Pending
kubectl get nodes -L karpenter.sh/nodepool,karpenter.sh/capacity-type

Pin CoreDNS and metrics-server to the managed node group with a nodeAffinity on eks.amazonaws.com/nodegroup, so cluster DNS never depends on a node Karpenter is about to remove. Once a week passes without surprises, delete the Cluster Autoscaler deployment and its IAM role, and stop treating node groups as your scaling unit.

Troubleshooting

Start with the objects, not the logs: kubectl describe nodepool general, kubectl describe ec2nodeclass default and kubectl describe nodeclaim <name> show status conditions and launch errors. Turn on debug logs with --reuse-values --set logLevel=debug on a helm upgrade. For pods that stay unscheduled for reasons that are not Karpenter's, use How to Fix Kubernetes Pods Stuck in Pending.

Symptom Likely cause Fix
Karpenter pods stay Pending after install Fewer than two non-Karpenter nodes; chart needs two replicas on different hosts Keep a managed node group of at least two nodes, or a Fargate profile with IRSA
ServiceLinkedRoleCreationNotPermitted in logs Account has never used Spot aws iam create-service-linked-role --aws-service-name spot.amazonaws.com
Controller logs STS i/o timeout at startup dnsPolicy: ClusterFirst while CoreDNS has nowhere to run Run CoreDNS on the node group, or --set dnsPolicy=Default
Controller logs AccessDenied or no credentials Pod Identity agent missing, association on the wrong namespace or service account, or trust policy without sts:TagSession aws eks list-pod-identity-associations --cluster-name "${CLUSTER_NAME}", then fix the association or trust policy
no instance type met the scheduling requirements or had a required offering Requirements too narrow, requests larger than any allowed type, or a PVC pinning the pod to one zone Widen families and sizes, include DaemonSet overhead, check the volume's zone
NodeClaim launches but node never joins No access entry for the node role, or untagged or wrong security group aws eks create-access-entry ... --type EC2_LINUX; check SG discovery tags
EC2NodeClass READY is False Subnets, security groups or AMI alias not found; in 1.15, invalid spec.kubelet now fails as InvalidKubeletConfiguration kubectl describe ec2nodeclass default and fix the condition it names
Pods stuck ContainerCreating, failed to assign an IP address maxPods above the instance's ENI limit, or subnet IP exhaustion Enable prefix delegation, remove maxPods, or add subnet space
Nodes never consolidate Blocking PDB, do-not-disrupt pod, node not initialized, or preferred anti-affinity kubectl describe node; read the Unconsolidatable events
Spot nodes replaced every few minutes AWS Node Termination Handler draining on rebalance recommendations as well Remove NTH, or disable its rebalance and Spot draining

If kubectl apply reports unknown field on a NodePool, your CRDs are older than the controller; upgrade the karpenter-crd chart to the same version.

FAQ

Is Karpenter better than Cluster Autoscaler on EKS?

For most mixed workloads, yes. Karpenter chooses instance types per batch of pending pods instead of scaling predefined node groups, and it consolidates underused nodes. Cluster Autoscaler is still simpler when you need a few fixed, pre-approved instance types and nothing else.

Should I use EKS Auto Mode instead of installing Karpenter?

Use Auto Mode if you accept AWS-managed Bottlerocket nodes, no SSH or SSM access, a 21-day maximum node lifetime and a per-instance fee. Run Karpenter yourself if you need custom AMIs, node access or full control over upgrades. Do not run both on the same capacity.

Is it safe to run production on Spot with Karpenter?

For stateless, replicated services with PodDisruptionBudgets, generally yes. Karpenter drains a node when the 2-minute interruption warning arrives and launches a replacement in parallel. Keep stateful and singleton pods on on-demand with a karpenter.sh/capacity-type: on-demand node selector.

How do I stop Karpenter replacing nodes during business hours?

Add a budget with nodes: "0", a cron schedule in UTC and a duration, and list the reasons to block, usually Underutilized and Drifted. Budgets do not stop expiry or Spot interruptions, so pair them with terminationGracePeriod.

When a Karpenter cluster misbehaves, the first thing I check is the NodeClaim and its conditions, because it says whether the problem is the launch, the join or the pod. Then I check whether the NodePool hit its limits, which fails quietly. In production I keep Karpenter on two small on-demand managed nodes, pin the AMI alias in Git, and upgrade Karpenter in staging first, the way the upgrade guide asks. Docs worth bookmarking: NodePools, Disruption, Troubleshooting, Settings and feature gates, and the EKS best practices guide for Karpenter.

Related: Kubernetes Cost Optimization in 2026: 8% CPU Use and What Fixes It, How to Fix Kubernetes Pods Stuck in Pending: Causes and Commands (2026) and Kubernetes Requests and Limits Explained: 8% CPU Used, Resize GA in 1.35.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related