DeepConcepts

Kubernetes / control plane / autoscaling

The HPA Does Nothing Until You Are 10% Past Target

The misconception

That a HorizontalPodAutoscaler with `averageUtilization: 80` keeps the workload near 80%, so a workload sitting above 80% and not scaling means the metrics pipeline is broken. The controller applies a dead band: `tolerances.isWithin(usageRatio)` returns the current replica count unchanged whenever the ratio of current to target is inside 1.0 +/- 0.1, so at a target of 80 nothing happens until the average crosses 88%, and nothing scales down until it falls under 72%. Worse, the second half of the same function re-adds every pod that has no metric yet at a usage of 0% whenever the answer would have been a scale-up, which routinely pushes the recomputed ratio back inside the dead band — so an HPA that has just added pods will refuse to add more until those pods start reporting, no matter how hot the running ones are.

15 min

A HorizontalPodAutoscaler set to averageUtilization: 80 does not hold your workload at 80%. It holds it anywhere between 72% and 88% and calls that done — and while it is waiting for new pods to start, it will refuse to add more no matter how hot the running ones get.

The HorizontalPodAutoscaler, HPA for short, is a controller that runs every 15 seconds and writes one number: the replica count on your Deployment's scale subresource. It has no model of your traffic and no memory of what it did last time. Each run it divides the average CPU utilization of the pods it has metrics for by your target, and multiplies. Everything people find surprising about it comes from the two tests it applies to that ratio before it acts.

The panel below runs ten minutes of a real HPA loop against a workload whose demand triples over five minutes. Four pods to start, a target of 80% of the CPU request, every default the Kubernetes control plane ships. The log prints the ratio at every sync and the reason for what it did.

One pod at 100% of its CPU request serves one unit of demand. Only pods that are Ready are Service endpoints, so only they take traffic — which is why a pod that is still starting keeps the average of the others high. minReplicas is 4 and maxReplicas is 40 throughout.

seconds above target
peak average CPU
pod-seconds paid for
syncs held by the dead band
syncs held by metric-less pods
Average CPU across Ready pods, as a percentage of request

at or under target · over target but inside the tolerance band, where the controller does nothing · outside the band, where it is allowed to act. The grid lines are 50% of request apart.

Replicas

The arithmetic is Kubernetes': the 15-second sync, the ratio, the tolerance test, ceil(newUsageRatio × len(metrics)), the 0%-and-100%-of-request substitutions, the Max(100%, 4 pods) scale-up policy and the max-over-window scale-down stabilization. Demand in units of one pod's request is a model, not a benchmark; treat the seconds as relative, not as your service's numbers.

Leave everything alone and read the log from 1:45 onwards. At 1:45 the average is 78% and the ratio is 0.975 — inside the band, no change. At 2:00 it is 84%, ratio 1.050 — still inside the band, still no change. The workload is running a twentieth over the number you configured and the controller has decided that is close enough. Only at 2:15, at 90% and a ratio of 1.125, does it add a pod.

That is the first test, and it is why 294 of the 600 seconds are spent above the target you set. Drag tolerance to 0.30 — the far end, but a value people really do set to stop flapping — and the same ten minutes gives 490 seconds above target, a peak of 126%, and eight replicas instead of nine, from two scaling actions instead of five. Drag it to 0 and you get 248 seconds and a peak of 102%. The tolerance is not a smoothing filter. It is the width of the region in which the HPA is not an autoscaler.

The ratio, and the test that throws it away

The documented formula is one line:

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

currentMetricValue for a resource metric is not CPU seconds and not a percentage of the node. It is the average, across the pods the controller has metrics for, of each pod's CPU usage as a fraction of that pod's CPU request. A container with no CPU request has no denominator, and the HPA reports FailedGetResourceMetric rather than guessing — see requests, limits and what actually enforces them for why the request is the number that matters here and the limit is not.

Then comes the test. In replica_calculator.go:

if tolerances.isWithin(usageRatio) {
    // return the current replicas if the change would be too small
    return currentReplicas, utilization, rawUtilization, timestamp, nil
}

isWithin is |1 - ratio| ≤ tolerance, and the tolerance comes from --horizontal-pod-autoscaler-tolerance on the kube-controller-manager, default 0.1. With a target of 80% that makes the band 72% to 88%. Nothing inside it produces an action, an event, or a log line. The HPA object still reports 90%/80% in kubectl get hpa while doing nothing, which is the exact display that gets filed as a bug: issue 78761, HPA doesn't scale down to minReplicas even though metric is under target, collected 113 comments and 89 reactions over three years and was closed as not planned.

The band is a fixed fraction, so its cost grows with the deployment. KEP-4951, the enhancement that made the tolerance configurable per HPA, motivates itself in one sentence: for large deployments, a 10% tolerance translates into very significant resources (i.e. hundreds of pods). On a 1,000-replica deployment the band is 100 replicas wide. Since Kubernetes 1.35 the HPAConfigurableTolerance feature gate is beta and on by default, so you can write the narrower band directly on the object:

