Back to blog
DevOps
AdvancedForPlatform EngineersSREKubernetes Operators
12 min

Kubernetes Autoscaling: Three Controllers That Do Not Talk to Each Other

HPA, VPA and the node autoscaler all read resource requests, none of them coordinate, and every one of them fails silently when requests are wrong. The mechanics that decide whether your cluster scales or just thrashes.

kubernetes-autoscalinghpa-kuberneteshorizontal-pod-autoscalervertical-pod-autoscalercluster-autoscalerkarpenterin-place-pod-resize
Cover image: Kubernetes Autoscaling: Three Controllers That Do Not Talk to Each Other
Contents

Most teams install all three Kubernetes autoscalers and assume they add up to a system. They do not. HPA, VPA and the node autoscaler are three independent controllers with no shared state and no negotiation protocol.

What they do share is an input: resource requests. Every one of them reads requests to make decisions. None of them read what your application actually uses, except indirectly. Get requests wrong and all three degrade at once — quietly, without a single error event.

This is a tour of the mechanics that actually decide whether your cluster scales or just thrashes.

The three Kubernetes autoscaling layers — HPA changing replica count, VPA changing Pod size, and the node autoscaler changing machine count — all reading the same resource requests with no coordination between them
Three controllers, three different objects, one shared input and zero coordination.

What Each Layer Actually Changes

What Each Layer Actually Changes
Changes Lives in Reads
HPA Replica count Core Kubernetes API Metrics API, requests
VPA CPU/memory requests Add-on CRD Metrics API, history, OOM events
Node autoscaler Number of machines Add-on + cloud API Pending Pods, requests

The HPA is the only one that ships with Kubernetes. VPA is a separate install with three of its own components. Node autoscaling requires a cloud provider integration. That asymmetry matters when you are debugging: two of the three are things you added.

HPA: The Formula Is Simpler Than You Think

The controller runs on a loop — every 15 seconds by default — and computes:

desiredReplicas = ceil[ currentReplicas × ( currentMetricValue / desiredMetricValue ) ]

That is the whole idea. Current metric double the target? Double the replicas. Half the target? Halve them.

Three things about this formula cause most production surprises.

Utilization is measured against requests, not limits. When you set averageUtilization: 60, you are saying “keep average usage at 60% of what the Pods requested.” The request is the denominator. This is why requests are not just a scheduling hint — they are the calibration of your autoscaler.

The controller skips small changes. If the ratio is close enough to 1.0 it does nothing. The default tolerance is 10%, cluster-wide. A workload sitting at 105% of its target will not scale, and there is no event explaining the inaction. Kubernetes 1.35 added a per-HPA tolerance field in beta, so you can now tighten it for one workload:

behavior:
scaleUp:
tolerance: 0.05

Before that, changing it meant a --horizontal-pod-autoscaler-tolerance flag on the controller manager, affecting every workload in the cluster.

Multiple metrics take the maximum. Specify CPU and memory and a custom queue-depth metric, and the HPA computes a desired replica count from each and picks the largest. It is an OR, not a blend. Adding a metric can only make your workload bigger.

The Silent Failure: No Request, No Autoscaling

This is the single most common broken-HPA cause, and it deserves its own section because of how it fails.

If a container in the Pod does not have the relevant resource request set, CPU utilization for that Pod is undefined, and the autoscaler takes no action for that metric.

No error. No warning event. kubectl get hpa shows the object exists. The TARGETS column shows <unknown>, which is easy to read as “still gathering metrics” rather than “permanently broken.”

The fix is trivial once you know. Finding out is the expensive part:

Terminal window
kubectl get hpa -A -o custom-columns=\
NS:.metadata.namespace,NAME:.metadata.name,TARGETS:.status.currentMetrics

Anything showing no current metrics after a few minutes is not warming up. It is misconfigured.

The Second Silent Failure: Your Sidecar Is Diluting the Signal

HPA sums resource usage across all containers in the Pod. The docs are explicit that this may not represent individual container usage — a single container can run hot while the Pod average stays comfortable, and the HPA never scales out.

Service meshes and logging sidecars make this routine. An application container at 95% of its request paired with an idle sidecar at 5% averages to something that looks fine.

Since Kubernetes 1.30 there is a stable fix — scale on one named container instead of the Pod:

metrics:
- type: ContainerResource
containerResource:
name: cpu
container: application
target:
type: Utilization
averageUtilization: 60

One caveat worth knowing: if you rename that container, update the HPA to track both names before rolling out the workload change, otherwise the recommendation goes blind mid-rollout.

Scale Up Fast, Scale Down Slow — On Purpose

The default behaviour is deliberately asymmetric, and misreading it produces a lot of false bug reports.

Scale Up Fast, Scale Down Slow — On Purpose
Stabilization window Rate limit
Scale up 0 seconds 100% of replicas or 4 Pods per 15s, whichever is larger
Scale down 300 seconds 100% per 15s, after the window

Scale-up is immediate. Scale-down looks at all recommendations from the past five minutes and takes the highest one — a rolling maximum that stops the autoscaler from removing Pods it will need again moments later.

So when someone reports “the HPA is not scaling down,” the answer is usually that it is working exactly as designed and they are watching a five-minute window. If you genuinely need it faster:

behavior:
scaleDown:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 10
periodSeconds: 60

There is also a trap in how you deploy. If you keep spec.replicas in a manifest that an HPA is managing, every kubectl apply resets the count to whatever is in the file — and the HPA pulls it back. That fight is what thrashing looks like on a dashboard. Remove the field from the manifest once the HPA owns the workload.

VPA: Different Problem, Different Failure Mode

The VerticalPodAutoscaler answers a question HPA cannot: how big should each Pod be? It runs three components — a recommender that analyses usage history, peaks and OOM events, an updater that applies changes, and an admission controller webhook that injects the recommended requests into new Pods.

The update mode is the whole decision:

VPA: Different Problem, Different Failure Mode
Mode Behaviour
Off Recommend only. Nothing is applied.
Initial Set requests at Pod creation, never again.
Recreate Evict Pods when requests drift from recommendation.
InPlaceOrRecreate Resize in place if possible, evict if not.
InPlace Resize in place, never evict — defer and retry instead.
Auto Deprecated since VPA 1.4.0. Alias for Recreate.

Start at Off. It is genuinely useful on its own: the recommender writes a target, a lower bound and an upper bound into .status.recommendation, which is a free rightsizing audit with no production risk.

The Big 2026 Change — and a Documentation Contradiction

For years the honest objection to VPA was that vertical scaling meant killing the Pod. In-place Pod resize reached stable in Kubernetes 1.35 and is enabled by default. You can now change CPU and memory on a running container:

Terminal window
kubectl patch pod my-pod --subresource resize --patch \
'{"spec":{"containers":[{"name":"app",
"resources":{"requests":{"cpu":"800m"},"limits":{"cpu":"800m"}}}]}}'

Per-resource policy controls whether a restart is needed — and memory usually still needs one, because most runtimes cannot shrink their heap on demand:

resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired # default
- resourceName: memory
restartPolicy: RestartContainer

Here I have to flag something. The Kubernetes documentation contradicts itself on whether VPA can use this. The autoscaling overview page states that as of 1.36 VPA does not support resizing Pods in place. The VPA page documents InPlaceOrRecreate and InPlace in full, noting InPlace is alpha in VPA 1.7.0 and needs Kubernetes 1.33+ with the InPlacePodVerticalScaling gate plus InPlace gates on the VPA updater and admission controller.

The VPA page was revised more recently, so the overview is most likely stale. But I could not resolve this from the docs alone, and neither can you — check your installed VPA version and test in a non-production cluster before promising anyone restart-free rightsizing.

Worth knowing regardless of mode: a resize cannot change the Pod’s QoS class. A Guaranteed Pod must keep requests equal to limits after the resize. A Burstable Pod cannot become Guaranteed. Windows Pods are not supported at all. If a resize does not fit the node, the Pod gets a PodResizePending condition with reason: Infeasible — a condition to alert on, because nothing else will tell you.

Why HPA and VPA Fight

Now put the two together on CPU and follow the arithmetic.

HPA computes utilization as usage / request. VPA’s entire job is to change request.

  1. VPA observes low usage and lowers the request.
  2. The same unchanged application now shows much higher utilization — the denominator shrank.
  3. HPA sees utilization above target and adds replicas.
  4. Load spreads across more Pods, so per-Pod usage drops.
  5. VPA observes low usage and lowers the request again.

Neither controller is malfunctioning. They are moving the numerator and denominator of the same fraction with no knowledge of each other.

Combinations that work in practice:

  • VPA on memory, HPA on CPU. Different resources, no shared fraction. The most common production pairing.
  • VPA in Off mode. Recommendations feed a human or a pipeline, not the live workload.
  • VPA scoped with controlledResources so it never touches the resource HPA scales on:
resourcePolicy:
containerPolicies:
- containerName: "application"
controlledResources: ["memory"]
controlledValues: RequestsOnly

controlledValues: RequestsOnly is worth calling out. The default is RequestsAndLimits, which scales the limit proportionally to preserve your request-to-limit ratio. If you have deliberately set a wide gap between request and limit for burst headroom, the default will maintain that gap as it moves — which may be exactly what you want, or may quietly grow your limits far beyond what you intended.

The feedback loop between HPA and VPA on the same CPU resource: VPA lowers the request, measured utilization rises, HPA adds replicas, per-Pod usage falls, and VPA lowers the request again
Nothing is broken. Both controllers are correctly editing opposite halves of the same fraction.

The Node Layer: Provisioning and Consolidation

Underneath both Pod-level autoscalers sits the node autoscaler, which does two things the Kubernetes docs now name explicitly:

  • Provisioning — add nodes so pending Pods can schedule. Formerly called scale-up.
  • Consolidation — remove underutilised nodes. Formerly called scale-down.

Two implementations are sponsored by SIG Autoscaling, and the difference is not really about scaling quality:

The Node Layer: Provisioning and Consolidation
Cluster Autoscaler Karpenter
Node shapes Pre-configured node groups Auto-provisioned from constraints
Scope Node autoscaling only Full node lifecycle
Node refresh / upgrade No Yes — recreates nodes after a set lifetime
Cloud providers Many, including smaller ones Fewer — AWS, Azure
Delivery Integrations in the Kubernetes project Published as a library providers integrate

Cluster Autoscaler asks “which of my configured groups fits these pending Pods?” Karpenter asks “what machine should exist for these pending Pods?” — and then also takes responsibility for retiring that machine later.

The Third Silent Failure: Consolidation Ignores Real Usage

This one costs the most money, and it follows directly from the shared-input problem.

Consolidation, like provisioning, considers only Pod resource requests — not real resource usage.

A node whose Pods request 90% of capacity and actually consume 5% is, to the autoscaler, a fully packed node. It will never be reclaimed. Your dashboards show a nearly idle cluster, your invoice shows a full one, and no controller emits an event about it, because from the scheduler’s point of view nothing is wrong.

This is why rightsizing is a prerequisite for node-level cost efficiency rather than a separate optimisation you get to later. The Kubernetes docs say it plainly: setting requests correctly matters as much to cost-effectiveness as node utilisation does.

One related caution: do not run VPA on DaemonSet Pods when using node autoscaling. The autoscaler has to predict what DaemonSet Pods will consume on a hypothetical new node in order to estimate its usable capacity. A VPA that keeps changing those requests makes the prediction unreliable, and wrong predictions produce wrong scaling decisions.

Where KEDA Fits

HPA scales on resource utilization, which is a lagging signal — CPU rises only after work has already arrived and started queueing. For queue-driven workloads, that is backwards.

KEDA, a CNCF-graduated project, scales on the event source itself: messages in a queue, lag on a topic, rows pending. It also handles the case CPU cannot express at all — scaling to zero when the queue is empty. Its Cron scaler covers scheduled scaling, which is the honest answer for predictable daily traffic patterns where reacting is strictly worse than knowing.

KEDA does not replace HPA. It creates and drives one.

A Working Order of Operations

  1. Set requests deliberately. Everything else reads them. Guessing here corrupts all three layers at once.
  2. Install VPA in Off mode and let it observe for a week. Compare .status.recommendation against what you actually requested. The gap is your real rightsizing opportunity.
  3. Fix requests based on that data — by hand or through your pipeline. Node consolidation starts working the moment requests reflect reality.
  4. Add HPA on the metric that reflects your load. Use ContainerResource if the Pod has sidecars.
  5. Keep HPA and VPA off the same resource. Memory-VPA with CPU-HPA is the default safe pairing.
  6. Enable node autoscaling last. It amplifies whatever your requests already say — accurate or not.
  7. Alert on PodResizePending with reason: Infeasible if you use in-place resize, and on HPA ScalingActive: false. Both are silent failures otherwise.

The Point

There is no such thing as “turning on Kubernetes autoscaling.” There are three controllers operating on three different objects, coordinating through nothing but the resource requests you wrote.

Every failure mode in this article traces back to the same root: a controller read a request, believed it, and acted correctly on bad information. HPA does not scale because a request is missing. HPA thrashes because VPA is editing the request underneath it. Nodes never consolidate because requests describe a cluster that does not exist.

Autoscaling does not make resource sizing someone else’s problem. It makes it the only problem.


Related reading: for the layer these controllers run inside, see platform engineering on Kubernetes. If you are choosing a distribution first, see k3s vs k0s vs MicroK8s vs RKE2. For the managed-versus-self-managed trade-off on GKE, see GKE Autopilot vs Standard. To enforce that requests are actually set, see OPA vs Kyverno.

Sources: Horizontal Pod Autoscaling, Vertical Pod Autoscaling (revised 31 May 2026), Autoscaling Workloads, Node Autoscaling and Resize CPU and Memory Resources assigned to Containers, all Kubernetes documentation, CC BY 4.0.

Frequently asked questions

What is the difference between HPA, VPA and Cluster Autoscaler?

They operate on three different objects. The HorizontalPodAutoscaler changes the replica count of a workload — more Pods, same size. The VerticalPodAutoscaler changes the CPU and memory requests of the Pods themselves — same count, different size. The node autoscaler (Cluster Autoscaler or Karpenter) changes how many machines exist underneath — it provisions nodes when Pods cannot be scheduled and consolidates nodes that are underutilised. They are three separate controllers with no coordination between them, which is why combining them requires thought rather than just installing all three.

Why is my HPA not scaling?

The most common cause is missing resource requests. HPA computes utilization as a percentage of the CPU or memory request, so if a container in the Pod has no relevant request set, the utilization for that Pod is undefined and the controller takes no action on that metric. It does not raise an error. Second most common: the default tolerance is 10%, so a metric sitting at 105% of target will not trigger anything. Third: HPA sums resource usage across all containers in the Pod, so a busy application container can be diluted by an idle sidecar — use a ContainerResource metric to target one container specifically.

Can I use HPA and VPA together?

Not on the same resource without care. HPA on CPU computes utilization as usage divided by request. VPA's job is to change that request. When VPA lowers a request, measured utilization jumps even though nothing about the application changed, and HPA reacts by adding replicas — which lowers per-Pod usage, which feeds back into VPA. The two controllers move the numerator and denominator of the same fraction independently. The workable combinations are: VPA on memory with HPA on CPU, VPA in Off mode as a recommendation engine only, or VPA restricted with controlledResources so it never touches the resource HPA scales on.

Does in-place Pod resize mean VPA no longer restarts Pods?

In-place Pod resize reached stable in Kubernetes 1.35 and is enabled by default, so the platform capability is there. VPA exposes it through the InPlaceOrRecreate update mode, and an InPlace mode that never falls back to eviction, available as an alpha feature in VPA 1.7.0 and requiring Kubernetes 1.33 or later with the relevant feature gates. Be aware that the Kubernetes documentation is currently inconsistent on this point — the autoscaling overview page still states that VPA does not support in-place resizing as of 1.36, while the VPA page documents the modes in detail. Verify against your actual VPA version rather than trusting either page.

Should I use Cluster Autoscaler or Karpenter?

Cluster Autoscaler adds and removes nodes within node groups you have configured in advance, and it integrates with a long list of cloud providers, including smaller ones. Karpenter auto-provisions nodes without pre-defined groups — you give it constraints and it picks the instance shape — and it manages the whole node lifecycle, including refreshing nodes after a set lifetime and upgrading them to new images. Karpenter has fewer provider integrations, currently AWS and Azure. If your provider supports both, the question is whether you want node-group bookkeeping or node lifecycle management.

Why is my cluster not scaling down even though usage is low?

Because consolidation decisions are made on resource requests, not on actual usage — the same blind spot as provisioning. A node whose Pods request 90% of its capacity but consume 5% looks fully packed to the autoscaler and will never be reclaimed. This is why vertical rightsizing is a prerequisite for node-level cost efficiency, not a separate optimisation. Other common blockers: Pods without controllers to recreate them, restrictive PodDisruptionBudgets, and local storage that prevents eviction.

From the community

Discussion on the Fediverse

Replies from Mastodon and Bluesky — straight from the open web, no tracking.

Loading replies …

ENDE