How to Set Up Karpenter on EKS: NodePools, Spot and Consolidation (2026)
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.
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
Amazon EKS in 2026: 1.37, $0.60 Extended Support and Auto Mode Explained
Amazon EKS in October 2026: Kubernetes 1.34 to 1.37 in standard support at $0.10 an hour, 1.31 to 1.33 in extended support at $0.60, what the Auto Mode fee buys, Pod Identity vs IRSA, Hybrid Nodes pricing, the new 7-day rollback, and when EKS is the right choice.
13 minOct 31, 2026How to Run Postgres on Kubernetes with CloudNativePG 1.30 (2026)
Step-by-step CloudNativePG 1.30.1 guide: install the operator, create a three-instance PostgreSQL 18 cluster, back up to S3 with the Barman Cloud plugin, restore to a point in time, add PgBouncer, monitor with Prometheus, upgrade majors and fix common errors.
15 minOct 31, 2026How to Upgrade an EKS Cluster to 1.37 Safely: Step by Step (2026)
Upgrade Amazon EKS one minor version at a time without an outage: refresh upgrade insights, scan for removed APIs with Pluto, upgrade the control plane with eksctl or the AWS CLI, then VPC CNI, CoreDNS and kube-proxy, then node groups and Karpenter, with the 7-day rollback.
15 min