HPA vs VPA vs Cluster Autoscaler: Which Scales What
6 min readBytePatterns
HPA scales replicas, VPA scales each Pod's requests, Cluster Autoscaler scales nodes: how the three chain, why HPA and VPA clash on CPU, and the VPA modes.
HPA vs VPA vs Cluster Autoscaler: Which Scales What, in one line: the HorizontalPodAutoscaler changes how many Pods a workload runs, the Vertical Pod Autoscaler changes how much CPU and memory each Pod requests, and the Cluster Autoscaler changes how many nodes the cluster has. They chain, with one rule: HPA and VPA must not both act on the same workload's CPU or memory. Facts come from the Kubernetes documentation and the kubernetes/autoscaler project listed at the end, as of October 2026.
| Autoscaler | Changes | Reacts to |
|---|---|---|
| HPA | the replica count | a metric against its target, such as CPU as a % of the request |
| VPA | each Pod's requests, and limits in proportion | observed usage of the Pods over time |
| Cluster Autoscaler | the node count | Pods that cannot be scheduled, and underused nodes |
The problem it solves
How the HPA computes replicas from the request is covered in Kubernetes requests, limits and the HPA, and what requests and limits do on a node in Requests vs limits: what OOMKilled means. Here the three autoscalers stand side by side:
- More traffic: more Pods of the same size. That is horizontal.
- Wrong sizing: the same number of Pods, each asking for what it really uses. That is vertical.
- No room: Pods that fit no node. That is a node problem, solved by adding nodes.
The intuition
HPA: replicas. The controller computes ceil(currentReplicas × currentMetricValue / desiredMetricValue) and sets the replica count of a Deployment or StatefulSet. It does not apply to objects that cannot be scaled, such as a DaemonSet. For CPU utilization it divides usage by the request, so without a request it takes no action for that metric.
VPA: requests. VPA ships separately from the core, in the kubernetes/autoscaler project, as a recommender, an updater and an admission controller. The recommender computes requests from usage; updateMode decides what happens next:
Off: recommendations are stored in the VPA object's status and applied to nothing.Initial: requests are set only when Pods are created, never changed afterwards.Recreate: Pods whose requests differ significantly from the recommendation are evicted, respecting PodDisruptionBudgets, and the replacements get the new requests.InPlaceOrRecreate: resize in place where possible, fall back to eviction otherwise.InPlace: alpha in VPA 1.7.0, needs Kubernetes 1.33 or later with in-place resize enabled, and never evicts.Auto: deprecated since VPA 1.4.0, an alias forRecreate.
Cluster Autoscaler: nodes. It scales up when Pods fail to schedule on any current node for lack of resources, and only if a node like the existing ones would help. It scales a node down when the sum of its Pods' requests is under a threshold, half of allocatable by default, its Pods can all move elsewhere, and it has stayed unneeded for a while, ten minutes by default. Pods with a restrictive PodDisruptionBudget, no controller or local storage block removal. It reads requests, never actual usage.
The chain. Traffic rises, the HPA adds replicas, the new Pods do not fit, they wait Pending, the Cluster Autoscaler adds a node, and they schedule. VPA sits beside the chain, making the requests that both count honest.
The clash. The VPA documentation says it should not be used with the HPA on the same resource metric, CPU or memory. The HPA's CPU utilization is usage divided by the request; VPA moves the request toward usage. Each one moves the other's input. Using VPA on memory and the HPA on CPU, or the HPA on a custom metric, is the documented way to combine them.
Watch it run
The lesson animation is the first half of the chain. One bar per Pod shows CPU use as a percentage of the 250m request, against a 60% target. Traffic spikes to 90%, the HPA computes ceil(4 × 90 / 60) = 6, and the two new Pods wait Pending because no node has room. Node autoscaling adds a node and they schedule, bringing the bars back to target. That handoff from HPA to nodes is the chain.
Requests, Limits & HPA
Step 1 of 12
One bar per Pod: its CPU use as a percentage of its request, 250m. The HPA's target is 60%.
The same interactive animation as the lesson — step through it with the controls.
The code
An HPA and a VPA that do not collide: the HPA owns CPU through replicas, VPA starts in Off and only recommends memory:
# illustrative: HPA on CPU, VPA limited to memory
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
minReplicas: 4
maxReplicas: 10
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
---
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata: { name: api }
spec:
targetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
updatePolicy:
updateMode: "Off" # recommend only; later InPlaceOrRecreate
resourcePolicy:
containerPolicies:
- containerName: "*"
controlledResources: ["memory"]
A toy model of the chain, not the controllers. Each Pod requests 250m, a node fits three, and the cluster starts with two nodes. The HPA applies its formula with the 0.1 tolerance; Pods that do not fit stay Pending; the Cluster Autoscaler orders a node that arrives two ticks later; and when load drops, the last node is removed once its requests stay under half its allocatable and its Pods fit elsewhere:
import math
from fractions import Fraction
REQUEST_M, TARGET = 250, 60 # the lesson's 250m request, 60% target
NODE_M, PODS_PER_NODE = 900, 3 # 3 x 250m fit in 900m allocatable
def hpa(replicas, total_use_m, min_r=4, max_r=10, tolerance=Fraction(1, 10)):
"""Toy model of the HPA rule ceil(replicas x current / target)."""
ratio = Fraction(total_use_m * 100, replicas * REQUEST_M * TARGET)
if abs(ratio - 1) <= tolerance:
return replicas
return max(min_r, min(max_r, math.ceil(replicas * ratio)))
def pack(replicas, nodes):
"""Scheduler toy: fill nodes in order; what does not fit stays Pending."""
per_node = [min(PODS_PER_NODE, max(0, replicas - i * PODS_PER_NODE)) for i in range(nodes)]
return per_node, replicas - sum(per_node)
def simulate(load_m, nodes=2, provision=2, unneeded=2):
"""Toy timeline: HPA -> Pending Pods -> cluster autoscaler adds nodes;
later, a node whose requests are under 50% and whose Pods fit elsewhere
is removed after `unneeded` ticks. Ignores the HPA's 300 s window."""
replicas, arriving, idle = 4, [], 0
for t, total in enumerate(load_m):
nodes += arriving.count(t)
arriving = [a for a in arriving if a > t]
replicas = hpa(replicas, total)
per_node, pending = pack(replicas, nodes)
if pending and not arriving: # scale up for the Pending Pods
arriving = [t + provision] * math.ceil(pending / PODS_PER_NODE)
last = per_node[-1] if per_node else 0
spare = sum(PODS_PER_NODE - n for n in per_node[:-1])
if nodes > 1 and last * REQUEST_M < NODE_M / 2 and last <= spare:
idle += 1 # the last node is unneeded
if idle >= unneeded:
nodes, idle = nodes - 1, 0
per_node = pack(replicas, nodes)[0]
else:
idle = 0
print(f"t={t} load={total}m replicas={replicas} pods/node={per_node} "
f"pending={pending} nodes={nodes}" + (f" (+{len(arriving)} coming)" if arriving else ""))
simulate([600, 900, 1350, 1350, 1350, 600, 600, 600, 600])
# t=0 load=600m replicas=4 pods/node=[3, 1] pending=0 nodes=2
# t=1 load=900m replicas=6 pods/node=[3, 3] pending=0 nodes=2
# t=2 load=1350m replicas=9 pods/node=[3, 3] pending=3 nodes=2 (+1 coming)
# t=3 load=1350m replicas=9 pods/node=[3, 3] pending=3 nodes=2 (+1 coming)
# t=4 load=1350m replicas=9 pods/node=[3, 3, 3] pending=0 nodes=3
# t=5 load=600m replicas=4 pods/node=[3, 1, 0] pending=0 nodes=3
# t=6 load=600m replicas=4 pods/node=[3, 1] pending=0 nodes=2
# t=7 load=600m replicas=4 pods/node=[3, 1] pending=0 nodes=2
# t=8 load=600m replicas=4 pods/node=[3, 1] pending=0 nodes=2
At t=2 the HPA has done its job, but three Pods wait Pending for two ticks while the running six sit at 90%: horizontal scaling is only as fast as node provisioning. At t=0 the second node holds one Pod, under half its allocatable, yet stays, because that Pod fits nowhere else.
Next, the clash. A toy rule, not the real VPA recommender, which works from usage history: VPA sets the CPU request to usage × 1.15, and the HPA scales on CPU as a percentage of that same request:
def fight(total_use_m=1500, replicas=4, request_m=250, rounds=6, max_r=12):
"""Toy model, not the real VPA recommender: VPA sizes the CPU request to
usage x 1.15 while the HPA scales on CPU as a percentage of that request."""
for r in range(rounds):
per_pod = total_use_m / replicas
util = round(100 * per_pod / request_m)
print(f"round {r}: replicas={replicas} request={request_m:.0f}m usage={per_pod:.0f}m util={util}%")
replicas = min(max_r, math.ceil(replicas * per_pod / request_m * 100 / TARGET))
request_m = per_pod * 1.15 # VPA chases the new usage
fight()
alone = [4]
for _ in range(5):
alone.append(hpa(alone[-1], 1500))
print("HPA alone, request fixed at 250m:", alone)
# round 0: replicas=4 request=250m usage=375m util=150%
# round 1: replicas=10 request=431m usage=150m util=35%
# round 2: replicas=6 request=172m usage=250m util=145%
# round 3: replicas=12 request=288m usage=125m util=43%
# round 4: replicas=9 request=144m usage=167m util=116%
# round 5: replicas=12 request=192m usage=125m util=65%
# HPA alone, request fixed at 250m: [4, 10, 10, 10, 10, 10]
The same 1500m of demand never settles: replicas swing between 6 and 12, each autoscaler reacting to a number the other just changed. With the request held still, the HPA reaches 10 and stays.
The complexity
The costs are reaction time and disruption:
- HPA is the fastest, but each new Pod waits for a node when the cluster is full.
- Cluster Autoscaler is the slowest link: a node must be provisioned and join first.
- VPA disrupts Pods in
Recreatemode, since changing requests means eviction; in-place modes avoid that where the node has room.
Where it goes wrong
- HPA and VPA on the same CPU or memory metric. The toy shows the swing. Split the resources, or give the HPA a custom metric.
- A VPA recommendation bigger than any node. It can leave Pods
Pending, beyond what the Cluster Autoscaler can fix. Cap it in the VPA's resource policy. - Expecting the Cluster Autoscaler to react to CPU usage. It counts requests; a busy node with small requests looks empty.
- Nodes that never scale down. A Pod with no controller, local storage or a too-strict PodDisruptionBudget pins its node.
When it shows up in interviews
In system design, after "traffic triples at noon": name the HPA, explain why new Pods go Pending, bring in the Cluster Autoscaler. The follow-up "would you add VPA?" tests the CPU conflict and Off mode as a sizing tool.
How to say it in an interview
"Three dimensions: the HPA changes replicas, VPA changes per-Pod requests, and the Cluster Autoscaler changes nodes. They chain: the HPA adds Pods, the ones that do not fit go Pending, and the Cluster Autoscaler adds a node, counting requests, not usage. I would not let HPA and VPA both act on CPU, because the HPA's utilization is divided by the request VPA keeps moving; I'd run VPA in Off mode to learn sizes, or on memory only, and let the HPA own CPU."
The HPA formula and the manifest are in requests, limits and HPA; the same pattern on virtual machines is in EC2 Auto Scaling explained.
Frequently asked questions
What is the difference between HPA and VPA?
The HPA changes how many Pods run, from a metric against a target, such as CPU as a percentage of the request. VPA changes how much CPU and memory each Pod requests, from observed usage. The Cluster Autoscaler, a third tool, changes the node count.
Can I use HPA and VPA together?
Yes, if they do not act on the same resource metric. The VPA documentation says not to combine them on CPU or memory, because the HPA's utilization is divided by the request VPA changes. Run VPA on memory and the HPA on CPU, give the HPA a custom metric, or use VPA in Off mode.
When does the Cluster Autoscaler add a node?
When Pods fail to schedule on any existing node because of insufficient resources, and a node like the existing ones would let them run. It counts requests, not actual usage, so a busy node with small requests does not trigger it. It removes a node whose requests stay low and whose Pods fit elsewhere.
Sources
- Horizontal Pod Autoscaling — Kubernetes Documentation
- Vertical Pod Autoscaling — Kubernetes Documentation
- Node Autoscaling — Kubernetes Documentation
- Vertical Pod Autoscaler README — Kubernetes Autoscaler on GitHub
- VPA known limitations — Kubernetes Autoscaler on GitHub
- Cluster Autoscaler FAQ — Kubernetes Autoscaler on GitHub