behavior:
  scaleUp:
    tolerance: 0.02
  scaleDown:
    tolerance: 0.05

Asymmetry is the point. A tight scale-up tolerance makes the controller react to a small overshoot; a loose scale-down tolerance stops it giving the capacity back the moment the ratio dips. Setting both to zero is available in the panel and is not a good idea — you get an action on every 15-second sync, and every action starts pods that will not report for the next minute.

The pods it refuses to count

Switch the traffic to spike and read the log at 1:30. Twelve replicas exist, four of them are reporting a metric, and those four are at 240% of request. The controller does nothing — and it does nothing again at 1:45, when eight are reporting at 120%. The reason it gives both times is arithmetic, not policy.

Before computing anything, groupPods splits the pod list. Pods that are deleting or Failed are ignored. Pods that are Pending, or unready, or still inside --horizontal-pod-autoscaler-cpu-initialization-period (five minutes, during which a CPU metric is not trusted even if metrics-server has one), or unready and inside --horizontal-pod-autoscaler-initial-readiness-delay (30 seconds) are set aside. The ratio is computed over what is left. Then, if that ratio came out above 1.0 — if the answer was going to be a scale-up — the set-aside pods are put back at a usage of zero, and the ratio is computed again. The documentation calls this conservatively assuming the not-yet-ready pods are consuming 0% of the desired metric, further dampening the magnitude of a scale up.

It does not dampen the magnitude. Look at what the log prints: ratio 1.000 is inside the band — no change. It would still have asked for 12. That is not a coincidence of this simulation, it is algebra. The final line of the calculator is ceil(newUsageRatio × len(metrics)), and newUsageRatio is total usage divided by total requests divided by the target. When every pod in a Deployment has the same request — which is what a Deployment means — adding a pod at zero usage adds nothing to the numerator and exactly cancels in the denominator. The number the controller would ask for is unchanged. What the substitution changes is whether the second tolerance test lets it ask at all.

You can check this. Untick count metric-less pods and re-read the ramp. The same nine replicas, the same five scaling actions, 293 seconds above target instead of 294, and 4,219 pod-seconds instead of 4,159 — one pod, one minute earlier, across the whole ten minutes. The counters swap round: nineteen dead-band holds and ten metric-less holds become twenty-three dead-band holds and none. The substitution is a gate, and it is a gate that mostly agrees with the gate already standing in front of it.

The other direction is the one that costs money, and it is not symmetric. When the ratio comes out below 1.0, the pods without a metric are not put back at the target. They are put back at 100% of their CPU request:

// on a scale-down, treat missing pods as using 100% (all) of the resource request
// or the utilization target for targets higher than 100%
fallbackUtilization := int64(max(100, targetUtilization))
for podName := range missingPods {
    metrics[podName] = metricsclient.PodMetric{Value: requests[podName] * fallbackUtilization / 100}
}

Read that carefully against the documentation, which says the controller assumes the missing pods are consuming 100% of the desired value in case of a scale down. The desired value sounds like your target, and it is not: max(100, targetUtilization) is 100 for every target at or under 100%, which is every target anyone writes, and the 100 is a percentage of the pod's request. The documented sentence is exact only for the other code path — calcPlainMetricReplicas, which serves averageValue and custom metrics, really does substitute targetUsage. For the averageUtilization target this lesson is about, a pod with no metric is assumed to be flat out, not merely at target — and the algebra bites harder than the symmetry suggests. Working it through: ceil(newUsageRatio × len(metrics)) expands to ceil(ratio × reporting + missing ÷ target). The pods without a metric enter that sum as missing ÷ target, not as missing — the single ceil at the end is applied to the whole total, so the inflation is 1 ÷ target per silent pod before rounding: 1.25 at a target of 80%, two at a target of 50%. A rolling update with 5 pods mid-restart therefore asks for six or seven replicas above what the load justifies, which is the mechanism behind issue 72775, Pod HPA creates extra pods during deployment rolling update with no load — reported as a bug, open for three years, and working as designed.

Watch it happen. Set traffic to spike, drag scaleDown stabilizationWindowSeconds to 0 so the window is not what is holding the count up, and drag seconds until a new pod reports to 240. Read the log at 4:00, the sync after the spike ends. Four reporting pods sit at 60% of request, a ratio of 0.750, and every part of that says scale down. The eight pods with no metric yet are counted at 100% of request, the ratio comes back as 1.083 — over 1.0, on the other side of the dead band — and the controller holds twelve replicas for a workload that needs three. It repeats that decision five syncs running.

Note what would have happened if the fallback really were the target. The recomputed ratio would be 0.917: still inside the band, still no change, but pointing down rather than up. The difference between assuming a silent pod is at target and assuming it is saturated is the difference between a controller that is nearly ready to shrink and one that thinks it is nearly ready to grow.

The control that actually moves the number

Put the traffic back on ramp and leave the tolerance alone. Drag seconds until a new pod reports from 45 to 240.

  • 5 seconds: 215 seconds above target, peak 93%.
  • 45 seconds: 294 seconds above target, peak 108%.
  • 120 seconds: 369 seconds above target, peak 138%.
  • 240 seconds: 489 seconds above target, peak 180%.

