Skip to content

Karpenter Blueprint: Prioritize Reserved Capacity

Note: This blueprint focuses on Savings Plans, Reserved Instances, and On-Demand Capacity Reservations. EC2 Capacity Blocks are out of scope for this blueprint.

Purpose

If you have purchased Savings Plans, Reserved Instances, or On-Demand Capacity Reservations (ODCRs), you want to prioritize this reserved capacity before using standard on-demand or spot instances. This blueprint demonstrates how to configure Karpenter to prioritize different types of reserved capacity, ensuring maximum utilization and cost optimization.

AWS Recommendation: We recommend using On-Demand Capacity Reservations (ODCRs) for guaranteed capacity needs, especially as current guidance favors Savings Plans over Reserved Instances. Note that Savings Plans provide cost savings but do not reserve capacity. For capacity guarantees, ODCRs are the preferred approach. See AWS documentation on capacity reservation differences for more details.

This blueprint covers four main scenarios: 1. Savings Plans - Prioritize instance families that match your Savings Plans 2. Reserved Instances - Prioritize specific instance types with Reserved Instance commitments
3. On-Demand Capacity Reservations (ODCRs) - Utilize native ODCR support with the reserved capacity type 4. Migrating Existing Workloads - Transition running workloads from on-demand to reserved capacity

Requirements

  • A Kubernetes cluster with Karpenter installed. You can use the blueprint we've used to test this pattern at the cluster folder in the root of this repository.
  • For Savings Plans/Reserved Instances: A list of instance types or families that match your reservations, along with the total number of vCPUs reserved.
  • For ODCRs: Active On-Demand Capacity Reservations in your AWS account and Karpenter v1.6+ (ReservedCapacity feature gate enabled by default) or v1.3+ with manual feature gate configuration.
  • A default Karpenter NodePool and EC2NodeClass deployed in your cluster. If you used the Terraform template in this repository, these are already created for you.

Feature Gate Configuration (Karpenter < v1.6)

For Karpenter versions prior to v1.6, you must enable the ReservedCapacity feature gate:

# In your Karpenter Helm values or deployment
settings:
  featureGates:
    ReservedCapacity: true

Or via environment variable:

env:
- name: FEATURE_GATES
  value: "ReservedCapacity=true"

Scenario 1: Savings Plans Priority

Scenario: You purchased a Savings Plan covering 20 vCPUs of c5 instances. You want Karpenter to use those discounted c5 instances first, then fall back to regular pricing for any additional capacity needed.

Deploy

kubectl apply -f savings-plans.yaml
kubectl apply -f workload-savings.yaml

Results

Check results:

kubectl get nodes -o custom-columns="NAME:.metadata.name,INSTANCE:.metadata.labels.node\.kubernetes\.io/instance-type,NODEPOOL:.metadata.labels.karpenter\.sh/nodepool"
kubectl get nodepool savings-plans -o jsonpath='{.status.resources.cpu}' && echo " / 20 vCPUs used"

What this demonstrates: - Prioritization works: c5 instances are provisioned first (savings-plans NodePool) - Capacity limits respected: savings-plans NodePool reaches its 20 vCPU limit - Spillover works: Additional workload uses default NodePool when savings-plans is at capacity

Cleanup

kubectl delete -f savings-plans.yaml -f workload-savings.yaml

Scenario 2: Reserved Instances Priority

Scenario: You have Reserved Instances for specific instance types (c5.xlarge, c5.2xlarge) and want to prioritize them over other instance types. The NodePool limits capacity to 16 vCPUs and uses weight 100 for prioritization.

Key configuration:

spec:
  limits:
    cpu: "16"
  template:
    spec:
      requirements:
      - key: node.kubernetes.io/instance-type
        operator: In
        values:
        - c5.xlarge    # 4 vCPUs
        - c5.2xlarge   # 8 vCPUs
  weight: 100

Deploy

kubectl apply -f reserved-instances.yaml
kubectl apply -f workload-reserved.yaml

Results

Check the results:

kubectl get nodes -l karpenter.sh/nodepool -o custom-columns="NAME:.metadata.name,INSTANCE:.metadata.labels.node\.kubernetes\.io/instance-type,NODEPOOL:.metadata.labels.karpenter\.sh/nodepool"

Expected output:

NAME                                        INSTANCE      NODEPOOL
ip-10-0-x-x.region.compute.internal         c5.2xlarge    reserved-instances

What this demonstrates: - Reserved Instance types prioritized: c5.xlarge and c5.2xlarge are used first - Higher weight ensures prioritization over other instance types

Cleanup

kubectl delete -f reserved-instances.yaml -f workload-reserved.yaml

Scenario 3: On-Demand Capacity Reservations (ODCRs)

Prerequisites: Creating an ODCR for Testing

⚠️ COST WARNING: ODCRs incur charges whether instances are running or not. You will be billed for reserved capacity even if unused.

For testing Scenario 3, create a small ODCR first:

# Get cluster name, region and AZ from Terraform outputs
export CLUSTER_NAME=$(terraform -chdir="../../cluster/terraform" output -raw cluster_name)
export AWS_REGION=$(terraform -chdir="../../cluster/terraform" output -raw region)
export AZ=$(terraform -chdir="../../cluster/terraform" output -json availability_zones | jq -r '.[0]')

# Create ODCR
aws ec2 create-capacity-reservation \
  --instance-type t3.small \
  --instance-platform Linux/UNIX \
  --availability-zone $AZ \
  --instance-count 2 \
  --tag-specifications 'ResourceType=capacity-reservation,Tags=[{Key=intent,Value=apps},{Key=project,Value='$CLUSTER_NAME'}]' \
  --region $AWS_REGION

Important: This costs ~$0.0208/hour ($15/month) for 2 × t3.small whether used or not. Delete after testing: aws ec2 cancel-capacity-reservation --capacity-reservation-id <cr-id>

Scenario: You need guaranteed capacity for critical workloads during peak traffic or launch events. You've created On-Demand Capacity Reservations to ensure instances are always available when needed. You want Karpenter to automatically use these reserved instances first, with seamless fallback to regular on-demand when reservations are full.

Configuration Options

The EC2NodeClass can select ODCRs in two ways:

Option 1: Select by tags (recommended)

capacityReservationSelectorTerms:
- tags:
    intent: apps
    project: karpenter-blueprints

Option 2: Select by specific ODCR ID

capacityReservationSelectorTerms:
- id: cr-1234567890abcdef0

Deploy

If you're using the Terraform template provided in this repo, run the following commands to get the cluster name, IAM role name, and availability zone:

export CLUSTER_NAME=$(terraform -chdir="../../cluster/terraform" output -raw cluster_name)
export KARPENTER_NODE_IAM_ROLE_NAME=$(terraform -chdir="../../cluster/terraform" output -raw node_instance_role_name)
export AZ=$(terraform -chdir="../../cluster/terraform" output -json availability_zones | jq -r '.[0]')

Now, make sure you're in this blueprint folder, then run the following commands:

sed -i '' "s/<<CLUSTER_NAME>>/$CLUSTER_NAME/g" odcr.yaml
sed -i '' "s/<<KARPENTER_NODE_IAM_ROLE_NAME>>/$KARPENTER_NODE_IAM_ROLE_NAME/g" odcr.yaml
sed -i '' "s/<<AZ>>/$AZ/g" odcr.yaml
kubectl apply -f odcr.yaml
kubectl apply -f workload-odcr.yaml

Results

Check the results:

kubectl get nodes -L karpenter.sh/capacity-type,karpenter.k8s.aws/capacity-reservation-id,topology.kubernetes.io/zone
echo "ODCR Usage:"
aws ec2 describe-capacity-reservations --filters Name=state,Values=active --query 'CapacityReservations[*].[CapacityReservationId,InstanceType,AvailabilityZone,TotalInstanceCount,AvailableInstanceCount]' --output table

Expected output:

NAME                                        CAPACITY-TYPE   CAPACITY-RESERVATION-ID   ZONE
ip-10-0-x-x.region.compute.internal         reserved        cr-01a3e9b00c9e81913      eu-west-2a
ip-10-0-x-x.region.compute.internal         on-demand                                 eu-west-2b

ODCR Usage:
|  cr-01a3e9b00c9e81913  |  t3.small  |  eu-west-2a  |  2  |  1  |  # 1 instance used

kubectl logs -n karpenter -l app.kubernetes.io/name=karpenter --tail=50 | grep -E "replacement-nodes.*reserved|launched.*capacity-type"

Expected output:

"replacement-nodes":[{"capacity-type":"reserved",...}]  # Planned reserved first
"launched nodeclaim"..."capacity-type":"on-demand"     # Fell back to on-demand

What this demonstrates: - ODCR prioritization: Karpenter uses reserved capacity first when available - Exhaustion handling: When ODCR is full, automatically falls back to on-demand - AZ-specific behavior: ODCRs only work within their designated availability zone - Cost optimization: Reserved capacity is consumed before on-demand instances

Cleanup

kubectl delete -f odcr.yaml -f workload-odcr.yaml

Scenario 4: Migrating Existing Workloads to ODCR

Scenario: You have workloads running on regular on-demand instances and want to migrate the same NodePool to use On-Demand Capacity Reservations (ODCRs) without downtime.

Prerequisites

Ensure you have an active ODCR (from Scenario 3 or existing setup).

Deploy

Step 1: Create a stable on-demand NodePool

export CLUSTER_NAME=$(terraform -chdir="../../cluster/terraform" output -raw cluster_name)
export KARPENTER_NODE_IAM_ROLE_NAME=$(terraform -chdir="../../cluster/terraform" output -raw node_instance_role_name)
export AZ=$(terraform -chdir="../../cluster/terraform" output -json availability_zones | jq -r '.[0]')

sed -i '' "s/<<CLUSTER_NAME>>/$CLUSTER_NAME/g" migration-nodeclass.yaml
sed -i '' "s/<<KARPENTER_NODE_IAM_ROLE_NAME>>/$KARPENTER_NODE_IAM_ROLE_NAME/g" migration-nodeclass.yaml
sed -i '' "s/<<AZ>>/$AZ/g" migration-nodeclass.yaml
kubectl apply -f migration-nodeclass.yaml

Step 2: Deploy workload to the stable NodePool

kubectl apply -f workload-migration.yaml

Step 3: Verify initial on-demand placement

kubectl get nodes -L karpenter.sh/capacity-type,karpenter.k8s.aws/capacity-reservation-id,karpenter.sh/nodepool

Expected output:

NAME                                       STATUS   ROLES    AGE   VERSION               CAPACITY-TYPE   CAPACITY-RESERVATION-ID   NODEPOOL
ip-10-0-34-41.eu-west-2.compute.internal   Ready    <none>   18h   v1.34.2-eks-ecaa3a6                                             
ip-10-0-5-48.eu-west-2.compute.internal    Ready    <none>   37s   v1.34.2-eks-ecaa3a6   on-demand                                 reserved-migration
ip-10-0-9-147.eu-west-2.compute.internal   Ready    <none>   18h   v1.34.2-eks-ecaa3a6   

Migration to ODCR

Step 4: Add ODCR configuration to existing EC2NodeClass

kubectl patch ec2nodeclass migration-nodeclass --type='merge' -p='{
  "spec": {
    "capacityReservationSelectorTerms": [{
      "tags": {
        "intent": "apps",
        "project": "'$CLUSTER_NAME'"
      }
    }]
  }
}'

Step 5: Update NodePool to prioritize reserved capacity

kubectl patch nodepool reserved-migration --type='merge' -p='{
  "spec": {
    "template": {
      "spec": {
        "requirements": [{
          "key": "karpenter.sh/capacity-type",
          "operator": "In",
          "values": ["reserved", "on-demand"]
        }]
      }
    }
  }
}'

Step 6: Trigger drift to migrate to ODCR

kubectl rollout restart deployment migration-workload

Results

Monitor the migration:

kubectl get nodes -L karpenter.sh/capacity-type,karpenter.k8s.aws/capacity-reservation-id,karpenter.sh/nodepool

Expected output after migration:

NAME                     CAPACITY-TYPE   CAPACITY-RESERVATION-ID   NODEPOOL
NAME                                       STATUS     ROLES    AGE    VERSION               CAPACITY-TYPE   CAPACITY-RESERVATION-ID   NODEPOOL
ip-10-0-0-120.eu-west-2.compute.internal   NotReady   <none>   105s   v1.34.2-eks-ecaa3a6   reserved        cr-01a3e9b00c9e81913      reserved-migration
ip-10-0-34-41.eu-west-2.compute.internal   Ready      <none>   18h    v1.34.2-eks-ecaa3a6                                             
ip-10-0-6-17.eu-west-2.compute.internal    Ready      <none>   94s    v1.34.2-eks-ecaa3a6   reserved        cr-01a3e9b00c9e81913      reserved-migration
ip-10-0-9-147.eu-west-2.compute.internal   Ready      <none>   18h    v1.34.2-eks-ecaa3a6

Check ODCR usage:

aws ec2 describe-capacity-reservations --filters Name=state,Values=active --query 'CapacityReservations[*].[CapacityReservationId,InstanceType,TotalInstanceCount,AvailableInstanceCount]' --output table

What this demonstrates: - Same NodePool migration: Existing NodePool migrates from on-demand to reserved capacity - Drift-based replacement: Configuration changes trigger node replacement - ODCR utilization: New nodes use reserved capacity first - Zero downtime: Rolling restart ensures seamless transition

Cleanup

kubectl delete -f workload-migration.yaml -f migration-nodeclass.yaml
EKS Auto Mode **Prerequisite:** an EKS cluster with Auto Mode enabled, and an EKS Access Entry granting `AmazonEKSAutoNodePolicy` to the node IAM role used by Auto Mode. > If you're using the Terraform template under [`cluster/automode/`](https://github.com/aws-samples/karpenter-blueprints/tree/main/cluster/automode) in this repo, the cluster, node IAM role, and Access Entry are all created for you — you can skip the manual access entry steps below. To deploy this blueprint on Auto Mode, use the `-automode.yaml` manifest instead of the OSS one:
kubectl apply -f savings-plans-automode.yaml
kubectl apply -f reserved-instances-automode.yaml
kubectl apply -f odcr-automode.yaml
kubectl apply -f migration-nodeclass-automode.yaml
Differences from the OSS version: - `NodeClass` (`eks.amazonaws.com/v1`) replaces `EC2NodeClass` (`karpenter.k8s.aws/v1`) - `amiFamily`, `amiSelectorTerms`, `role`, `metadataOptions`, and `blockDeviceMappings` are removed — Auto Mode manages these - Instance label keys use the `eks.amazonaws.com/` prefix instead of `karpenter.k8s.aws/` If you are **not** using the `cluster/automode/` Terraform template, configure the Access Entry manually:
aws eks create-access-entry \
  --cluster-name $CLUSTER_NAME \
  --principal-arn <node-role-arn> \
  --type EC2

aws eks associate-access-policy \
  --cluster-name $CLUSTER_NAME \
  --principal-arn <node-role-arn> \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSAutoNodePolicy \
  --access-scope type=cluster

Complete Cleanup

To remove all objects from all scenarios:

kubectl delete -f .

Don't forget to cancel any ODCRs you created for testing:

# List active ODCRs
aws ec2 describe-capacity-reservations --filters Name=state,Values=active --query 'CapacityReservations[*].[CapacityReservationId,InstanceType,InstanceCount]' --output table

# Cancel ODCR (replace with your ID)
aws ec2 cancel-capacity-reservation --capacity-reservation-id <cr-id>

Additional Resources