Scaling Karpenter Capacity Buffers
Karpenter provisions nodes when pods are pending, which means the first pod of a scale-up wave still waits for an instance to boot. Capacity buffers (CapacityBuffer, autoscaling.x-k8s.io/v1beta1) close that gap: a buffer describes spare capacity as a number of chunks of a pod shape, and Karpenter holds enough nodes for those chunks at all times, so real pods land on warm capacity instantly.
The buffer API has one practical limitation: spec.replicas exists, but the CRD exposes no /scale subresource. The HPA, KEDA, and kubectl scale cannot target it, so out of the box the buffer size is a static number you edit by hand. Karpenter’s own blueprint resorts to a shell script patching spec.replicas for its forecast-driven scenario.
Scale Adapter removes that limitation. Its replica field-path mode bridges the missing /scale contract, so any KEDA or Kedify trigger can drive the buffer size: a cron schedule for known peaks, a queue depth or request rate for demand-driven warm pools, or the Predictive Scaler for forecasted load.
KEDA trigger (cron, prometheus, predictive, ...) │ metric ▼HPA / KEDA ──> ScaleAdapter ──> CapacityBuffer.spec.replicas ──> virtual pods ──> warm nodesPrerequisites
Section titled “Prerequisites”-
Karpenter v1.14.0 or newer with the
CapacityBufferfeature gate enabled. Capacity buffers are alpha and the gate is off by default. On EKS:Terminal window helm upgrade karpenter oci://public.ecr.aws/karpenter/karpenter -n karpenter \--reuse-values --set settings.featureGates.capacityBuffer=trueAvailability differs per provider: the AWS provider ships buffers since v1.14.0. The Azure provider and AKS Node Auto Provisioning do not support them yet. For local experiments without a cloud account, Karpenter’s kwok provider supports buffers and fakes the node lifecycle on any cluster.
-
Kedify Agent v0.6.8 or newer with the Scale Adapter controller enabled and RBAC for buffers. Field-path targets are read through an informer cache and updated as whole resources, so the grant covers the full resource:
agent:features:scaleAdaptersEnabled: trueextraRbacRules:- apiGroups: ["autoscaling.x-k8s.io"]resources: ["capacitybuffers"]verbs: ["get", "list", "watch", "update"]
Create the Buffer
Section titled “Create the Buffer”A buffer references a PodTemplate describing one chunk of capacity and a replicas count of how many such chunks to keep warm. Chunks are bin-packed like real pods, so N chunks does not mean N nodes; Karpenter provisions new nodes only when the chunks no longer fit the existing capacity.
If your buffer capacity should be reserved on dedicated nodes, taint the NodePool and give the pod template a matching toleration, as the Karpenter blueprint does. Note that buffers grant no exclusivity: any pod tolerating the taint can use the warm capacity, which is exactly what makes the pre-warmed nodes useful.
apiVersion: v1kind: PodTemplatemetadata: name: standard-workload-shape namespace: defaulttemplate: metadata: labels: app: warm-pool spec: containers: - name: placeholder image: registry.k8s.io/pause:3.9 resources: requests: cpu: "1" memory: 1Gi---apiVersion: autoscaling.x-k8s.io/v1beta1kind: CapacityBuffermetadata: name: warm-pool namespace: defaultspec: podTemplateRef: name: standard-workload-shape replicas: 1The template’s containers never run. Karpenter turns the buffer into in-memory virtual pods that participate in scheduling simulation only, so the image is never pulled and nothing is written to the cluster beyond the buffer itself.
Adapt and Scale
Section titled “Adapt and Scale”Create the adapter in the buffer’s namespace, pointing the field paths at the buffer’s replica fields. Leave the adapter’s spec.replicas unset; the controller initializes it from the buffer, so creating the adapter never changes the buffer size:
apiVersion: autoscaling.kedify.io/v1alpha1kind: ScaleAdaptermetadata: name: warm-pool namespace: defaultspec: targetRef: apiVersion: autoscaling.x-k8s.io/v1beta1 kind: CapacityBuffer name: warm-pool selector: matchLabels: app: warm-pool desiredReplicasPath: ".spec.replicas" currentReplicasPath: ".status.replicas"The selector is nominal: buffer chunks are virtual and never exist as Pods, so nothing will ever match it. It only satisfies the HPA’s requirement for a non-empty scale selector, see the selector caveat.
Then point a regular ScaledObject at the adapter. A cron trigger that pre-provisions ten chunks of capacity ahead of the morning ramp-up and releases them at night looks like this:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: warm-pool namespace: defaultspec: scaleTargetRef: apiVersion: autoscaling.kedify.io/v1alpha1 kind: ScaleAdapter name: warm-pool minReplicaCount: 0 maxReplicaCount: 20 triggers: - type: cron metadata: timezone: Europe/Prague start: 30 7 * * 1-5 end: 0 20 * * 1-5 desiredReplicas: "10"With minReplicaCount: 0 the buffer drops to zero chunks outside the window, and Karpenter consolidates the now-empty nodes away. Any other trigger works the same way, including combining several triggers so the buffer follows whichever demand signal is highest.
Verify
Section titled “Verify”kubectl get scaleadapter,capacitybuffer,nodeclaimsInside the cron window the adapter forwards the desired count to the buffer, and Karpenter provisions nodes for the chunks that do not fit existing capacity:
NAME TARGET KIND DESIRED CURRENT READYscaleadapter.autoscaling.kedify.io/warm-pool warm-pool CapacityBuffer 10 10 True
NAME ... REPLICAS CONDITIONSTYPE CONDITIONSSTATUScapacitybuffer.autoscaling.x-k8s.io/warm-pool ... 10 ReadyForProvisioning True
NAME TYPE CAPACITY READYnodeclaim.karpenter.sh/wp-p8dz8 c-4x-large on-demand Truenodeclaim.karpenter.sh/wp-w7llp c-4x-large on-demand TrueThe buffer’s Provisioning condition reports FitsExistingCapacity once all virtual pods fit, and kubectl get nodes shows the warm nodes. When the trigger deactivates, the buffer reports 0 replicas and the empty nodes are consolidated according to the NodePool disruption settings.
Behavior Notes
Section titled “Behavior Notes”- Reaction time. The buffer controller re-resolves templates and counts on a polling loop of roughly 30 seconds, on top of the usual KEDA and HPA intervals. Expect up to a minute between a trigger change and the node request. That envelope is fine for the use case, since the buffer exists to absorb instance boot time on behalf of the real workload.
- Single writer. The adapter is authoritative over
spec.replicas. Do not edit the buffer size by hand or from another controller while the adapter manages it; the adapter overwrites external changes and raises theConflictingReplicaWritercondition. - Reactivation and stabilization. After a scale to zero, the HPA’s downscale stabilization window can briefly restore the last recommendation from before the idle period. If the overshoot matters, tune
spec.advanced.horizontalPodAutoscalerConfig.behavioron theScaledObject. - Empty-node protection. Nodes held by buffer chunks are excluded from Karpenter’s empty-node consolidation, but drift, expiry, and underutilization disruption still apply, with replacement nodes sized to keep fitting the chunks.