fix: reach READY in headless kubernetes deployments - #459
Open
joaopaulosr95 wants to merge 1 commit into
Open
Conversation
Contributor
Author
|
@jreadey I tried upgrading our production HSDS server after yesterday's release and found this bug. |
joaopaulosr95
force-pushed
the
fix/k8s-headless-cluster-state
branch
from
September 3, 2026 18:18
e59e395 to
cd2b9ef
Compare
joaopaulosr95
force-pushed
the
fix/k8s-headless-cluster-state
branch
from
September 3, 2026 18:41
cd2b9ef to
610a786
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On a Kubernetes deployment with no head node, every node stays in
WAITINGfor thelifetime of the process and returns 503 to every request. The cause is a readiness gate
added in
ed6b5a01that is only ever satisfied by a head node's/registerresponse,so the k8s discovery path can never satisfy it. This is deterministic, not a race, and
independent of replica count.
This fixes it by deriving
cluster_statefrom the dn roster in the k8s path, and byre-checking that roster while a node is not yet READY.
Why this affects the shipped manifests
All three deployment manifests under
admin/kubernetes/run headless, each saying soexplicitly:
k8s_deployment_aws.ymlHEAD_PORT: null # no head containerk8s_deployment_azure.ymlHEAD_PORT: "0" # no head containerk8s_deployment_posix.ymlHEAD_PORT: "0" # no head containerNone defines a head container, only
NODE_TYPE: snandNODE_TYPE: dn. Headless istherefore not an unusual configuration but the only Kubernetes topology this project
documents, which is why the fix belongs in the k8s path rather than in guidance to
deploy a head node.
Root cause
ed6b5a01("avoid race condition in ready state logic") added a gate inupdateReadyState():cluster_stateis initialised to"WAITING"inbaseInit()and is only everreassigned inside
docker_update_dn_info(), from the head node's/registerresponse.But
update_dn_info()branches on whether a head url exists:With no head node the first branch is taken on every health check, so
cluster_statenever leaves
"WAITING"andis_readyis permanentlyFalse.Reproduced at a single replica with a fully converged roster, so no race is involved:
Why configuration cannot work around it
getHeadUrl()hardcodesdns_name = "127.0.0.1"whenKUBERNETES_SERVICE_HOSTisset, and there is no
head_endpoint/head_hostconfig key, so a head node has to be acontainer in the same pod. With N replicas that yields N independent heads, each seeing
only its own pod's sn/dn and each concluding the cluster is complete. Pods would then
disagree on
getObjPartition()for the sameobj_id, which is the hazarded6b5a01set out to prevent.
The fix
Two changes to
k8s_update_dn_info(), both using values the function already computes.1. Derive
cluster_statefrom the dn roster. The existingif/elif/elsechainalready separates "roster complete and self-consistent" from every partial state, so
the
elsebranch setsREADYand the partial branches setWAITING.This covers only the dn dimension of
isClusterReady(). That function alsocompares the sn count against
target_sn_count, which is deliberately not checkedhere:
getObjPartition()partitions by dn count, so dn completeness is what decideswhether nodes agree on partitioning, whereas an sn that is not up is simply not
serving. Happy to add an sn-count condition if you would rather the two paths match
exactly, though it would need a
target_sn_countthat headless deployments do notcurrently set (it defaults to
0).It also enforces the function's own documented condition 1, "node_count ==
len(dn_urls) for all dn's" —
min_node_count/max_node_countwere being computed andthen never read.
2. Re-check the roster while not READY.
scale_updatewas only set whendn_urlschanged, which made the roster check one-shot. During a rescale a dn can be observed
before it has assigned its own
node_number(reporting-1), or two dn's cantransiently report the same number. The check logged a warning and, with the pod set
then stable, never ran again, leaving the cluster wedged.
Observed on a 3-replica deployment; every rollout hit one of these transient states:
dn_node_numbers[-1][-1, 0, 1][0, 1, 1]Probes in the example manifests
Related, and the reason this outage went unnoticed for as long as it did: all three
manifests pointed their liveness probes at
/info, which is inINFO_METHODSand sobypasses the
node_stategate. It returns 200 even while the node is wedged inWAITINGand returning 503 to every real request, so pods reportedRunning, neverrestarted, and passed every probe throughout a total outage. There were also no
readiness probes, so wedged pods stayed in their Service's endpoints.
Adds a
hsds-node-stateconsole script that inspectsnode_stateand exits non-zerounless it is
READY. It takes its port fromNODE_TYPE, so the sn and dn containersshare one identical command rather than each hardcoding a port:
All three manifests now use it for both probes. Readiness keeps a wedged pod out of the
Service, so a bad rollout stalls with the old pods still serving instead of replacing
them with 503-ing ones. Liveness restarts a genuinely stuck node. Without it, readiness
alone would pull every wedged pod from the Service and nothing would recover them,
leaving zero endpoints indefinitely. The 5 x 60s liveness budget is deliberately far
longer than the ~10s
WAITINGdips a rescale causes, so only a real wedge trips it.It prints the state on stdout as well, which makes the same command useful for
diagnostics (
kubectl exec ... -- hsds-node-state).Trade-offs worth weighing
Two consequences that reviewers should see up front rather than discover.
Rescales now briefly return 503. Any partial view of the roster sets
WAITING, soa scale event 503s until the roster reconverges, roughly one health check interval.
Before
ed6b5a01a node holding a completedn_idsstayed READY through roster churn.The trade here is inconsistent partitioning versus short unavailability.
Measured on a 3-replica rollout: 2 requests received 503 across the entire rollout.
Fix 2 polls while not READY. It costs one
/inforequest per dn per health check,so n² across the cluster, though only while not READY. A cluster that never converges
therefore polls indefinitely where it previously checked once. Negligible at small node
counts; there is a comment marking where backoff would go if it matters at larger
scale.
Testing
Adds
tests/k8s/: a k3d integration test that deploys HSDS headless on hermetic POSIX storage,asserts every node reaches
READY, then scales to 3 replicas and re-asserts.Two properties this failure mode required designing around:
/infobypasses thenode_stategate, so probing it proves nothing about whethera node is serving (see the probe section above). The test asserts
node_stateexplicitly and sends its requests to a gated route, where the pass condition is
"not 503".
broken code. The test scales up and re-asserts.
Readiness is asserted per container. sn and dn run separate health checks and
converge independently, so an sn can report READY while its own dn is still WAITING,
and a request that shards to that dn gets a 503 from the dn's own gate. Asserting on sn
alone produced a false pass during development.
Verified in both directions: fails on the parent commit (
0/1 READY,sn=WAITING dn=WAITING)and passes with this change, holding at
2/3 READYwhile a dn convergedrather than falsely reporting
3/3.Also adds
.github/workflows/k8s-integration.ymlto run it on push and PR.Tested on k3s v1.35.5 (via k3d 5.9.0) and on a 3-replica EKS deployment at this commit,
on both
linux/amd64andlinux/arm64.