Skip to main content

Your Health Checks Are Quietly Killing Scale-to-Zero — And How to Get Idle Actually to Mean Zero on Kubernetes

17 min readDora NodaDora Noda
Share

You configured scale-to-zero. You watched replicas hit zero. Then you watched them bounce back to one without a single real user ever showing up.

If you have parked idle preview environments or low-traffic tenant services at zero replicas, you have met this trap: something inside your own platform keeps generating traffic the scaler cannot tell apart from a human. Health checks and uptime monitors are that traffic — correct in isolation, catastrophic in aggregate.

On July 29, 2026 the CNCF published "Your Kubernetes health checks are accidentally waking your services. Here's the fix" with the KubeElasti maintainers (CNCF Sandbox, accepted January 2026). The point is simple: once you hide a proxy in front of a zero-replica workload, that proxy must decide whether each HTTP request is a real user worth waking a pod for, or a probe to answer without touching a workload. Get it wrong and your idle service bills like it's warm.

This post delivers the fix early, then proves it holds. If you came for a copy-pasteable pattern, take the next section and go ship it. If you came to understand why the default breaks and how the managed versions of the same idea compare, keep reading.

The answer before the why: genuine idle-to-zero needs two traffic lanes, not one. Route every real user request through an activator that can buffer and scale; answer every synthetic probe on a separate lane that never counts as traffic.

In concrete YAML, that looks like one of these two patterns. Pick the one that matches the gateway you already run.

Option A — KubeElasti (CNCF Sandbox, ElastiService CRD): the resolver proxies idle traffic and answers probes inline via ProbeResponse.

yaml
# 1. KubeElasti ElastiService + ProbeResponse for health paths
apiVersion: kubeelasti.truefoundry.com/v1alpha1
kind: ElastiService
metadata:
  name: tenant-api
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tenant-api
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tenant-api
  probeResponse:            # <— the critical lane split
    - path: /healthz
      response: "ok"
      statusCode: 200
      headers:
        Content-Type: text/plain
    - path: /readyz
      response: "ok"
      statusCode: 200
  idleAfter: 5m              # scale to 0 after 5m of *real* traffic idleness
  minReplicas: 0
yaml
# 2. Gateway / Ingress routes REAL traffic through KubeElasti;
#    probes hit the same host but are answered without waking.
#    Example with Gateway API — same idea for Ingress/Envoy/Traefik:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: tenant-api
spec:
  parentRefs: [{ name: external-gw }]
  rules:
    - matches: [{ path: { value: /healthz } }]
      backendRefs: [{ name: kubeelasti-resolver, port: 8080 }]
    - matches: [{ path: { value: / } }]
      backendRefs: [{ name: kubeelasti-resolver, port: 8080 }]
# KubeElasti's resolver is the only backend the gateway knows;
# it queues the first real request, scales the target to 1,
# waits for readiness, then forwards — monitors hit /healthz
# and get 200 while replicas stay 0.

Option B — KEDA HTTP Add-On (interceptor proxy): the gateway points at the interceptor; the interceptor holds the cold-start queue.

yaml
apiVersion: http.keda.sh/v1alpha1
kind: HTTPScaledObject
metadata:
  name: tenant-api
spec:
  hosts: ["tenant.example.com"]
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tenant-api
  scaledownPeriod: 300      # 5m after last *routed* request
  minReplicas: 0
  maxReplicas: 20

Gateway MUST route through the interceptor, or scale-from-zero breaks:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: tenant-api-via-keda
spec:
  parentRefs: [{ name: external-gw }]
  rules:
    - backendRefs:
        - name: keda-add-ons-http-interceptor-proxy
          namespace: keda
          port: 8080