The replica trajectory is identical in all four — four pods rising to nine, five scaling actions, the same 4,159 pod-seconds of capacity. Only the pain changes, and it more than doubles. No autoscaler setting is in that list. The tolerance moved the peak from 108% to 102% at its most aggressive; pod startup time moved it from 93% to 180%.

The reason is structural. The average is taken over pods that are Ready, and a pod that is not Ready is not in the Service's endpoints either — so it takes no traffic while it is starting, which keeps the running pods at exactly the utilization that caused the scale-up in the first place. The HPA then spends the next several syncs looking at a number that cannot improve yet. Every second of container image pull, JVM warm-up, connection-pool fill and readiness-probe initialDelaySeconds is a second the autoscaler spends blind.

This is also the boundary where the whole mechanism gives up. If your pods need two minutes to become useful and your traffic doubles in thirty seconds, no value of tolerance, behavior or target will save the request that arrives at second forty. The HPA is a reactive controller with a dead time equal to your startup time. The fixes that work are the ones that shorten it or pre-empt it: a smaller image, a readiness probe that is honest rather than padded, or capacity you provisioned before you needed it.

The five minutes after the spike

Switch traffic to spike. Demand goes to four times normal at 1:00 and back to normal at 4:00. Watch what the replica chart does after 4:00: nothing, for five minutes.

The default behavior.scaleDown.stabilizationWindowSeconds is 300, and the rule is that the applied replica count is the maximum recommendation seen inside that window. The controller stores a recommendation on every sync, including the syncs where the tolerance stopped it doing anything, so the window does not start counting when the spike ends — it starts counting from the last sync that still recommended the high number. In the panel that is the 3:45 sync — the last one before the traffic fell — and the drop lands at 9:00, five minutes and one sync later.

Priced in the panel's own units: 6,184 pod-seconds with the default window, 3,784 with the window set to zero. The difference is exactly 2,400 pod-seconds — eight surplus pods held for the full five minutes. Whether that is worth paying depends entirely on whether the spike comes back, which is the trade the window exists to make. What it is not is a bug, and it is not the HPA is stuck. Set the window to 150 seconds and the same run costs 4,984 pod-seconds; set it to 600 and the ten minutes ends with all twelve replicas still running.

Note also which pods leave. The controller only writes a number; the ReplicaSet decides who dies, and its ordering prefers unready and newest-first pods — see Deployments and ReplicaSets for why that matters during a rollout. And the pods the HPA adds are only useful once the scheduler can place them; if it cannot, they sit Pending and the autoscaler's job passes to the cluster autoscaler.

Checking it in a running system

Read the conditions, not the events. kubectl describe hpa <name> prints three conditions and they answer three different questions. AbleToScale false means the controller cannot reach the scale subresource at all. ScalingActive false with reason FailedGetResourceMetric means the metric is missing — usually a container in the pod with no CPU request, which is enough to disqualify the whole pod. ScalingLimited true means the number it wanted was clipped, and the message names the clipper: TooManyReplicas, TooFewReplicas, or ScaleDownStabilized. That last one is the five-minute window, stated in plain words, and it is the single most useful string on the page.

Compute the band before you tune anything. Take the target from kubectl get hpa and multiply by 0.9 and 1.1. If the current utilization is inside that interval, the HPA is behaving exactly as written and no amount of restarting metrics-server will change it. If you want it narrower, the field is spec.behavior.scaleUp.tolerance on Kubernetes 1.35 and later, and the cluster-wide fallback is --horizontal-pod-autoscaler-tolerance.

Measure the dead time directly. The number that dominated every result above is the gap between a pod being created and a pod reporting a usable metric. Take a pod's status.startTime, the lastTransitionTime of its Ready condition, and the timestamp of its first kubectl top pod reading. In practice:

kubectl get pod -o jsonpath='{.status.startTime} {range .status.conditions[?(@.type=="Ready")]}{.lastTransitionTime}{end}'

If that gap is over a minute, that is your autoscaler's reaction time and nothing in the HPA spec can shorten it.

Watch it decide, once. kubectl get hpa <name> -w alongside kubectl get events --field-selector involvedObject.name=<name> -w for one real traffic event is worth more than any dashboard. You are looking for the interval between a SuccessfulRescale event and the next one. If it is a multiple of 15 seconds with nothing in between while utilization is visibly high, you are watching the two gates in this lesson, in order.

An HPA targets averageUtilization: 50. It reports 54%/50% and has held 20 replicas for an hour while the team insists the service is CPU-bound and needs more pods. What is happening?

Two things follow from here. The pods an HPA creates have to land somewhere, and when they do not, the cluster autoscaler is the component that decides whether to buy a node — with a completely different notion of what "not enough capacity" means. And when the cluster is full and something has to give, the ordering is set by pod priority and preemption, not by which workload scaled up first. If a container in your pod has no CPU request at all, start instead with QoS classes — the HPA and the kubelet are both reading the same missing field.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.