fix(worker): give the worker a real health endpoint and a mode-aware HEALTHCHECK#10999
Merged
Conversation
The image bakes in a single HEALTHCHECK that curls http://localhost:8080/readyz, but the same image also runs `local-ai worker`, which serves HTTP on the gRPC base port minus one and never binds 8080. Every worker container was therefore permanently `unhealthy` (43 consecutive failures observed on a production node), which is worse than having no healthcheck: a genuinely broken worker and a perfectly good one both report `unhealthy`, so the signal carries no information and orchestration that keys on it misbehaves. The worker already served /readyz on that port via the file-transfer server, but as a constant 200 — it only proved the listener was bound, which is precisely the failure mode at issue. Readiness now tracks the live NATS connection: all of a worker's actual work (backend lifecycle events, inference dispatch, file staging) arrives over NATS, so a worker whose link is dead is up and useless. Registration is already implied, since the server only starts after registration succeeds. This reports something the controller cannot already see. The node registry's status/last_heartbeat is fed by an HTTP heartbeat to the frontend, a different network path from NATS — a worker can keep heartbeating while its NATS connection is dead and still look healthy in the registry. /healthz stays a constant 200: liveness must not follow readiness, or a NATS blip becomes a cluster-wide restart storm. The HEALTHCHECK is now a script that derives its endpoint from the mode the container is actually running plus the env vars that configure the bind address, so a frontend moved off 8080 with LOCALAI_ADDRESS (broken the same way) and a worker on a non-default base port are both probed correctly. Modes with no HTTP surface (agent-worker, one-shot commands) report healthy rather than false-unhealthy. HEALTHCHECK_ENDPOINT remains as an explicit override, so the workaround shipped in docker-compose.distributed.yaml keeps working; both overrides in that file are now unnecessary and have been removed. Also fixes the latent --start-period gap. Since #10949 a frontend's startup preload materializes HuggingFace artifacts before the HTTP server binds (31 GB observed on a live cluster), so a healthy replica can legitimately fail probes for a long time. --start-period is Docker's knob for exactly this: failures inside it leave the container `starting` instead of burning retries, and it ends early on the first success, so a generous 60m costs a fast-starting container nothing. --timeout drops from 10m to 10s — it is a per-probe deadline, and a localhost curl that has not answered in 10s is itself the fault being detected. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
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.
Problem
The image bakes in one
HEALTHCHECKthat curlshttp://localhost:8080/readyz, but the same image also runslocal-ai worker, which serves HTTP on the gRPC base port minus one and never binds 8080. Every worker container is therefore permanentlyunhealthy— 43 consecutive failures observed on a production node.That is worse than having no healthcheck. A genuinely broken worker and a perfectly good one both report
unhealthy, so the signal carries no information,depends_on: service_healthyand restart-on-unhealthy misbehave, and operators learn to ignore(unhealthy)on every worker.Fixes #10987.
What was already there — and why fixing only the Dockerfile was not enough
The worker does already serve
/readyzon port 50050 via the file-transfer server, started unconditionally in worker mode (core/services/worker/worker.go:157), not gated on file staging.But the handler was a constant
200 "ok". It proved only that the listener was bound — which is precisely the failure mode this issue is about, one layer in: a process that is up and serving nothing still answers healthy. It also starts before the NATS connect and kept answering 200 after NATS dropped.So there were two independent bugs. Fixing only the Dockerfile would have made every worker report healthy forever instead of unhealthy forever — still zero information.
Changes
Worker readiness is now real.
/readyzreturns 200 only when the worker is registered and its NATS connection is live; 503 otherwise.status/last_heartbeatis fed by an HTTP heartbeat to the frontend — a different network path. A worker can keep heartbeating happily while NATS is dead and still lookhealthyin the registry. The local probe closes exactly that gap./healthzstays a constant 200. Liveness must not follow readiness, or a NATS blip becomes a cluster-wide restart storm.The HEALTHCHECK is now mode-aware.
scripts/build/healthcheck.shdetects the mode from the running process's argv via/procand derives the port from the same env vars that configure the bind address — chosen over a static endpoint because the port is both mode- and config-dependent.This also fixes an unreported sibling bug: a frontend moved off 8080 with
LOCALAI_ADDRESSwas permanently unhealthy for the same reason. Modes with no HTTP surface (agent-worker, one-shot commands) report healthy rather than false-unhealthy.HEALTHCHECK_ENDPOINTis retained as an explicit override, so the existing compose workaround keeps working — both overrides indocker-compose.distributed.yamlare now unnecessary and removed.--start-periodadded:--start-period=60m --interval=1m --timeout=10s --retries=3(was--interval=1m --timeout=10m --retries=10).Since #10949 a frontend's startup preload materializes HF artifacts before the HTTP server binds — 31 GB observed on a live cluster — so a healthy replica can legitimately fail probes for a long time. Today it is
starting; a small timing change would flip it tounhealthy.--start-periodis Docker's purpose-built knob: failures inside it do not burn retries, and it ends early on the first success, so a generous 60m costs a fast-starting container nothing.--timeoutdrops from 10m to 10s — it is a per-probe deadline, and a localhost curl that has not answered in 10s is itself the fault. Interacts correctly with #10989's preload-aware 503:curl -ffails fast and the start period absorbs it.Testing
NATSReadiness, and the/readyz//healthzsplit. Red first:serves /readyz 503 once the probe reports not-ready→Expected <int>: 200 to equal <int>: 503.scripts/build/healthcheck_test.sh(15 cases, picked up by the existingmake test-build-scripts), covering mode/port derivation for both modes, theHEALTHCHECK_ENDPOINToverride, exit-code normalisation, and/procdetection under both normal andinit: truecontainer shapes. Red first with 6 failures includingworker defaults to the file-transfer port: expected 'http://localhost:50050/readyz', got 'http://localhost:8080/readyz'— the literal bug.Two regressions those tests caught before merge:
runis kongdefault:"withargs", solocal-ai gemma-4 whisperis the frontend with model args — the first implementation classifiedgemma-4as an unknown mode and silently skipped the probe for the most common invocation in the docs. And asort -t/ -k3over/procsorted lexicographically, putting PID 64 before PID 7 and picking the wrong process underinit: true.go build ./core/... ./pkg/...,go teston both touched packages,make lint(0 issues), andshellcheckall clean. Shell suite stable over 3 runs.Not yet verified in a real container. The
/procdetection path was tested against fixture trees via aLOCALAI_HEALTHCHECK_PROCseam, since this environment has no Docker orunshare. Worth a smoke test on a real worker image before merge.🤖 Generated with Claude Code