Design, install, debug, review, plan capacity, audit, migrate, and upgrade Karpenter on EKS.
Verify current stable version before installing (Karpenter uses OCI, not a Helm repo):
crane ls public.ecr.aws/karpenter/karpenter | grep -E '^[0-9]+.[0-9]+.[0-9]+$' | sort -V | tail -5
Pin to the version from that output, e.g.:
helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter --version x.y.z
All guidance targets the karpenter.sh/v1 API. The v0.x Provisioner/AWSNodeTemplate API was removed in v1.0 — if you are on v0.x, use migrate mode first.
Interactive Wizard (fires when no arguments are provided)
When invoked with no arguments, ask before proceeding:
Q1 — Mode?
What do you need?
1. generate — design NodePool and EC2NodeClass from requirements
2. debug — diagnose why nodes are not provisioning or pods are stuck Pending
3. review — production-readiness review of existing NodePool/EC2NodeClass
4. audit — reconstruct scale-out/scale-in history and explain why it happened
5. plan — predict what Karpenter would provision for a given workload before deploying
6. migrate — move from Cluster Autoscaler to Karpenter
7. upgrade — upgrade Karpenter version (including v0.x → v1.x CRD migration)
Enter 1–7 or mode name:
Q2 — Environment context (ask after mode, one question at a time):
1. EKS cluster version? (e.g. 1.29, 1.30, 1.31)
2. Karpenter version currently installed (or target version for fresh install)?
3. Identity method for Karpenter controller?
a) EKS Pod Identity (recommended, requires EKS 1.24+)
b) IRSA (IAM Roles for Service Accounts)
4. Is this a private cluster (no public API endpoint)? [yes/no]
5. Do you use Spot instances? [yes / no / mixed]
6. Are you migrating from Cluster Autoscaler? [yes/no]
Use the answers to set defaults for every section below. If the user pastes a manifest or error, infer as much as possible and skip questions already answered.
Mode: generate
Design a production-ready NodePool and EC2NodeClass from requirements.
Steps:
-
Collect (or infer from context):
- Workload profile: general-purpose / compute-optimised / memory-optimised / GPU / Spot-flex
- Instance family preferences (e.g.
m,c,r,g4dn) and architecture (amd64,arm64/Graviton, or both) - AMI family:
AL2023(default, recommended) |Bottlerocket|Windows2022 - Environment: dev/staging or production — this drives the AMI strategy
- Whether scale-to-zero is needed (requires
limitsplanning) - Disruption tolerance: can workloads survive consolidation? Are there PDBs?
- GitOps method: Flux / Argo CD / direct kubectl
-
Generate
EC2NodeClasswith:amiSelectorTerms: usealias: al2023@latestfor dev/staging; use a pinned AMI ID (id: ami-xxxx) for production. Floating@latestin production means an untested AMI can land on fleet nodes during any scheduled SSM parameter update. Ask the user which environment this is for before choosing.
Ask user: "What is your EKS cluster name?" — substitute into
karpenter.sh/discovery: <cluster-name>before applying.subnetSelectorTermsandsecurityGroupSelectorTermsusingkarpenter.sh/discovery: <cluster-name>tagsinstanceProfileorrolematching the Karpenter node IAM roleblockDeviceMappingswith encrypted EBS and IMDSv2 enforced viametadataOptionstagsincluding at minimumkarpenter.sh/discovery,Environment, and billing tags
-
Generate
NodePoolwith:spec.template.spec.requirementscovering at least 3 instance families and bothOn-Demand/Spotcapacity types (or explicit single type)minValueson instance family or size requirements to guarantee Spot diversitylimitson CPU and memory — never omit, uncapped NodePools can runawayweightif this is one of multiple NodePools (higher weight = preferred)disruptionblock:consolidationPolicy,consolidateAfter,budgetsexpireAfterset to720h(30 days) unless the user has a reason to extend
-
Generate companion resources:
- IRSA or Pod Identity IAM policy (minimum permissions — see references)
PodDisruptionBudgetfor stateful workloads that will land on Karpenter nodes- Flux
Kustomizationdependency or Argo CDsyncWaveannotation if GitOps is in use
-
Show validation:
kubectl apply --dry-run=server -f ec2nodeclass.yamlkubectl apply --dry-run=server -f nodepool.yamlkubectl describe nodepool <name> # check Conditions: Ready=True
Reference: references/karpenter.md → NodePool design, EC2NodeClass, IAM
Rollback: kubectl delete nodepool <name> && kubectl delete ec2nodeclass <name> — Karpenter immediately stops provisioning nodes from these templates. Existing nodes remain until drained by the scheduler or TTL.
Mode: debug
Diagnose why pods are stuck Pending or nodes are not provisioning.
Steps:
-
Collect evidence first — do not suggest fixes before seeing output:
# Pending pods and their scheduling failure reasonkubectl get pods -A --field-selector=status.phase=Pendingkubectl describe pod <name> -n <ns> # look for Events: FailedScheduling# NodeClaim lifecyclekubectl get nodeclaim -Akubectl describe nodeclaim <name> # look for Conditions block# NodePool statuskubectl get nodepool -Akubectl describe nodepool <name> # look for: Ready condition, limits# Karpenter controller logs (most diagnostic signal is here)kubectl logs -n karpenter -l app.kubernetes.io/name=karpenter \--since=30m --tail=200# Node registration failureskubectl get nodeskubectl describe node <new-node-name> # look for NotReady or taint issues -
Work through this decision tree in order:
Symptom Most likely cause Field to check No NodePool foundin pod eventsNo NodePool matches pod requirements NodePool requirementsvs podnodeSelector/tolerationsNodePool at resource limitNodePool limitsexhaustedkubectl describe nodepool→ Current UsageNodeClaim stuck PendingIAM error launching instance Karpenter logs for EC2 error,UnauthorizedOperationNodeClaim launched but node NotReadyBootstrap failure, security group, subnet kubectl describe node, user-data cloud-init logsSpot capacity unavailableSingle AZ or narrow instance family Add instance families, enable multi-AZ subnets Node registers then terminates immediatelyDrift triggered on launch Check AMI selector — SSM alias resolved to drifted AMI Pods evicted unexpectedlyConsolidation or expiration Node annotation karpenter.sh/disruption-reasonPrivate cluster: node never registersKarpenter cannot reach EC2/SQS APIs VPC endpoints: EC2, SQS, ECR, SSM, S3 -
For IAM errors — test the exact API calls Karpenter makes:
# Test from a pod in the karpenter namespace using the controller SAkubectl run -it --rm debug --image=amazon/aws-cli \--serviceaccount=karpenter -n karpenter -- \aws ec2 describe-instance-types --region eu-north-1 -
State the most likely root cause with the exact field or annotation to fix. Show the corrected spec section. Show the command to verify the fix worked.
Reference: references/karpenter.md → Troubleshooting
Rollback: If you edited NodePool/EC2NodeClass during debug: kubectl apply -f <original-backup>.yaml to restore. Always take a backup before editing: kubectl get nodepool <name> -o yaml > nodepool-backup.yaml.
Mode: review
Production-readiness review of NodePool and EC2NodeClass manifests.
Review in this exact priority order:
Correctness
- Does
EC2NodeClass.spec.instanceProfileor.spec.rolereference a valid node IAM role? - Are
subnetSelectorTermsandsecurityGroupSelectorTermsusing stable selectors (tags, not names which can change)? - Does
NodePool.spec.template.spec.nodeClassRef.namematch theEC2NodeClassmetadata name? - Are
requirementssatisfiable — does the cluster have subnets in the selected AZs with enough IP space? - Is
limitsset? An uncapped NodePool will provision nodes without bound on runaway workloads.
Cost
- Are at least 3 instance families listed for Spot (narrow families = frequent
InsufficientCapacityError)? - Is
minValuesset on instance family or size requirements to enforce Spot diversity? - Is Graviton (
arm64) included where workloads support it (~20% cheaper on EC2)? - Is
consolidationPolicy: WhenEmptyOrUnderutilizedenabled? Idle nodes are silent cost leaks. - Is
expireAfterset? Nodes that run indefinitely accumulate AMI drift and resist consolidation.
Reliability
- Are PDBs in place for workloads that will land on these nodes?
- Does
disruption.budgetsprotect critical workloads from simultaneous eviction? - Is
terminationGracePeriodset on the NodePool (default 48h — may be too long or too short)? - Does
startupTaintsprevent workloads scheduling onto nodes before DaemonSets are ready?
Security
- Is
amiSelectorTermsusingalias: al2023@latestor a pinned, scanned AMI ID? (Floatinglatestin production means untested AMIs can land on nodes.) - Is EBS encrypted? (
blockDeviceMappings[].ebs.encrypted: true) - Is IMDSv2 enforced? (
metadataOptions.httpTokens: required,httpPutResponseHopLimit: 1) - Do EC2 instance tags include
karpenter.sh/discovery: <cluster-name>— and is this tag protected by an SCP/tag policy against modification? - Does the node IAM role follow least privilege? (No
ec2:*, noiam:*)
GitOps safety
- Does the Flux/Argo Kustomization
dependsOnthe Karpenter Helm release so CRDs exist before NodePool? - Is the IAM role ARN injected via
postBuild.substituteor Argo CDApplicationSetparameter, not hardcoded?
Separate findings into:
- Critical — must fix before deploying to production
- Improvement — should fix (cost, resilience)
- Note — informational (consider Graviton, document disruption tolerance)
Reference: references/karpenter.md → NodePool design, Security, Cost strategy
Mode: audit
Reconstruct what scaled out or in, when, and why — for post-incident analysis or cost review.
Steps:
-
Collect the timeline from three layers:
# Layer 1: NodeClaim lifecycle events (node creation and termination)kubectl get events -A \--field-selector involvedObject.kind=NodeClaim \--sort-by='.lastTimestamp' | tail -50# Layer 2: Disruption reason on terminated nodes (if node still exists)kubectl get node <name> \-o jsonpath='{.annotations.karpenter\.sh/disruption-reason}'# Layer 3: Karpenter controller logs filtered to provisioning and disruptionkubectl logs -n karpenter -l app.kubernetes.io/name=karpenter \--since=3h | grep -E "provisioned|disrupted|consolidat|drift|interrupt|expir"# Layer 4: EC2 instance history (for Spot interruptions, verify in CloudWatch)aws ec2 describe-spot-instance-requests \--filters "Name=tag:karpenter.sh/nodepool,Values=<nodepool-name>" \--query 'SpotInstanceRequests[*].{ID:InstanceId,State:State,Fault:Fault}' \--region eu-north-1 -
Map each event to a cause:
disruption-reasonannotationCause What triggered it consolidationNode underutilised or empty consolidateAftertimer elapseddriftNodePool, EC2NodeClass, or AMI changed GitOps push updated NodePool spec expirationexpireAfterTTL elapsedRoutine node rotation interruptionSpot 2-minute termination notice AWS Spot capacity reclaim emptinessNo non-DaemonSet pods remained Scale-in after workload removed (none / node gone) Spot rebalance recommendation acted on Check SQS interruption queue messages -
For scale-out events, correlate with the workload that triggered provisioning:
# Find which pod triggered a NodeClaimkubectl describe nodeclaim <name> | grep -A 5 "Pod:"# Check what was Pending at the time (requires events within TTL)kubectl get events -n <workload-ns> \--field-selector reason=TriggeredScaleUp \--sort-by='.lastTimestamp' -
Output a timeline table:
TIME EVENT NODE/CLAIM REASON TRIGGER14:32:01 scale-out ip-10-0-1-42 provisioned orders-processor Pending (no capacity)14:32:44 node-ready ip-10-0-1-42 — Spot m6i.xlarge eu-north-1b15:45:10 scale-in ip-10-0-1-42 consolidation node underutilised after queue drain -
When Kubernetes events have aged out (default TTL: 1h), fall back to CloudTrail for instance-level history:
# Find RunInstances calls tagged with Karpenter nodepool — covers up to 90 daysaws cloudtrail lookup-events \--lookup-attributes AttributeKey=EventName,AttributeValue=RunInstances \--start-time <iso-timestamp> \--end-time <iso-timestamp> \--region eu-north-1 \--query 'Events[*].CloudTrailEvent' --output text | \jq -r '. | fromjson | select(.requestParameters.tagSpecificationSet.items[]?.tags[]?| select(.key=="karpenter.sh/nodepool")) | {time: .eventTime, instance: .responseElements.instancesSet.items[0].instanceId,type: .requestParameters.instanceType, az: .requestParameters.availabilityZone}'# Find TerminateInstances calls for Karpenter nodesaws cloudtrail lookup-events \--lookup-attributes AttributeKey=EventName,AttributeValue=TerminateInstances \--start-time <iso-timestamp> \--region eu-north-1 \--query 'Events[*].CloudTrailEvent' --output text | \jq -r '. | fromjson | {time: .eventTime,instance: .requestParameters.instancesSet.items[0].instanceId,user: .userIdentity.sessionContext.sessionIssuer.userName}'CloudTrail is the authoritative source when kubectl events are gone — use it as Layer 4 in the timeline.
-
Finish with a one-line verdict: what caused the scale event, whether it was expected, and whether any tuning is needed (e.g. disruption budget too loose,
consolidateAftertoo aggressive).
Reference: references/karpenter.md → Disruption strategy, Troubleshooting
Mode: plan
Predict what Karpenter would provision before you deploy a workload — useful for cost estimates, pre-rollout capacity checks, and catching NodePool mismatches before they page you.
Steps:
-
Extract the scheduling requirements from the workload:
# Get resource requests, nodeSelector, tolerations, affinity, and spread constraintskubectl get deployment <name> -n <ns> -o json | jq '{requests: .spec.template.spec.containers[].resources.requests,nodeSelector: .spec.template.spec.nodeSelector,tolerations: .spec.template.spec.tolerations,affinity: .spec.template.spec.affinity,topologySpread: .spec.template.spec.topologySpreadConstraints}'Or ask the user to paste the pod spec / Deployment YAML.
-
Walk through NodePool matching manually:
- For each NodePool (highest
weightfirst), check: do the pod'snodeSelectorlabels satisfy the NodePoolrequirements? Do the pod'stolerationscover all NodePooltaints? Does the pod'saffinityconflict with any NodePool zone requirements? - The first NodePool that satisfies all constraints is the target.
- If no NodePool matches, state exactly which requirement is the blocker.
- For each NodePool (highest
-
Predict instance selection within the matched NodePool:
- Karpenter picks the cheapest instance type that fits: pod requests + DaemonSet overhead (typically 300–600m CPU + 512Mi–1Gi RAM depending on your DaemonSet stack)
- For Spot: Karpenter uses current Spot pricing — check
aws ec2 describe-spot-price-historyfor the region - State the likely instance family and size (e.g. "likely
m7i.largeineu-north-1b, ~$0.03/hr Spot")
-
Check NodePool limits headroom:
kubectl describe nodepool <name> | grep -A 8 "Status:"# Resources shows current usage vs limits — will the new workload fit? -
Output:
- Which NodePool would be selected and why
- Likely instance type and AZ
- Whether limits have headroom for the replica count
- Any topology spread constraints that could block provisioning
- Estimated cost per replica per hour (Spot or On-Demand)
Reference: references/karpenter.md → NodePool design, Cost strategy
Mode: migrate
Move a cluster from Cluster Autoscaler to Karpenter safely.
Steps:
-
Collect current state:
# CA version and configurationkubectl get deployment cluster-autoscaler -n kube-system -o yaml | \grep -E "image:|--node-group"# Existing node groups (Karpenter takes over scheduling; MNGs stay but CA stops managing them)aws eks describe-nodegroup --cluster-name <cluster> \--nodegroup-name <ng> --region eu-north-1 \--query 'nodegroup.{Min:scalingConfig.minSize,Max:scalingConfig.maxSize,Labels:labels}'# Pods currently running on CA-managed nodeskubectl get pods -A -o wide | grep <node-name> -
Pre-migration checklist — all must pass before proceeding:
- Karpenter controller installed and
NodePool+EC2NodeClasscreated andReady=True - Node IAM role tags include
karpenter.sh/discovery: <cluster-name> - Interruption queue created and Karpenter controller has SQS permissions
-
karpenter.sh/discoverytag applied to subnets and security groups - Test NodePool provisions a node:
kubectl run test --image=nginx --restart=Never - CA and Karpenter are NOT both active on the same nodes — they will fight
- Karpenter controller installed and
-
Cordon CA-managed nodes to prevent new scheduling, then drain:
# Scale CA to 0 replicas first — stops CA from interferingkubectl scale deployment cluster-autoscaler -n kube-system --replicas=0# Cordon all CA-managed nodesfor node in $(kubectl get nodes -l <ca-node-group-label> -o name); dokubectl cordon "$node"done# Drain with grace period — PDBs are respectedfor node in $(kubectl get nodes -l <ca-node-group-label> -o name); dokubectl drain "$node" \--ignore-daemonsets \--delete-emptydir-data \--grace-period=120 \--timeout=300sdoneBlast radius: pods on drained nodes will reschedule. If PDBs block drain, the command will wait — do not force.
-
After workloads reschedule onto Karpenter nodes and verify healthy:
# Verify Karpenter provisioned new nodeskubectl get nodeclaim -Akubectl get nodes -l karpenter.sh/nodepool=<name># Verify workloads are runningkubectl get pods -A | grep -v Running | grep -v Completed -
Remove CA:
kubectl delete deployment cluster-autoscaler -n kube-system# Remove CA RBAC, ClusterRole, and ServiceAccount# Scale MNG minSize to 0 or delete if fully replaced by Karpenter -
Critical migration risks:
- CA leaves nodes running after you delete its deployment — drain explicitly, do not rely on CA to scale down
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"annotations block drain — audit pods for this annotation before starting- Karpenter will not manage existing MNG nodes — it only provisions new ones via NodeClaim; running nodes remain until drained
- Two scalers competing — the window between CA scale-down and Karpenter taking over is the riskiest moment; keep CA at 0 replicas, not deleted, until Karpenter is confirmed healthy
Reference: references/karpenter.md → Migration from Cluster Autoscaler
Rollback: If drain was initiated: kubectl uncordon <node> re-admits workloads immediately. Delete the new NodePool/EC2NodeClass with kubectl delete nodepool <name>. Restore the original node group config in Terraform before running terraform apply.
Mode: upgrade
Upgrade Karpenter, including the v0.x → v1.x CRD migration.
Steps:
-
Identify the upgrade path:
helm list -n karpenterkubectl get crd | grep karpenter# v0.x CRDs: provisioners.karpenter.sh, awsnodetemplates.karpenter.k8s.aws# v1.x CRDs: nodepools.karpenter.sh, ec2nodeclasses.karpenter.k8s.aws, nodeclaims.karpenter.sh -
v0.x → v1.x is a breaking migration — follow this order:
- Do NOT run
helm upgradedirectly from v0.x to v1.x — the CRDs are renamed and old resources will be orphaned - Read the Karpenter migration guide for the exact version pair (e.g. v0.37 → v1.0)
- Steps:
a. Export existing
ProvisionerandAWSNodeTemplateresources as YAML backups b. Install v1.x CRDs alongside v0.x (they have different names — no conflict) c. Create equivalentNodePoolandEC2NodeClassfrom the v0.x YAML (field mapping below) d. Runhelm upgradeto the v1.x chart — controller switches to v1 API e. Drain nodes managed under oldProvisioner— they will reprovision asNodeClaimf. Delete oldProvisionerandAWSNodeTemplateresources
- Do NOT run
-
v0.x → v1.x field mapping:
v0.x ( Provisioner)v1.x ( NodePool)spec.requirementsspec.template.spec.requirementsspec.limits.resourcesspec.limitsspec.ttlSecondsAfterEmptyspec.disruption.consolidateAfterspec.ttlSecondsUntilExpiredspec.template.spec.expireAfterspec.providerRefspec.template.spec.nodeClassRefv0.x ( AWSNodeTemplate)v1.x ( EC2NodeClass)spec.subnetSelectorspec.subnetSelectorTerms(list)spec.securityGroupSelectorspec.securityGroupSelectorTerms(list)spec.amiSelectorspec.amiSelectorTerms(list)spec.instanceProfilespec.instanceProfileorspec.role -
For patch/minor upgrades (v1.x → v1.y):
# Check release notes for deprecated fields or changed defaultshelm repo updatehelm diff upgrade karpenter oci://public.ecr.aws/karpenter/karpenter \--version <target-version> \--namespace karpenter \-f karpenter-values.yaml# Applyhelm upgrade karpenter oci://public.ecr.aws/karpenter/karpenter \--version <target-version> \--namespace karpenter \--reuse-values \--atomic \--timeout 5m -
Post-upgrade validation:
kubectl get pods -n karpenterkubectl describe nodepool <name> # Conditions: Ready=Truekubectl get nodeclaim -A # No stuck Pending claimskubectl logs -n karpenter -l app.kubernetes.io/name=karpenter \--tail=100 | grep -i "error\|warn" -
Rollback:
helm rollback karpenter -n karpenter# NodePool/EC2NodeClass CRs are preserved in etcd — nodes continue running
Reference: references/karpenter.md → Upgrade, v0.x → v1.x migration