Kubernetes · Core

Kubernetes Autoscaling

The HPA algorithm in one line and the missing resource request that silently disables it, VPA as a measuring tool before it is an actuator, why the two fight on CPU, and the single pod that pins a node against every scale-down.

24 min read Level: core → advanced Kubernetes 04 / 05
The model

THREE AUTOSCALERS, THREE AXES

Count, size and capacity. They interact, and two of them actively conflict.

SIGNALmetrics-server (CPU/mem)Prometheus adapter (custom)KEDA (external: queue depth)POD COUNTHPA — scales replicasPOD SIZEVPA — scales requests/limitsNODE COUNTCluster Autoscaler / KarpenterCONSTRAINTPodDisruptionBudgetResourceQuotanode group min/maxRESULTpods fit, or stay Pending

The constraint row is what makes autoscaling fail quietly: a PodDisruptionBudget or a node-group maximum stops the whole chain without anything reporting an error.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat is inside the HPA's control loop?
HPA CONTROLLER, EVERY 15Smetrics-serverDeployment /scalereplica countwrittenScalingActiveconditionREADmetrics.k8s.iofrom metrics-servercurrent replicasscale subresourceresource requestsMISSING → no CPU target at allDECIDEratio = current / targetper metrictolerance 10%inside it, do nothingtake the MAX across metricsDAMPscale-up: no delay by defaultscale-down stabilisation 5muses the window's maxmin / maxReplicas clamp
The CPU target is a percentage OF THE REQUEST. With no resources.requests on the container there is no denominator, the HPA reports ScalingActive=False, and it silently never scales.
ConnectionWhat has to happen for load to turn into a new node?
load riseskubelet cAdvisor10smetrics-server15s scrapeHPA loop15s, +10% toleranceDeployment /scalereplicas++new Pod → Pendingno roomcluster-autoscaler10s scannode joins, pod binds30s–5m
Add the hops up before blaming the HPA: roughly a minute of pure measurement latency before the first new pod, and minutes more if a node has to be provisioned. Autoscaling is not a latency control.
Core

THE THREE AUTOSCALERS

What each one moves, and the one thing that stops each from working.

The Horizontal Pod Autoscaler adjusts replica count. The core calculation is one line:

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

At 4 replicas averaging 80% CPU against a 50% target: ceil(4 × 1.6) = 7. There is no mystery in the arithmetic — the surprises are all in the inputs and the damping.

The three things that stop it working

  • No resource requests. CPU-percentage targets are a percentage of the request. A container with no requests.cpu has no denominator, the HPA reports <unknown>, and it never scales. This is the single most common cause.
  • No metrics-server. Same symptom, different reason. kubectl top failing and the HPA showing <unknown> together point here.
  • Scale-down stabilization. The default 300-second window means scale-down looks "broken" for five minutes after load drops. It is deliberate — it stops flapping.

CPU is often the wrong signal

For a queue consumer, the honest metric is queue depth, not CPU: a worker blocked on I/O sits at 5% CPU with a backlog of 50,000. That is what custom metrics (via the Prometheus adapter) and external metrics (via KEDA) exist for, and it is usually the difference between autoscaling that works and autoscaling that is decorative.

an HPA that reports unknown
$ kubectl get hpa api
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
api Deployment/api <unknown>/50% 2 10 2
two candidates. Check metrics-server first:
$ kubectl top pods -l app=api
error: Metrics API not available <- metrics-server
if top works, it is the missing request:
$ kubectl get deploy api -o jsonpath='{.spec.template.spec.containers[0].resources}'
{} <- no requests, no denominator
$ kubectl set resources deploy/api --requests=cpu=200m,memory=256Mi
and the behaviour block that stops flapping while still reacting fast:
behavior:
scaleDown: { stabilizationWindowSeconds: 300 }
scaleUp: { stabilizationWindowSeconds: 0,
policies: [{type: Percent, value: 100, periodSeconds: 30}] }

The Vertical Pod Autoscaler adjusts requests and limits rather than replica count. It has three parts, and they are separable — which matters more than the docs make obvious:

  • Recommender — watches usage and computes what the requests should be.
  • Updater — evicts pods whose requests are wrong.
  • Admission controller — rewrites requests on pods as they are created.

In updateMode: "Off" you get only the recommender: no evictions, just a recommendation you can read. That is the mode most clusters should start in — it turns "what should this request be?" from guesswork into a measured number, with no runtime risk.

VPA and HPA on the same metric will fight

Both react to CPU. VPA raises the request; raising the request lowers CPU-as-a-percentage-of-request; the HPA sees lower utilisation and scales in; fewer pods means more load each; VPA raises requests again. The documented rule is simple: do not run both on CPU or memory for the same workload. HPA on a custom metric with VPA on CPU is fine.