The common move in both is what the July 29 CNCF post calls ProbeResponse: the synthetic lane (/healthz, /readyz, /livez) is answered by the proxy itself while replicas are zero, so an uptime monitor stays green without ever waking a workload. Real traffic (/, /api/*) parks in a queue, triggers a scale to one, and is then forwarded. Miss that split and you have wired monitoring overhead directly to your compute bill.

If that is all you needed, stop here and wire the split. The rest of this post is the evidence that the split actually matters and what it costs to get wrong.

Why Kubernetes does not scale to zero by itself — and why any HTTP looks like a wake signal

The built-in HorizontalPodAutoscaler is honest about its limit: minReplicas cannot be zero. It scales one to many, not zero to one. That is not a bug — HPA watches CPU, memory, or custom metrics from pods that exist, so with zero pods there is nothing to watch. Zero needs an external decision-maker that watches traffic instead of pods.

KEDA, Knative Serving, and KubeElasti fill that gap — traffic-watching scalers with a proxy in front. KEDA parks cold requests in an interceptor, Knative holds them in its Activator queue, KubeElasti does the same with an ElastiService CRD.

The catch is that "traffic" is a dumb counter. To an interceptor, an HTTP GET for / from a paying user and an HTTP GET for /healthz from your load balancer look identical — both are a packet on the wire that matched the host rule. The CNCF post's diagnosis is that this indistinguishability is the entire failure mode. Every component that is supposed to talk HTTP becomes a false wake source once the workload can go to zero:

  • Cloud load balancer health checks (every 5–30s, per target)
  • Kubernetes livenessProbe / readinessProbe / startupProbe — though this one has a subtlety worth calling out explicitly
  • Service mesh or API gateway active health checks
  • Synthetic uptime monitors (UptimeRobot, Blackbox Exporter, Datadog Synthetic, Pingdom)
  • Internal cron or keepalive pings that were added years after scale-to-zero was designed

At zero replicas there is, strictly, no kubelet probe running — the Deployment has no pods, so there is no container to probe. The wake does not come from the kubelet. It comes from the layer above the pods: the gateway, mesh, or monitor that still believes the service should be reachable and polls the proxy address that the scaler owns. That distinction matters because it tells you where to fix it: not in deployment.spec.template.spec.containers[].livenessProbe, but in the traffic split at the proxy.

Left un-split, the arithmetic is brutal. A single monitor polling every 30 seconds makes 86,400 requests per month. At a 5-minute idleAfter, the service can never stay idle longer than 30 seconds — you pay for a forever-warm deployment and get a scale-to-zero architecture in name only.

The activator pattern: hold the first request, scale, then forward

All three projects converge on the same sequence, borrowed from Knative's Activator design that has scaled serverless workloads to zero since 2018:

  1. Last real request finishes.
  2. Idle timer starts (idleAfter / scaledownPeriod / stableWindow).
  3. Timer fires → controller scales target to 0, proxy stays up.
  4. New request arrives → proxy does not forward immediately. It enqueues the request, signals the scale subresource (/scalespec.replicas: 1), waits for the pod to pass readinessProbe and register endpoints.
  5. Proxy replays the buffered request to the now-ready pod. Subsequent warm requests bypass the queue entirely.

The queue in step 4 is where teams get burned. KEDA had to bound its cold-start hold queue after spikes against one cold app exhausted the shared interceptor's memory and file descriptors and took down routing for every tenant. KubeElasti and Knative make the same point: the proxy is hot only while idle, so overhead is zero when warm and bounded when cold.

That is also where cold-start latency lives. Ranges, not single numbers, settle the real question a reader is asking — "how long will my user wait when this wakes?" — and the answer is dominated by runtime and image size, not by which scaler you picked:

wake mechanismwhat happens at resumetypical user-visible latency (p50)
Suspend / checkpoint (Fly.io auto_stop_machines = "suspend", Firecracker snapshot restore)RAM is checkpointed, process resumes in place, no image pull, no JVM warm-up300–600 ms
Cold container, small cached image (Go, Node, Python slim — ~50–150 MB, already on node)scheduler + image cache hit + container start + startupProbe2–5 s
Cold container, large or uncached image (JVM/Spring, Python ML, ~500 MB–2 GB, cache miss)includes image pull + layer extraction + framework boot10–30 s
Managed free-tier spin-down (Render: 15-minute idle → full stop)container start on shared 0.1 CPU, including image pull on cold host30–60 s (documented for hobby/demo workloads)

A single number like "3 seconds" hides that spread — the KEDA community's 3.0-second Traefik→interceptor→pod measurement was a tiny cached image on k3d. Plan your queue timeout against the slowest image you run, not the fastest demo.

The two lanes you must split: probe traffic vs. real traffic

Once the proxy is in place, scale-to-zero is a routing problem, not a replica problem. You need to answer two questions for every path that traverses the gateway and prove both stay true at zero replicas.

Question 1 — Does this request count as traffic?

Only real user traffic should reset the idle timer and trigger a wake. Synthetic paths must not. That is not a flag on the Deployment — it is a rule on the proxy.

request sourceexample pathshould it wake from zero?where to fix it
Browser / mobile appGET /, POST /api/checkoutYesroute through activator/interceptor queue (default)
Gateway / LB health checkGET /healthz, GET /readyzNoProbeResponse / interceptor health-path bypass
Mesh / gateway active checkGET /healthz via Envoy/TraefikNosame — answer inline at proxy
Kubelet probehttpGet: /healthz on containerN/A at zero — no pod exists; no action
Uptime monitorGET https://tenant.example.com/healthzNo — must stay green at 0point monitor at health lane, not real lane
Internal cron / keepaliveGET / every minute (legacy)No — disable or retarget to /healthzremove keepalive or scope to real lane

Tuning livenessProbe/readinessProbe on the Deployment does not make probes safe — those probes do not exist at zero replicas. The fix lives one hop higher, at the proxy that classifies HTTP host+path.

KubeElasti's ProbeResponse makes that decision declarative: for as long as the target is scaled to zero and the proxy is in control, the resolver returns a configured 200 ok for /healthz without ever touching the workload. KEDA's HTTP add-on does not have a ProbeResponse field, so the equivalent there is an explicit gateway split — create a non-intercepted route for health paths that answers from a static backend or from the gateway itself, while the catch-all route goes through keda-add-ons-http-interceptor-proxy. Either way, the monitor sees 200 and the idle timer keeps ticking.

Question 2 — Where does my uptime monitor actually point?

This is the place most teams silently regress. A common pattern:

  • Gateway: tenant.example.com/ → interceptor → tenant Deployment
  • UptimeRobot check: GET https://tenant.example.com/ every 60s, expecting 200
  • Result: service never idles, monitor is green because the interceptor wakes the app for every check

The honest fix is to move the monitor, not to defeat scale-to-zero:

  • Point the monitor at https://tenant.example.com/healthz (the synthetic lane) when you have a ProbeResponse or health-path bypass. It stays 200 at zero replicas, correctly reports "the platform can still serve this tenant" without waking it.
  • Keep a deep check (/readyz or /api/health/deep) that is allowed to wake, but run it rarely (every 15–30 min) and treat a 2–5 s cold start as healthy at zero. Alert on "wake failed," not on "200 took 3 s."
  • For Prometheus-style scraping, split the lanes too: kube_pod or gateway-level metrics prove the activator is healthy, while app-level http_requests_total naturally reads zero while scaled to zero. Blackbox Exporter probes should target /healthz through the gateway, not through the pod IP.

A minimal gateway proof that the monitor stays honest:

yaml
# Gateway: two routes, two backends, one host
- match: { path: /healthz }  → backend: kubeelasti-resolver (or static 200 responder)
  # hit by: UptimeRobot, Blackbox, LB health check
  # behavior at 0 replicas: 200 immediately, replicas stay 0
 
- match: { path: / }  → backend: kubeelasti-resolver  (real traffic lane)
  # hit by: browsers, API clients
  # behavior at 0 replicas: queue → scale → forward (cold start latency applies)

Wire that split and you have proven scale-to-zero is not paying a monitoring tax. Skip it and your "savings" is a rounding error.

A note on idle-window sensitivity — the same workload, three bills

The savings from idle-to-zero scale with how idle the service actually is, and the idleAfter you tolerate is the variable that hides or reveals that. Take a service that sees bursty human traffic — say 10 minutes of requests, then 50 minutes of silence — on repeat:

  • idleAfter: 1m → service is warm ~12 minutes per hour, zero ~48 minutes. Maximal saving, but every burst pays a cold start. Best for preview environments and internal dashboards where a 2–3-second wake is invisible.
  • idleAfter: 5m → warm ~20 minutes per hour, zero ~40 minutes. Still strong saving, smoother for spiky but clustered traffic.
  • idleAfter: 15m → warm ~35 minutes per hour, zero ~25 minutes. If a health check also polls every 30s with no lane split, warm is effectively 60 minutes per hour and zero never happens. The lane split is the difference between "15 minutes saves a little" and "15 minutes saves nothing."

Measure idleAfter against your monitor period. If the monitor rides the real lane and polls faster than idleAfter, zero is unreachable by construction.

Renting the pause button vs. owning it

Managed PaaS offer the same UX — "your app sleeps when idle" — but you rent their activator and policy. Owning the activator on a Cluster API fleet means you control the queue and the lane split.

dimensionRender (managed)Fly.io Machines (managed)Self-hosted activator on Hetzner (owned)
Idle trigger15 min of no traffic, fixedauto_stop_machines after inactivity (configurable), min_machines_running = 0idleAfter / scaledownPeriod you set per workload (1m–30m)
Resume mechanismfull container stop → cold start on shared schedulersuspend (RAM checkpoint → ~300–600 ms resume) vs stop (full reboot → 5–15 s)KEDA/KubeElasti/Knative: queue + scale + forward (latency per table above)
Resume latency range30–60 s (free tier, cold host)suspend 0.3–0.6 s; stop 5–15 ssmall image 2–5 s; large/jvm 10–30 s (image-pull-bound)
What defeats zero?platform's own routing + your monitor (you cannot split lanes)machine proxy treats any routed request as wake; health checks go through proxyyou split lanes: probes answer inline, only real traffic queues (you own the gateway)
Can you fix a false wake?no — policy is the product; file an issue and waitlimited — suspend helps but does not split probes from trafficyes — add a ProbeResponse or gateway bypass, redeploy
Cost when idle$0 for free tier (750h/workspace) but single shared scheduler; $7/mo Starter to stay warmper-second billing, essentially $0 at zero (pay only resume + RAM-seconds)the node is already paid for — idle-to-zero increases tenant density on the same Hetzner flat-rate box, not a separate meter
Lock-in if you dislike the policymigrate off Rendermigrate off Fly proxyswap KEDA ↔ KubeElasti ↔ Knative; same cluster, no vendor

There is no universal winner. A single demo tolerates Render's 30–60 s cold start for free. Dozens of PR previews want Fly's 300 ms suspend — but one spiking cold app can still exhaust a shared hold queue, which is why KEDA recently bounded it. On owned Hetzner hardware the win is not per-machine dollars — the CX22 is sunk — but tenant density: how many idle tenants fit on the same pool before you need the next box. Zero is the capacity plan.

Putting it together on a Cluster API fleet

On a Cluster API-managed Hetzner fleet, the checklist before you promise tenants "scale-to-zero" is short but non-optional.

1. Pick one activator and keep it. Knative Serving brings an Activator, Istio/Kourier plumbing, and revision traffic splitting — powerful but heavy if you only wanted zero. KEDA HTTP add-on is lighter (one interceptor Deployment, one HTTPScaledObject per workload) but requires every HTTPRoute to explicitly route through keda-add-ons-http-interceptor-proxy or scale-from-zero silently breaks. KubeElasti is lightest (an ElastiService per workload, no per-host interceptor) and is the only option with first-class ProbeResponse for the health-lane split. Do not run two — pick one pattern fleet-wide.

2. Prove the gateway routes through the activator. The most common failure report against KEDA HTTP is not a bug in KEDA — it is a HTTPRoute that bypasses the interceptor entirely, so requests never park in the cold-start queue and zero can never be left. Grep every route in the workload cluster for the interceptor service name (or the KubeElasti resolver name). One bypassed host is one workload that can never wake.

3. Wire the lane split and prove it at zero replicas. Scale a canary to zero, then in parallel:

  • curl https://tenant.example.com/healthz — expect 200 in <50 ms, replicas stay 0 (watch kubectl get deploy tenant-api -w).
  • curl https://tenant.example.com/ — expect cold-start latency (2–5 s for small image), replicas go 0→1, request succeeds.
  • curl https://tenant.example.com/healthz while cold-starting — expect 200 immediately, not queued behind the waking pod.

If any of those three fails, the split is wrong.

4. Harden the queue. Set timeout and concurrency limits on the cold-start hold queue (KEDA's interceptor bound the cold-start hold queue change, Knative's activator throttling). A single tenant's spike at zero should not exhaust the proxy that every other tenant shares. Load-test one cold service with 100 concurrent requests and watch the interceptor's memory — unbounded queues OOM a 512 MB proxy fast.

5. Budget tail latency, not median. Advertise cold-start SLOs by image class (small 2–5 s, large 10–30 s), not by scaler. The scaler you pick does not make a 2 GB JVM image boot in 500 ms. If a tenant needs sub-second wake, the honest answer is suspend semantics (Firecracker snapshots, KEDA with checkpoint restore, or minReplicas: 1 for that workload) — not "we'll tune the interceptor."

6. When not to scale to zero. Preview environments, nightly dashboards, webhook receivers with loose latency SLOs — scale them to zero aggressively. User-facing APIs with sub-second p95 promises, stateful services with PVCs, and anything behind aggressive uptime checks — keep them warm and let idle-to-zero live on the next tier of tenants instead. Zero is a density tool, not a correctness one.


Idle-to-zero is not a toggle — it is a traffic classifier, a request buffer, and a cold-start budget. The CNCF's July 2026 post is right that health checks are the common reason the saving fails, but the lesson is architectural: a platform that bills by traffic cannot tell your intentions from your packets unless you give it two lanes and force every sender to pick one. Do that and a Hetzner fleet parks real tenants at zero, answers probes in microseconds, and stays densely packed without renting a per-request meter.

Own the activator, not the workaround. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with the gateway and lane split in your Cluster API fleet rather than behind a vendor you cannot reconfigure. Star the repo on GitHub or deploy your first idle-to-zero service today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex