Kubernetes 1.37 landed on 26 August, and one line in the release notes will change a few Helm charts: HorizontalPodAutoscaler scale-to-zero graduated to beta and is enabled by default. Set spec.minReplicas: 0 and an idle workload can hold at zero pods.
The feature was first introduced in v1.16. It reached a default seven years later, and the reason for the delay is the same reason it still won't work on CPU. That constraint is not a limitation somebody forgot to lift. It is the whole shape of the problem, and it decides which of your workloads this is actually for.▸The 60-second version — flip through the deck8 slides · swipe →
Why CPU can't get there
An HPA that scales on CPU reads metrics.k8s.io, which is fed by samples taken from running pods. That API graduated to stable in this same release, after nearly nine years in beta.
Follow the arithmetic to its end. The last pod terminates. The sample stream stops. The HPA now has no observation to compare against a target, so there is no signal that could ever tell it to come back. The workload sits at zero forever, and it isn't the autoscaler's fault: you asked it to steer using a gauge that only exists while the engine is running.
So the supported set follows directly from where the number lives:
| Metric type | Can reach zero | Where the value comes from |
|---|---|---|
Resource (cpu, memory) | No | sampled from live pods |
Object | Yes | read off a Kubernetes object |
External | Yes | read off a queue, a broker, a bill |
Object and external metrics survive an empty deployment because they were never measuring the deployment. They were measuring the work waiting for it.
Which workloads this is actually for
The release notes name three: queue consumers, batch jobs, GPU workloads. That list is tighter than it looks, and it is worth saying why rather than just repeating it.
Each of those has a backlog you can count from outside. SQS message count. Kafka consumer lag. Pending rows in a jobs table. The number exists whether or not anything is running, which is exactly the property scale-to-zero requires.
A web frontend is the counter-example that makes the rule obvious. Requests do not queue up somewhere visible waiting for a pod to appear; they arrive at a service that has no endpoints and fail. Scale-to-zero there needs something in front holding the connection open, which is a different piece of infrastructure and a different conversation.
The GPU case deserves its own note, because that is where the money is. A GPU node that idles overnight bills like a GPU node that works overnight. If the trigger for that work is a queue rather than a request, this feature is aimed squarely at your invoice.
The status condition nobody will put in a headline
Here is my favourite part of the change, and it is not the scaling.
While the HPA is holding a workload at zero, it records a condition in its status:
status:
conditions:
- type: ScaledToZero
status: "True"
Once the workload scales back up, that flips to False with reason NotScaledToZero.
Before this, a Deployment sitting at zero replicas had exactly one appearance in the API regardless of how it got there. The autoscaler deliberately parking an idle consumer and a human typing kubectl scale --replicas=0 during an incident produced identical manifests.
That difference matters at 3 a.m. more than it matters at any other hour. "Will this come back on its own?" is the entire question, and until 1.37 the honest answer was to go read Slack history and hope somebody wrote it down.
A workload at zero used to be a state. Now it's a state plus a reason, and the reason is the part on-call actually needs.
Does this retire KEDA?
No, and I want to be careful here because the tempting version of this post is the one that says it does.
Our default stack puts KEDA at the event-driven layer for a reason: dozens of scalers out of the box, covering brokers and cloud services we would otherwise be writing glue for. None of that moves into core Kubernetes with this release.
More to the point, the metric still has to reach the HPA through the custom or external metrics API. Something has to publish it. Whether that is Prometheus Adapter, KEDA's own metrics API server, or something you maintain, the adapter does not disappear because minReplicas learned a new value.
There is one operational rule that survives untouched and is worth repeating because it bites people: when KEDA scales a target, it manages the underlying HPA itself. Do not hand-write a second HPA against the same workload. Two controllers steering one replica count is a fight, not a configuration.
And a cost note from the same drawer: VPA and HPA on the same CPU or memory metric oscillate. VPA shrinks requests, the utilisation ratio spikes, HPA adds pods, and the loop thrashes. VPA alongside an HPA on a custom or external metric is fine, which happens to be the exact combination scale-to-zero requires anyway.
Before you set it
A short pre-flight, in the order I would actually run it:
- Confirm the metric your HPA targets is
ObjectorExternal, notResource - Confirm something publishes that metric when zero pods are running, and test it with the workload scaled down by hand
- Measure the cold start, then check it against the SLO the workload actually carries
- Check whether KEDA is already doing this job, and what else it is doing before you remove it
- Alert on
ScaledToZerostayingTruelonger than the work pattern explains
The third item is the one that gets skipped. Reliability wins ties: a cold start you have not measured is a latency budget you have not spent yet. Queue consumers and batch jobs usually have room for it. Anything with a user waiting at the other end usually does not.
The line worth keeping
Scale-to-zero has been available to Kubernetes users for years through KEDA, and it is a good sign that the pattern finally earned a default in core. But the seven-year wait was never about the scaling.
Getting to zero is easy. Knowing when to come back is the problem, and you cannot answer it with a gauge that only exists while something is running.
If you run KEDA today, the interesting question is not whether 1.37 replaces it. It's what your install is doing beyond scale-to-zero on a queue. For some clusters that list is long. For a few, it is empty, and that's a piece of infrastructure you get to delete. Which one is yours?