using VPA as a measuring tool, safely
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
spec:
targetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
updatePolicy: { updateMode: "Off" } # recommend only, never evict
$ kubectl describe vpa api | grep -A12 'Recommendation'
Container Recommendations:
Target: cpu: 240m memory: 310Mi
Lower Bound: cpu: 180m
Upper Bound: cpu: 520m
compare with what is actually requested, then set it deliberately:
$ kubectl get deploy api -o jsonpath='{.spec.template.spec.containers[0].resources.requests}'

The Cluster Autoscaler adds nodes when pods are Pending for want of capacity, and removes nodes that have been underused for a while. Scale-up is straightforward. Scale-down is where the money leaks, because a single pod can pin a whole node indefinitely.

What blocks a node from being removed

  • A pod with no controller — a bare Pod nothing would recreate.
  • A pod whose PodDisruptionBudget would be violated.
  • A pod with local storage (emptyDir or hostPath) unless annotated safe.
  • A pod in kube-system without a PDB.
  • Anything carrying cluster-autoscaler.kubernetes.io/safe-to-evict: "false".

The autoscaler logs its reason for every node it declined to remove, which turns "why are we paying for twelve nodes at 20% utilisation" into a five-minute answer rather than a theory.

Karpenter takes a different approach — instead of scaling fixed node groups it provisions the instance shape that fits the pending pods, and consolidates aggressively. On AWS it is usually the better answer now; the mental model shifts from "which group do I grow" to "what shape does this workload need".

why the cluster will not scale down
$ kubectl logs -n kube-system deploy/cluster-autoscaler | grep -i 'scale.down' | tail -20
node worker-07 cannot be removed: pod kube-system/metrics-server-x is
not replicated and has no PodDisruptionBudget
node worker-09 cannot be removed: pod prod/batch-1 has local storage
the summary the autoscaler maintains for itself:
$ kubectl get cm -n kube-system cluster-autoscaler-status -o yaml | head -40
allow a pod with scratch space to be moved:
$ kubectl annotate pod batch-1 \
cluster-autoscaler.kubernetes.io/safe-to-evict=true
min replicas 1 plus a strict PDB is a permanent node

A single-replica Deployment with minAvailable: 1 can never be evicted, so its node can never be drained — not by the autoscaler, and not by an upgrade either. Two replicas, or maxUnavailable: 1 instead, costs less than the node you are pinning.

In practice

ADVANCED TROUBLESHOOTING

Autoscaling failures are quiet: nothing errors, the thing just does not scale. Each of the three has one dominant cause, so check that first rather than reading configuration.

SymptomMost likely causeCheck
HPA target <unknown>No resource requests, or no metrics-serverkubectl top pods; then the container's resources.requests
HPA at max, still slowBottleneck is downstream, not replicasDatabase connections, a queue, a rate limit
Scales up, never down300s stabilization, or one busy replica in the averagekubectl describe hpa — the conditions explain each decision
Replicas flappingscaleUp and scaleDown both aggressiveSet a behavior block
Pods Pending, no new nodesNode group at max, or no shape fitsCluster Autoscaler logs; the group's max size
Nodes never removedA pod pinning each onecluster-autoscaler-status ConfigMap
VPA and HPA both actingBoth on CPU — they fight by designMove HPA to a custom metric, or VPA to Off

Read the HPA's own reasoning

kubectl describe hpa carries conditions — AbleToScale, ScalingActive, ScalingLimited — each with a message stating exactly what the controller decided and why. It is the autoscaling equivalent of the scheduler's Events line, and equally underused.

the HPA explaining itself
$ kubectl describe hpa api | grep -A10 Conditions
AbleToScale True ReadyForNewScale
ScalingActive True ValidMetricFound
ScalingLimited True TooManyReplicas
the desired replica count is more than the maximum replica count
-> it WANTS more and maxReplicas is the ceiling. Raise it, or accept the limit.
$ kubectl get hpa -A -o custom-columns=\
NS:.metadata.namespace,NAME:.metadata.name,MIN:.spec.minReplicas,MAX:.spec.maxReplicas,CUR:.status.currentReplicas
every HPA currently pinned at its ceiling — the ones to look at:
$ kubectl get hpa -A -o json | jq -r '.items[]
| select(.status.currentReplicas==.spec.maxReplicas)
| "\(.metadata.namespace)/\(.metadata.name)"'
Reference

CHEATSHEET

CommandWhat it answers
kubectl get hpa -ATargets, current versus desired, everywhere
kubectl describe hpa <x>The controller's own reasoning, in conditions
kubectl top pods / nodesWhether metrics-server works at all
kubectl set resources deploy/<x> --requests=cpu=200mGive the HPA a denominator
kubectl describe vpa <x>Recommended requests, measured not guessed
kubectl logs -n kube-system deploy/cluster-autoscalerWhy a node was not removed
kubectl get cm -n kube-system cluster-autoscaler-status -o yamlNode group state at a glance
kubectl get pdb -AWhat blocks eviction, scale-down and upgrades
kubectl get pods --field-selector status.phase=Pending -AWhat should be triggering scale-up
kubectl annotate pod <p> cluster-autoscaler.kubernetes.io/safe-to-evict=trueUnpin a node