You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Readiness should mean "this process can serve". When ate-api-server cannot reach its store it can
serve nothing — every RPC fails — so it should fail /readyz, leave the Service endpoints, and
surface a signal that something above it can act on: an alert, a metric, a Condition, anything.
Liveness should keep succeeding throughout. The process is alive and will recover on its own once
the store returns; restarting it would make things worse, not better (see #).
Actual Behavior
The two probes are indistinguishable, and both are green while the control plane serves nothing.
Store scaled to zero, sampled every 15 s for six minutes:
Six minutes in which the pod is 200 on /healthz, 200 on /readyz, Ready=True to both the kubelet
and its Service, has never restarted, is still receiving traffic — and fails every RPC. Nothing
in Kubernetes has any way to know, and nothing in the control plane says so either.
This is not a slow-detection problem. There is no detection: /readyz does not consult the store
under any circumstances, so the interval is infinite.
What it costs, concretely: on 27 Aug we spent about three hours on an outage of this shape, most of
it looking in the wrong place, because every signal at the top of the stack was green and the only
component visibly misbehaving was the api-server crash loop from # — which is a symptom of
the store being gone, not the cause. A store outage presents as an api-server bug.
Steps to Reproduce the Problem
Install on GKE with the PostgreSQL backend and confirm ate-api-server is serving: kubectl-ate get atespaces returns.
Take the store away: kubectl -n ate-system scale sts/postgres --replicas=0.
Poll all four signals every 15 s for six minutes:
kubectl -n ate-system get pod <ate-api-server pod> -o jsonpath='{.status.containerStatuses[0].ready} {.status.containerStatuses[0].restartCount}'
curl -s -o /dev/null -w '%{http_code}' localhost:9090/healthz # via port-forward
curl -s -o /dev/null -w '%{http_code}' localhost:9090/readyz
kubectl-ate get atespaces # the actual RPC
Every sample reads Ready=True restarts=0 healthz=200 readyz=200 while the RPC fails. It does
not change with time — we held it for six minutes; there is nothing to wait for.
Confirm the endpoint is still in service: kubectl -n ate-system get endpointslices -l kubernetes.io/service-name=ate-api-server. The pod
is still listed, so it is still being sent traffic.
Bring the store back: kubectl -n ate-system scale sts/postgres --replicas=1. RPCs succeed again
~15 s later with restarts=0 — the process recovers by itself, which is exactly why liveness
must not be involved in the fix.
Specifications
Version:4c1b37d (base of release-0.1-rc) plus three cherry-picks. Confirmed still
present on main at ea3bdc32 (2026-09-02) — internal/serverboot/ has one commit since the
pin (66300d5c, workersync: gate worker registration on ateom readiness #1243) and it does not touch the health surface. Store-agnostic: unchanged by the
valkey → Postgres migration (60073ecd, Replace ateredis with atepg #940).
Platform: GKE, us-central1-c, --store-backend=postgres, two ate-api-server replicas.
Reproduced 2026-08-27.
Evidence: the 24-sample polling log above. (One sample at T+195s read healthz=000 readyz=000
— that was the port-forward blipping, not the server; it recovered on the next sample with the
restart count unchanged.)
Root cause
internal/serverboot/serverboot.go on main at ea3bdc32.
1. /healthz is unconditional, by construction.metricsMux at :356:
The doc comment at :319 says so outright: "EnableHealthz adds an always-200 /healthz for liveness
probes, which must keep succeeding while a draining server fails /readyz." That rationale is
correct and this half is arguably working as designed — see the design constraint below.
2. /readyz consults a drain flag and nothing else.readinessHandler (:367) reads Readiness.Ready(), and Readiness (:299) is flipped only by MarkNotReady, called only from drainOnShutdown (cmd/ateapi/main.go:242). Nothing else in the process can ever influence it. A
store outage is invisible to it because no code path connects the two.
3. Readiness is a one-way latch, so the obvious fix does not fit the existing type. Its doc
comment (:297): "Calling MarkNotReady flips it permanently to not ready." That is right for
draining and wrong for a store outage, which must be able to recover. Gating readiness on store
reachability needs a reversible predicate — either a new type, or Readiness gaining a separate
recoverable condition alongside the terminal drain latch. This is the reason the fix is not a
one-liner, and worth knowing before someone starts.
4. Nothing else reports it either. There is no store-reachability metric, no log line above
per-RPC errors, and no Condition. The failure is only visible to a caller who tries an RPC and reads
the error.
The repo already argues this position, for a different binary
StartReadinessServer, twenty lines further down at :334, exits the process if its readiness
endpoint cannot bind, and explains why:
a worker whose readiness endpoint cannot come up never turns Ready and never registers, so dying
loudly lets the kubelet restart it instead of leaving a pod that looks alive but can never
receive work.
That is precisely the state ate-api-server sits in for the entire duration of a store outage. The
principle is already written down and already accepted; it just was not applied to the component
where the blast radius is the whole cluster.
Why this is a priority even though it breaks nothing by itself
It is a force multiplier rather than a fault. It does not cause outages — it makes every other
outage cost hours instead of minutes, and it does that without leaving a trace to grep for
afterwards. Two consequences that matter for anyone operating this without the maintainers on hand:
Every store-side failure presents as an api-server bug, because the api-server crash loop
(#) is the only visible symptom while the actual cause is silent.
Nothing can be alerted on. A pod that is Ready=True restarts=0 with no failing probe and no
metric produces no page, no dashboard change and no event. The first report comes from a user.
Unlike most items of this kind, it also cannot be resolved by documentation. There is no release
note that makes a green health check informative.
The design constraint the two fixes share
The naive fix — make /healthz reflect store reachability — makes # strictly worse: the
kubelet would then kill a pod that is correctly waiting for its store to come back, and a running
replica that today rides out a six-minute outage with restarts=0 would instead crash-loop through
it. The split has to be:
/healthz = the process is alive. Never store-gated. Starts before the store connect
(that is #'s fix).
/readyz = this process can serve. Store-gated, reversibly, in both boot and steady state.
Stated once here because the two issues will otherwise be fixed by two people in two files with
incompatible assumptions.
Candidate fixes
Gate /readyz on store reachability with a reversible predicate. A background health check
against the store (the pgx pool already exposes what is needed) flipping a condition that readinessHandler ANDs with the existing drain latch. Removes the pod from Service endpoints
while it cannot serve, and puts it back automatically. This is the core fix.
Export a store-reachability metric and log the transitions. Cheap, independent of 1, and the
only one of these that helps someone diagnosing a cluster after the fact rather than during.
Worth doing even if 1 slips.
Leave /healthz exactly as it is. Explicitly listed as a fix so that it is a decision on the
record rather than an omission — see the constraint above.
Apply the same treatment to the other binaries before the pattern spreads.atecontroller: serve health probes #844
(atecontroller: serve health probes, open and unreviewed since 11 Aug) adds HealthProbeBindAddress plus liveness on /healthz and readiness on /readyz with no AddHealthzCheck or AddReadyzCheck registered — controller-runtime then serves both as
unconditional 200, reproducing this issue in a second component. It is two lines to fix while the
PR is still open.
Related: [#1394 ] (the same health surface killing a booting process, and the fix that must land
compatibly with this one), #844 (about to copy the pattern), #636 (the valkey outage this made
unreadable, closed by deleting the backend rather than by a fix).
Expected Behavior
Readiness should mean "this process can serve". When
ate-api-servercannot reach its store it canserve nothing — every RPC fails — so it should fail
/readyz, leave the Service endpoints, andsurface a signal that something above it can act on: an alert, a metric, a Condition, anything.
Liveness should keep succeeding throughout. The process is alive and will recover on its own once
the store returns; restarting it would make things worse, not better (see #).
Actual Behavior
The two probes are indistinguishable, and both are green while the control plane serves nothing.
Store scaled to zero, sampled every 15 s for six minutes:
healthz=200 readyz=200 Ready=True restarts=0 rpc=OKhealthz=200 readyz=200 Ready=True restarts=0 rpc=FAILSix minutes in which the pod is 200 on
/healthz, 200 on/readyz,Ready=Trueto both the kubeletand its Service, has never restarted, is still receiving traffic — and fails every RPC. Nothing
in Kubernetes has any way to know, and nothing in the control plane says so either.
This is not a slow-detection problem. There is no detection:
/readyzdoes not consult the storeunder any circumstances, so the interval is infinite.
What it costs, concretely: on 27 Aug we spent about three hours on an outage of this shape, most of
it looking in the wrong place, because every signal at the top of the stack was green and the only
component visibly misbehaving was the api-server crash loop from # — which is a symptom of
the store being gone, not the cause. A store outage presents as an api-server bug.
Steps to Reproduce the Problem
ate-api-serveris serving:kubectl-ate get atespacesreturns.kubectl -n ate-system scale sts/postgres --replicas=0.Ready=True restarts=0 healthz=200 readyz=200while the RPC fails. It doesnot change with time — we held it for six minutes; there is nothing to wait for.
kubectl -n ate-system get endpointslices -l kubernetes.io/service-name=ate-api-server. The podis still listed, so it is still being sent traffic.
kubectl -n ate-system scale sts/postgres --replicas=1. RPCs succeed again~15 s later with
restarts=0— the process recovers by itself, which is exactly why livenessmust not be involved in the fix.
Specifications
4c1b37d(base ofrelease-0.1-rc) plus three cherry-picks. Confirmed stillpresent on
mainatea3bdc32(2026-09-02) —internal/serverboot/has one commit since thepin (
66300d5c, workersync: gate worker registration on ateom readiness #1243) and it does not touch the health surface. Store-agnostic: unchanged by thevalkey → Postgres migration (
60073ecd, Replace ateredis with atepg #940).us-central1-c,--store-backend=postgres, twoate-api-serverreplicas.Reproduced 2026-08-27.
healthz=000 readyz=000— that was the port-forward blipping, not the server; it recovered on the next sample with the
restart count unchanged.)
Root cause
internal/serverboot/serverboot.goonmainatea3bdc32.1.
/healthzis unconditional, by construction.metricsMuxat:356:The doc comment at
:319says so outright: "EnableHealthz adds an always-200 /healthz for livenessprobes, which must keep succeeding while a draining server fails /readyz." That rationale is
correct and this half is arguably working as designed — see the design constraint below.
2.
/readyzconsults a drain flag and nothing else.readinessHandler(:367) readsReadiness.Ready(), andReadiness(:299) is flipped only byMarkNotReady, called only fromdrainOnShutdown(cmd/ateapi/main.go:242). Nothing else in the process can ever influence it. Astore outage is invisible to it because no code path connects the two.
3.
Readinessis a one-way latch, so the obvious fix does not fit the existing type. Its doccomment (
:297): "Calling MarkNotReady flips it permanently to not ready." That is right fordraining and wrong for a store outage, which must be able to recover. Gating readiness on store
reachability needs a reversible predicate — either a new type, or
Readinessgaining a separaterecoverable condition alongside the terminal drain latch. This is the reason the fix is not a
one-liner, and worth knowing before someone starts.
4. Nothing else reports it either. There is no store-reachability metric, no log line above
per-RPC errors, and no Condition. The failure is only visible to a caller who tries an RPC and reads
the error.
The repo already argues this position, for a different binary
StartReadinessServer, twenty lines further down at:334, exits the process if its readinessendpoint cannot bind, and explains why:
That is precisely the state
ate-api-serversits in for the entire duration of a store outage. Theprinciple is already written down and already accepted; it just was not applied to the component
where the blast radius is the whole cluster.
Why this is a priority even though it breaks nothing by itself
It is a force multiplier rather than a fault. It does not cause outages — it makes every other
outage cost hours instead of minutes, and it does that without leaving a trace to grep for
afterwards. Two consequences that matter for anyone operating this without the maintainers on hand:
(#) is the only visible symptom while the actual cause is silent.
Ready=True restarts=0with no failing probe and nometric produces no page, no dashboard change and no event. The first report comes from a user.
Unlike most items of this kind, it also cannot be resolved by documentation. There is no release
note that makes a green health check informative.
The design constraint the two fixes share
The naive fix — make
/healthzreflect store reachability — makes # strictly worse: thekubelet would then kill a pod that is correctly waiting for its store to come back, and a running
replica that today rides out a six-minute outage with
restarts=0would instead crash-loop throughit. The split has to be:
/healthz= the process is alive. Never store-gated. Starts before the store connect(that is #'s fix).
/readyz= this process can serve. Store-gated, reversibly, in both boot and steady state.Stated once here because the two issues will otherwise be fixed by two people in two files with
incompatible assumptions.
Candidate fixes
/readyzon store reachability with a reversible predicate. A background health checkagainst the store (the pgx pool already exposes what is needed) flipping a condition that
readinessHandlerANDs with the existing drain latch. Removes the pod from Service endpointswhile it cannot serve, and puts it back automatically. This is the core fix.
only one of these that helps someone diagnosing a cluster after the fact rather than during.
Worth doing even if 1 slips.
/healthzexactly as it is. Explicitly listed as a fix so that it is a decision on therecord rather than an omission — see the constraint above.
(atecontroller: serve health probes, open and unreviewed since 11 Aug) adds
HealthProbeBindAddressplus liveness on/healthzand readiness on/readyzwith noAddHealthzCheckorAddReadyzCheckregistered — controller-runtime then serves both asunconditional 200, reproducing this issue in a second component. It is two lines to fix while the
PR is still open.
Related: [#1394 ] (the same health surface killing a booting process, and the fix that must land
compatibly with this one), #844 (about to copy the pattern), #636 (the valkey outage this made
unreadable, closed by deleting the backend rather than by a fix).