,

Karpenter NodePools and NodeClasses: A Practical Configuration Guide

A Karpenter NodePool defines the constraints Karpenter uses to launch nodes (instance types, capacity type, limits, taints, and disruption rules), and a NodeClass (for example EC2NodeClass) defines the cloud-specific node configuration (AMI, subnets, security groups). Together they replace the legacy Provisioner and give you fine control over cost and placement.

Kunal Das Avatar
karpenter nodepool featured image

The Karpenter v1 API (karpenter.sh/v1) gives you precise control over node provisioning through two resources: NodePool and EC2NodeClass. Used correctly, they let you provision exactly the right node for each workload. Misconfigured (requirements blocks too broad, limits absent, disruption policy misaligned with workload criticality), you end up with nodes too large, too expensive, or poorly matched to actual usage. The failure mode is silent: pods schedule, the cluster runs, but the bill keeps growing.

NodePools define the scheduling policy: which instance families, sizes, architectures, and capacity types are eligible; resource ceilings; disruption behavior; and taints applied to launched nodes. They’re the primary lever Karpenter exposes for workload-specific provisioning strategy. Cast AI’s 2026 State of Kubernetes Optimization Report, based on data from more than 23,000 production clusters, found that 69% of clusters overprovision CPU while average CPU utilization sits at just 8%. Teams often create that waste during node provisioning by defining NodePools too broadly, causing Karpenter to launch instances that are larger than workloads actually need. This guide walks through every configuration block with YAML you can copy into production, so you can avoid being in that 69%.

If you’re new to Karpenter, start with what Karpenter is and how it works before diving into configuration details here.

Prerequisites: This guide requires Karpenter >= 1.0 (v1 API GA, September 2024). Verify your installed version: kubectl get crd nodepools.karpenter.sh -o jsonpath='{.spec.versions[?(@.served==true)].name}'. The minValues field requires Karpenter >= 1.1 with the NodePoolMinValues feature gate enabled.

Key takeaways

  • NodePool defines scheduling policy; EC2NodeClass handles AWS-specific infrastructure. They’re separate concerns intentionally.
  • The legacy v1alpha5 Provisioner was removed at Karpenter 0.33+. Migrate with the karpenter-convert CLI.
  • Requirements, limits, taints, and disruption are the four core configuration blocks on every NodePool.
  • Three consolidation policies, conservative to aggressive: WhenEmpty, Balanced (available in karpenter.sh/v1; check release notes for stability status), WhenEmptyOrUnderutilized.
  • Weighted NodePools let you express provisioning preferences without hard constraints, useful for spot-with-on-demand-fallback patterns.
  • consolidateAfter applies to all consolidation policies. expireAfter is an upper limit on node lifetime, not a guaranteed TTL.

NodePool vs NodeClass vs the legacy Provisioner

Before Karpenter 0.33, provisioning was controlled by a single Provisioner resource from the v1alpha5 API, paired with an AWSNodeTemplate. Karpenter removed both in version 0.33+. AWS contributed the project to CNCF in 2023, where it entered the CNCF Sandbox, and the project reached v1 GA in September 2024.

The v1 API splits provisioning into two distinct concerns:

  • NodePool (karpenter.sh/v1): scheduling policy – which instance families, sizes, architectures, and capacity types are eligible; resource limits; disruption behavior; taints and labels applied to launched nodes.
  • EC2NodeClass (karpenter.k8s.aws/v1): AWS-specific infrastructure – which AMI, subnets, security groups, IAM role, and user data bootstrap script to use.

This separation lets you maintain one EC2NodeClass per environment (prod, staging) and reference it from multiple NodePools – one scoped for batch workloads, another for GPU workloads, another for spot-eligible stateless services.

Migrating from v1alpha5? The karpenter-convert CLI automates the conversion: ProvisionerNodePool, AWSNodeTemplateEC2NodeClass.

Configuring a NodePool

A NodePool is a cluster-scoped custom resource. Everything that controls how Karpenter picks and manages nodes lives here.

requirements (instance families, sizes, architectures, capacity type)

The spec.template.spec.requirements block constrains which instance types are eligible. Requirements use standard Kubernetes node label selectors – In, NotIn, Exists, DoesNotExist operators – applied against well-known labels:

  • karpenter.sh/capacity-type: spot or on-demand. Within a pool that allows both, Karpenter prefers spot first.
  • karpenter.k8s.aws/instance-family: m5, m6i, c5, r5, etc.
  • karpenter.k8s.aws/instance-size: medium, large, xlarge, 2xlarge, etc.
  • kubernetes.io/arch: amd64, arm64

Keep instance family lists narrow enough to be meaningful. A requirements block that includes every m, c, r, t, and x family gives Karpenter too much latitude and risks selecting instances that are wrong-sized for your workload. Scoping to two or three families you actually profile-test is more reliable.

The minValues field (requires Karpenter >= 1.1 with the NodePoolMinValues feature gate) lets you require a minimum number of distinct values per requirement key, for example, at least 3 distinct instance types across your allowed families, which helps maintain flexibility when one instance type is unavailable. Use it carefully: it adds scheduling complexity and can affect consolidation behavior.

limits

spec.limits caps total resource consumption across all nodes provisioned by this NodePool:

limits:
  cpu: "1000"
  memory: 4000Gi

When a NodePool limit is reached, Karpenter stops provisioning nodes for that pool. New pods that require this NodePool stay Pending indefinitely, Karpenter generates no events to explain why. This is how you cap blast radius, but it’s also one of the harder failures to diagnose. Check current usage against configured limits: kubectl get nodepool <name> -o jsonpath='{.status.resources}'. Set limits conservatively in non-production environments; in production, size to realistic peak demand plus reasonable surge headroom. Skipping spec.limits entirely is one of the more common mistakes.

taints and labels

Karpenter applies NodePool-level taints and labels to every node it launches from the pool.

spec:
  template:
    metadata:
      labels:
        team: platform
        workload-tier: batch
    spec:
      taints:
        - key: workload
          value: batch
          effect: NoSchedule

Use taints to create dedicated node groups for workloads that need isolation, GPU pods, compliance-sensitive services, batch jobs that shouldn’t compete with latency-sensitive workloads.

startupTaints are different. They’re temporary: applied during the node initialization phase while DaemonSets and the CNI initialize, then removed once the node is ready. Pods do not need to tolerate startupTaints – they’re an internal handshake between Karpenter and the init stack, not a scheduling constraint pods need to be aware of.

disruption block

The spec.disruption block controls when and how Karpenter terminates nodes it no longer needs. There are two axes: budget and consolidation policy.

budgets limits how many nodes can be disrupted simultaneously. You can stack multiple budget rules:

disruption:
  budgets:
    - nodes: "20%"                    # at most 20% of pool disrupted at any time
    - schedule: "0 9 * * mon-fri"     # weekdays at 09:00
      duration: 8h
      nodes: "0"                       # freeze disruption during business hours

Karpenter cron supports both numeric (0=Sunday through 6=Saturday) and named day formats (mon-fri, etc.). The named format is idiomatic in Karpenter docs and more readable in production configs.

consolidationPolicy has three options, from conservative to aggressive:

  • WhenEmpty: consolidates nodes only when they have no running pods. Safest option.
  • Balanced: available in karpenter.sh/v1 (check your release notes for stability status); balances consolidation aggressiveness with node churn.
  • WhenEmptyOrUnderutilized: consolidates both empty nodes and underutilized ones. Most aggressive; targets the utilization gap directly.

consolidateAfter is a stability timer that applies to all consolidation policies, not just WhenEmpty. It defines how long a node must be in a consolidatable state before Karpenter acts. Set it to Never to disable consolidation for a pool.

expireAfter sets an upper limit on node lifetime. Nodes can be removed earlier by consolidation, drift, or emptiness, it’s not a guaranteed TTL. For a full treatment of disruption mechanics including drift, see Karpenter disruption and drift explained.

Full annotated NodePool YAML

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    metadata:
      labels:
        environment: production
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        # Allow both spot and on-demand; within this pool Karpenter prefers spot
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        # Limit to memory-optimized and general-purpose families
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m5", "m6i", "r5", "r6i"]
        # Exclude the smallest sizes to avoid resource-constrained scheduling
        - key: karpenter.k8s.aws/instance-size
          operator: NotIn
          values: ["nano", "micro", "small"]
        # AMD64 only; add arm64 and graviton families if your workloads support it
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
      startupTaints:
        # Temporary — removed once the node is ready. Pods do NOT need to tolerate this.
        - key: node.cloudprovider.kubernetes.io/uninitialized
          effect: NoSchedule
      taints:
        # Persistent taint; pods scheduled here must tolerate it
        - key: workload
          value: general
          effect: NoSchedule
  limits:
    cpu: "500"      # cap nodes in this pool at 500 CPU total
    memory: 2000Gi
  disruption:
    # WhenEmptyOrUnderutilized is the most aggressive policy — targets both
    # empty nodes and underutilized ones. Use WhenEmpty for a safer default.
    consolidationPolicy: WhenEmptyOrUnderutilized
    # Applies to all consolidation policies — stability timer before Karpenter acts
    consolidateAfter: 30s
    # Upper limit on node lifetime — actual lifetime may be shorter due to
    # consolidation, drift, or emptiness
    expireAfter: 720h
    budgets:
      - nodes: "10%"                       # disrupt at most 10% of pool at once
      - schedule: "0 8 * * mon-fri"        # business hours, Mon-Fri
        duration: 9h
        nodes: "0"                          # no disruption during business hours
  # NodePool scheduling priority; higher value = tried first when multiple pools match.
  # See Weighted NodePools section below.
  weight: 100

Configuring an EC2NodeClass (AMI, subnets, security groups, user data)

EC2NodeClass is where Karpenter gets the AWS-specific details it needs to launch a node. One EC2NodeClass can serve many NodePools. Update the class reference on a NodePool to change its infrastructure configuration without affecting its scheduling logic.

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  # Use 'role' OR 'instanceProfile' — not both.
  # 'role' is recommended for new deployments.
  role: "KarpenterNodeRole-my-cluster"

  amiSelectorTerms:
    # alias resolves to the latest EKS-optimized Amazon Linux 2023 AMI.
    # When using an alias, spec.amiFamily is optional — Karpenter infers it.
    # For non-alias selection (by tag, name, or ID), spec.amiFamily is required
    # to determine the correct bootstrapping logic.
    - alias: al2023@latest

  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: my-cluster

  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: my-cluster

  metadataOptions:
    httpEndpoint: enabled
    httpProtocolIPv6: disabled
    httpPutResponseHopLimit: 1
    # hop limit 1 blocks ALL container access to IMDS (169.254.169.254) —
    # not just credential retrieval. Pods using IRSA are unaffected: IRSA
    # exchanges STS tokens, not IMDS calls. Only change to 2 if pods need
    # to query IMDS directly for region, AZ, Spot interruption notices, or
    # other instance metadata, AND you accept the security tradeoff of
    # re-enabling container IMDS access.
    httpTokens: required

  userData: |
    #!/bin/bash
    # Custom bootstrap steps; adjust cluster name as needed
    /etc/eks/bootstrap.sh my-cluster

Three things to get right here that are commonly misconfigured:

  • Use role or instanceProfile, not both. role is the recommended path for new deployments; using both is a validation error.
  • When using amiSelectorTerms with an alias (e.g., alias: al2023@latest), spec.amiFamily is optional, Karpenter infers it from the alias. For non-alias AMI selection (by tag, name, or ID), spec.amiFamily is required to determine the correct bootstrapping logic. The alias-based approach is recommended for new deployments.
  • httpPutResponseHopLimit: 1 is correct for most clusters. IRSA uses projected service account tokens and STS – it does not touch IMDS. Only increase to 2 if pods need direct IMDS access and you accept the security tradeoff.

Weighted NodePools and prioritization

When multiple NodePools can schedule a pending pod, Karpenter uses spec.weight to determine which to try first. Higher weight means higher priority. This is the standard mechanism for spot-with-on-demand-fallback patterns.

Here’s a two-pool setup: a high-priority spot pool and a lower-priority on-demand fallback, both referencing the same EC2NodeClass:

---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: spot-preferred
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot"]
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m5", "m6i", "c5", "c6i"]
  limits:
    cpu: "400"
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30s
  weight: 80   # tried first
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: on-demand-fallback
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m5", "m6i", "c5", "c6i"]
  limits:
    cpu: "400"
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 30s
  weight: 20   # fallback when spot isn't available or limit is exhausted

Karpenter tries spot-preferred (weight 80) first. If spot capacity is unavailable or the CPU limit is exhausted, it falls back to on-demand-fallback (weight 20). One detail worth knowing: spot-to-spot consolidation is disabled by default. If you want Karpenter to consolidate between spot instance types, enable it via the SpotToSpotConsolidation feature gate. For production spot strategies, Karpenter spot instances covers the mechanics and failure handling in depth.

For the full picture on how consolidation decisions are made across pools, see Karpenter consolidation.

Beyond NodePool configuration: Workload-Aware Consolidation

Karpenter’s built-in consolidation policies work at the node level – they consolidate when nodes are empty or underutilized based on pod requests. The problem is that pod requests are often wrong. If a pod requests 4 CPU but consistently uses 0.3, Karpenter provisions a node large enough to honor the request and consolidates based on that inflated signal.

Cast AI’s Workload-Aware Consolidation runs on top of your existing NodePool setup, taking rightsizing into account: it adjusts pod CPU and memory requests based on actual consumption, which feeds accurate signals into Karpenter’s bin-packing decisions. Rightsizing a pod from 4 CPU to its actual 0.3 CPU usage lets Karpenter see the corrected request during the next scheduling cycle and pack workloads more efficiently. No NodePool changes required. Cast AI works alongside your existing configuration. See Cast AI Karpenter optimization.

Common configuration mistakes

  • Using deprecated API versions. v1alpha5 (Provisioner, AWSNodeTemplate) was removed at Karpenter 0.33+. The resources no longer exist – YAML that references them silently does nothing. Run karpenter-convert before you spend time debugging provisioning that never starts.
  • Assuming amiFamily works as a standalone field for all AMI selection modes. When using amiSelectorTerms with an alias, spec.amiFamily is optional. For non-alias AMI selection (by tag, name, or ID), it’s required. Omitting it on non-alias configurations causes bootstrapping failures that don’t surface obviously in events.
  • Too-broad instance family requirements. A requirements block that covers every available family gives Karpenter too many options, reducing scheduling predictability and making it harder to attribute cost to workload types. Scope to the families your workloads actually benefit from.
  • Omitting spec.limits. Without limits, a misconfigured anti-affinity rule or an accidental deployment scale-up can provision an unbounded number of nodes before anyone notices. Always set limits and remember that hitting a limit causes silent Pending pods (see below).
  • Ignoring NodePool limits. When a NodePool reaches its configured limit, Karpenter stops provisioning additional nodes from that pool without generating an explanatory event. Pods that require those nodes remain Pending indefinitely. Check usage against the configured limits: kubectl get nodepool <name> -o jsonpath='{.status.resources}'.
  • Using role and instanceProfile together. EC2NodeClass accepts one or the other. Both present is a validation error that prevents the class from being applied.
  • Confusing taints and startupTaints. Taints persist on the node and pods must tolerate them. startupTaints are temporary init-phase taints, pods don’t need to tolerate them, and Karpenter removes them once the node is ready. Getting this wrong either blocks pod scheduling or leaves nodes with unexpected persistent taints.
  • Treating expireAfter as a guaranteed TTL. It’s an upper limit. Consolidation, drift, or emptiness can remove a node before expireAfter fires. If your compliance requirement is a maximum node age, that’s what expireAfter provides – it’s not a minimum.
  • Misreading PDB-blocked consolidation as a node failure. When a PodDisruptionBudget blocks consolidation, the node stays in Ready state, it does not go NotReady. Karpenter emits Unconsolidatable events on the node. Check events, not node status.

Debugging your NodePool

When provisioning behaves unexpectedly pods stay Pending, nodes aren’t consolidating, limits appear to be wrong – these commands surface the relevant signals:

# Start here — scheduler rejection messages on the pod reveal the root cause faster
# than anything else: NoMatchingNodePool, taint/toleration mismatches, resource
# requests exceeding max instance size, etc.
kubectl describe pod <pending-pod-name> -n <namespace> | grep -A15 Events

# Inspect provisioned NodeClaims and their current status
kubectl get nodeclaims -A -o wide

# NodeClaim and NodePool events are cluster-scoped — they surface via -A or
# kubectl describe nodeclaim <name>, not -n karpenter
kubectl get events -A --field-selector source=karpenter --sort-by=.lastTimestamp | grep -E 'Unconsolidatable|NoCompatibleInstanceTypes|InsufficientCapacityError|FailedLaunch'

# Check NodePool status conditions and resource usage vs configured limits
kubectl describe nodepool <name>

# Compare allocated resources against configured limits
kubectl get nodepool <name> -o jsonpath='{.status.resources}'

# Inspect scheduler decisions — look for constraint violations and
# capacity-unavailable messages
kubectl logs -n karpenter -l app.kubernetes.io/name=karpenter --tail=100

# Check AMI discovery status, subnet resolution, and security group matching.
# Failed AMI lookup or misconfigured subnetSelectorTerms are the most common
# causes of Karpenter failing to launch nodes when limits aren't the issue.
kubectl describe ec2nodeclass <name>

The most common blind spot: pods Pending because a NodePool limit is hit. kubectl describe nodepool shows the status conditions; the jsonpath command above shows current vs configured resource usage. If the allocated CPU or memory equals the configured limit, that’s your answer. Scale the limit or add a fallback NodePool.

For PDB-blocked consolidation, the Unconsolidatable filter is the fastest path. Karpenter emits these events, but filtering the event stream is often necessary to find them.

Next steps

Well-configured NodePools and EC2NodeClasses set the ceiling on what Karpenter can optimize. To close the gap between node-level consolidation and actual pod-level resource consumption, see how Cast AI’s Workload-Aware Consolidation works alongside your existing Karpenter setup.

Frequently Asked Questions

How do I migrate from Cluster Autoscaler to Karpenter?

The core architectural difference is that Cluster Autoscaler scales pre-defined node groups (ASGs), while Karpenter provisions individual nodes directly. Migration steps: (1) deploy Karpenter alongside CA, (2) create NodePools mirroring your existing node group configurations, (3) prevent CA from adding new nodes: set each managed nodegroup’s min and desired count to 0 (eksctl scale nodegroup --cluster=<cluster> --name=<ng> --nodes-min=0 --nodes=0), or annotate the nodegroup in the CA ConfigMap with cluster-autoscaler.kubernetes.io/enabled: "false"; then drain the existing CA-managed nodes with kubectl drain --ignore-daemonsets --delete-emptydir-data, (4) wait for Karpenter to reprovision workloads, (5) remove CA once NodePools are stable. Keep CA running in parallel until you’re confident in NodePool coverage – removing it before validating scheduling continuity is a common mistake. See the full migration guide.

What happens when a NodePool limit is hit?

Karpenter stops provisioning nodes for that pool. Pods that need the pool stay Pending indefinitely with no events from Karpenter explaining the cause – this makes it easy to misdiagnose as a scheduling constraint problem. The fastest check: kubectl get nodepool <name> -o jsonpath='{.status.resources}' – if allocated equals configured limit, the pool is full. Options: raise the limit, add a lower-priority fallback NodePool, or trigger consolidation to free up capacity before the limit blocks provisioning.

What consolidation policies does Karpenter support?

Three, from conservative to aggressive: WhenEmpty (consolidates only nodes with no running pods), Balanced (available in karpenter.sh/v1; check release notes for stability status; intermediate aggressiveness), and WhenEmptyOrUnderutilized (consolidates both empty and underutilized nodes). consolidateAfter is a stability timer that applies to all three, it’s how long a node must be in a consolidatable state before Karpenter acts. Set to Never to disable consolidation for a specific pool.

What’s the difference between taints and startupTaints?

Karpenter applies taints to every node it launches from the NodePool, and pods must tolerate those taints before Kubernetes schedules them on those nodes. Karpenter applies startupTaints only during node initialization, then automatically removes them after the DaemonSets and CNI finish initializing and the node becomes ready. Pods do not need to tolerate startupTaints. A common mistake is adding a taint that should be a startupTaint – which prevents all pods from scheduling on the node after it’s ready, not just during init.

Cast AIBlogKarpenter NodePools and NodeClasses: A Practical Configuration Guide