Skip to content

Commit dfcd267

Browse files
committed
fix(worker): give the worker a real health endpoint (#10987)
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]
1 parent 2f33ad6 commit dfcd267

10 files changed

Lines changed: 584 additions & 30 deletions

File tree

Dockerfile

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,12 @@ RUN go install github.com/mikefarah/yq/v4@latest
393393
# If you cannot find a more suitable place for an addition, this layer is a suitable place for it.
394394
FROM requirements-drivers
395395

396-
ENV HEALTHCHECK_ENDPOINT=http://localhost:8080/readyz
396+
# Optional override for the HEALTHCHECK target. Left empty so healthcheck.sh
397+
# derives the endpoint from the mode the container is actually running — the
398+
# same image runs `local-ai run` (HTTP on 8080) and `local-ai worker` (HTTP on
399+
# the gRPC base port minus one), and a hardcoded default marked every worker
400+
# permanently unhealthy (#10987). Set it to pin an explicit URL.
401+
ENV HEALTHCHECK_ENDPOINT=""
397402

398403
ARG CUDA_MAJOR_VERSION=12
399404
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
@@ -403,6 +408,7 @@ ENV NVIDIA_VISIBLE_DEVICES=all
403408
WORKDIR /
404409

405410
COPY ./entrypoint.sh .
411+
COPY ./scripts/build/healthcheck.sh .
406412

407413
# Copy the binary
408414
COPY --from=builder /build/local-ai ./
@@ -413,9 +419,22 @@ RUN --mount=from=builder,src=/build/,dst=/mnt/build \
413419
# Make sure the models directory exists
414420
RUN mkdir -p /models /backends /data
415421

416-
# Define the health check command
417-
HEALTHCHECK --interval=1m --timeout=10m --retries=10 \
418-
CMD curl -f ${HEALTHCHECK_ENDPOINT} || exit 1
422+
# Define the health check command.
423+
#
424+
# --start-period is the knob for slow starts, not --timeout/--retries. Since
425+
# #10949 a frontend's startup preload materializes HuggingFace artifacts before
426+
# the HTTP server binds (31 GB observed on a live cluster), so a healthy replica
427+
# can legitimately fail probes for a long time. Failures inside the start period
428+
# leave the container `starting` instead of burning retries, and the period ends
429+
# early on the first success — so a generous value costs a fast-starting
430+
# container nothing. A process that actually died is handled by the restart
431+
# policy, not by health.
432+
#
433+
# --timeout is a per-probe deadline: 10m meant a wedged probe could hang for ten
434+
# minutes and stretch detection without bound. A localhost curl that has not
435+
# answered in 10s is itself the fault being detected.
436+
HEALTHCHECK --start-period=60m --interval=1m --timeout=10s --retries=3 \
437+
CMD /healthcheck.sh
419438

420439
VOLUME /models /backends /configuration /data
421440
EXPOSE 8080

core/services/nodes/file_transfer_server.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,26 @@ const (
4646
// It provides PUT/GET/POST endpoints for uploading, downloading, and allocating temp files,
4747
// as well as backend log REST and WebSocket endpoints when logStore is non-nil.
4848
// Auth is via Bearer token (registration token), using constant-time comparison.
49-
func StartFileTransferServer(addr, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, logStore ...*model.BackendLogStore) (*http.Server, error) {
49+
// A nil readiness fails open, keeping /readyz's historical always-200 answer.
50+
func StartFileTransferServer(addr, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, readiness *WorkerReadiness, logStore ...*model.BackendLogStore) (*http.Server, error) {
5051
listener, err := net.Listen("tcp", addr)
5152
if err != nil {
5253
return nil, fmt.Errorf("listen %s: %w", addr, err)
5354
}
54-
return StartFileTransferServerWithListener(listener, stagingDir, modelsDir, dataDir, token, maxUploadSize, logStore...)
55+
return StartFileTransferServerWithReadiness(listener, stagingDir, modelsDir, dataDir, token, maxUploadSize, readiness, logStore...)
5556
}
5657

5758
// StartFileTransferServerWithListener starts the server on an existing listener.
5859
// This avoids the TOCTOU race of closing a listener and re-binding to the same port.
5960
func StartFileTransferServerWithListener(lis net.Listener, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, logStore ...*model.BackendLogStore) (*http.Server, error) {
61+
return StartFileTransferServerWithReadiness(lis, stagingDir, modelsDir, dataDir, token, maxUploadSize, nil, logStore...)
62+
}
63+
64+
// StartFileTransferServerWithReadiness is StartFileTransferServerWithListener
65+
// plus a readiness gate for the /readyz probe. A nil readiness fails open, so
66+
// the probe keeps its historical always-200 behaviour for callers that have no
67+
// meaningful readiness signal to report.
68+
func StartFileTransferServerWithReadiness(lis net.Listener, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, readiness *WorkerReadiness, logStore ...*model.BackendLogStore) (*http.Server, error) {
6069
if err := os.MkdirAll(stagingDir, 0750); err != nil {
6170
return nil, fmt.Errorf("creating staging dir %s: %w", stagingDir, err)
6271
}
@@ -131,18 +140,30 @@ func StartFileTransferServerWithListener(lis net.Listener, stagingDir, modelsDir
131140

132141
// Liveness/readiness probes — unauthenticated so container orchestrators
133142
// (Docker HEALTHCHECK, k8s probes) can hit them without the bearer token.
134-
// Reaching this point means the listener is bound and the mux is serving.
135-
healthHandler := func(w http.ResponseWriter, r *http.Request) {
136-
if r.Method != http.MethodGet && r.Method != http.MethodHead {
137-
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
138-
return
143+
probe := func(check func() error) http.HandlerFunc {
144+
return func(w http.ResponseWriter, r *http.Request) {
145+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
146+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
147+
return
148+
}
149+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
150+
if err := check(); err != nil {
151+
w.WriteHeader(http.StatusServiceUnavailable)
152+
_, _ = w.Write([]byte("not ready: " + err.Error()))
153+
return
154+
}
155+
w.WriteHeader(http.StatusOK)
156+
_, _ = w.Write([]byte("ok"))
139157
}
140-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
141-
w.WriteHeader(http.StatusOK)
142-
_, _ = w.Write([]byte("ok"))
143158
}
144-
mux.HandleFunc("/readyz", healthHandler)
145-
mux.HandleFunc("/healthz", healthHandler)
159+
160+
// Liveness: reaching this point means the listener is bound and the mux is
161+
// serving. Deliberately independent of readiness — a worker whose NATS link
162+
// is momentarily down must not be restarted, or a NATS blip becomes a
163+
// cluster-wide restart storm.
164+
mux.HandleFunc("/healthz", probe(func() error { return nil }))
165+
// Readiness: "can this worker actually accept work?" See WorkerReadiness.
166+
mux.HandleFunc("/readyz", probe(readiness.Check))
146167

147168
addr := lis.Addr().String()
148169
server := &http.Server{
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package nodes
2+
3+
import (
4+
"errors"
5+
"sync/atomic"
6+
)
7+
8+
// WorkerReadiness is the gate behind a worker's /readyz probe.
9+
//
10+
// It exists because the worker's HTTP file-transfer server is started before
11+
// the worker has connected to NATS, and must keep serving after NATS drops.
12+
// The probe is therefore installed after the fact rather than passed as a
13+
// value, and must be safe to read from HTTP handler goroutines while the
14+
// startup goroutine is still installing it.
15+
type WorkerReadiness struct {
16+
probe atomic.Pointer[func() error]
17+
}
18+
19+
// Set installs (or replaces) the readiness probe.
20+
func (r *WorkerReadiness) Set(fn func() error) {
21+
if r == nil {
22+
return
23+
}
24+
r.probe.Store(&fn)
25+
}
26+
27+
// Check reports whether the worker can accept work. A nil receiver, or one with
28+
// no probe installed, fails open: callers that never wire readiness (the
29+
// frontend's own file-transfer server, tests, embedders) keep the historical
30+
// always-ready behaviour rather than being wedged out of rotation forever.
31+
func (r *WorkerReadiness) Check() error {
32+
if r == nil {
33+
return nil
34+
}
35+
fn := r.probe.Load()
36+
if fn == nil || *fn == nil {
37+
return nil
38+
}
39+
return (*fn)()
40+
}
41+
42+
// natsConn is the slice of *messaging.Client the readiness probe needs. Kept
43+
// as a local interface so this package does not import messaging (which would
44+
// be an import cycle) and so tests can supply a fake.
45+
type natsConn interface {
46+
IsConnected() bool
47+
}
48+
49+
// ErrNATSDisconnected is reported by NATSReadiness when the worker has lost its
50+
// NATS connection.
51+
var ErrNATSDisconnected = errors.New("NATS connection is down: worker cannot receive work")
52+
53+
// NATSReadiness builds the worker's readiness probe.
54+
//
55+
// A worker's real health is not "a port is open" — that is precisely the
56+
// failure mode of issue #10987, where a process that serves nothing still
57+
// answered 200. All of a worker's actual work (backend install/start/stop
58+
// events, inference dispatch, file-staging notifications) arrives over NATS, so
59+
// a worker with a dead NATS link is up and useless. Registration is already
60+
// implied by the probe being reachable at all: the file-transfer server is only
61+
// started after the worker has successfully registered with the frontend.
62+
//
63+
// This is deliberately something the controller cannot already see. The node
64+
// registry's status/last_heartbeat is fed by an HTTP heartbeat to the frontend,
65+
// a completely different network path — a worker can keep heartbeating happily
66+
// while its NATS connection is dead, and look healthy in the registry. The
67+
// local probe closes that gap.
68+
func NATSReadiness(conn natsConn) func() error {
69+
return func() error {
70+
if conn == nil || !conn.IsConnected() {
71+
return ErrNATSDisconnected
72+
}
73+
return nil
74+
}
75+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package nodes
2+
3+
import (
4+
"errors"
5+
"net"
6+
"net/http"
7+
"time"
8+
9+
. "github.com/onsi/ginkgo/v2"
10+
. "github.com/onsi/gomega"
11+
)
12+
13+
// fakeConn stands in for *messaging.Client, which cannot be constructed without
14+
// a live NATS server. Only IsConnected() is consulted by the readiness probe.
15+
type fakeConn struct{ connected bool }
16+
17+
func (f *fakeConn) IsConnected() bool { return f.connected }
18+
19+
var _ = Describe("WorkerReadiness", func() {
20+
Describe("the gate itself", func() {
21+
It("reports ready when no probe has been installed yet", func() {
22+
// Fail open: an embedder (or the frontend) that never wires a probe
23+
// keeps the historical always-ready behaviour.
24+
r := &WorkerReadiness{}
25+
Expect(r.Check()).To(Succeed())
26+
})
27+
28+
It("surfaces the installed probe's error", func() {
29+
r := &WorkerReadiness{}
30+
r.Set(func() error { return errors.New("boom") })
31+
Expect(r.Check()).To(MatchError(ContainSubstring("boom")))
32+
})
33+
34+
It("lets a later Set replace an earlier probe", func() {
35+
r := &WorkerReadiness{}
36+
r.Set(func() error { return errors.New("boom") })
37+
r.Set(func() error { return nil })
38+
Expect(r.Check()).To(Succeed())
39+
})
40+
})
41+
42+
Describe("NATSReadiness", func() {
43+
It("reports ready while the NATS connection is up", func() {
44+
Expect(NATSReadiness(&fakeConn{connected: true})()).To(Succeed())
45+
})
46+
47+
It("reports not-ready once the NATS connection drops", func() {
48+
// This is the failure mode issue #10987 is about: the process is
49+
// up and the port is bound, but the worker can receive no work.
50+
err := NATSReadiness(&fakeConn{connected: false})()
51+
Expect(err).To(MatchError(ContainSubstring("NATS")))
52+
})
53+
})
54+
55+
Describe("the file transfer server probes", func() {
56+
var (
57+
srv *http.Server
58+
baseURL string
59+
ready *WorkerReadiness
60+
)
61+
62+
BeforeEach(func() {
63+
lis, err := net.Listen("tcp", "127.0.0.1:0")
64+
Expect(err).ToNot(HaveOccurred())
65+
ready = &WorkerReadiness{}
66+
srv, err = StartFileTransferServerWithReadiness(
67+
lis, GinkgoT().TempDir(), GinkgoT().TempDir(), GinkgoT().TempDir(),
68+
"tok", 1024, ready, nil,
69+
)
70+
Expect(err).ToNot(HaveOccurred())
71+
baseURL = "http://" + lis.Addr().String()
72+
DeferCleanup(func() { ShutdownFileTransferServer(srv) })
73+
})
74+
75+
get := func(path string) int {
76+
client := &http.Client{Timeout: 5 * time.Second}
77+
resp, err := client.Get(baseURL + path)
78+
Expect(err).ToNot(HaveOccurred())
79+
defer func() { _ = resp.Body.Close() }()
80+
return resp.StatusCode
81+
}
82+
83+
It("serves /readyz 200 while the probe reports ready", func() {
84+
ready.Set(func() error { return nil })
85+
Expect(get("/readyz")).To(Equal(http.StatusOK))
86+
})
87+
88+
It("serves /readyz 503 once the probe reports not-ready", func() {
89+
ready.Set(func() error { return errors.New("NATS disconnected") })
90+
Expect(get("/readyz")).To(Equal(http.StatusServiceUnavailable))
91+
})
92+
93+
It("keeps /healthz at 200 even when readiness fails", func() {
94+
// Liveness is deliberately independent of readiness: a worker whose
95+
// NATS link is briefly down must not be killed and restarted, or a
96+
// NATS outage turns into a restart storm across every worker.
97+
ready.Set(func() error { return errors.New("NATS disconnected") })
98+
Expect(get("/healthz")).To(Equal(http.StatusOK))
99+
})
100+
})
101+
})

core/services/worker/worker.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,12 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
149149
httpAddr := cfg.resolveHTTPAddr()
150150
stagingDir := filepath.Join(cfg.ModelsPath, "..", "staging")
151151
dataDir := filepath.Join(cfg.ModelsPath, "..", "data")
152-
httpServer, err := nodes.StartFileTransferServer(httpAddr, stagingDir, cfg.ModelsPath, dataDir, cfg.RegistrationToken, config.DefaultMaxUploadSize, ml.BackendLogs())
152+
// The readiness gate is created here but only armed once NATS is up, below.
153+
// Until then /readyz reports ready, which is correct: reaching this line
154+
// means the worker has already registered with the frontend, so it is
155+
// mid-startup rather than broken.
156+
readiness := &nodes.WorkerReadiness{}
157+
httpServer, err := nodes.StartFileTransferServer(httpAddr, stagingDir, cfg.ModelsPath, dataDir, cfg.RegistrationToken, config.DefaultMaxUploadSize, readiness, ml.BackendLogs())
153158
if err != nil {
154159
return fmt.Errorf("starting HTTP file transfer server: %w", err)
155160
}
@@ -163,6 +168,11 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
163168
}
164169
defer natsClient.Close()
165170

171+
// Arm the readiness gate now that the worker can actually receive work.
172+
// From here /readyz tracks the live NATS link, so a worker that is up but
173+
// cut off from the bus reports 503 instead of a meaningless 200 (#10987).
174+
readiness.Set(nodes.NATSReadiness(natsClient))
175+
166176
// Start heartbeat goroutine (after NATS is connected so IsConnected check works)
167177
go func() {
168178
ticker := time.NewTicker(heartbeatInterval)

docker-compose.distributed.yaml

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,12 @@ services:
101101
- BASE_IMAGE=ubuntu:24.04
102102
command:
103103
- worker
104-
# The image's default HEALTHCHECK targets the server's /readyz on 8080.
105-
# Workers don't run the OpenAI API server — their HTTP file transfer
106-
# server runs on the gRPC base port - 1 (50050 here) and exposes /readyz.
107-
# Override the env var so the inherited HEALTHCHECK probes the right port.
104+
# No HEALTHCHECK_ENDPOINT override is needed: the image's healthcheck
105+
# detects worker mode and derives the port from LOCALAI_SERVE_ADDR below
106+
# (gRPC base port - 1 = 50050). The worker's /readyz reports 503 while its
107+
# NATS connection is down, so `unhealthy` here means the worker genuinely
108+
# cannot receive work.
108109
environment:
109-
HEALTHCHECK_ENDPOINT: "http://localhost:50050/readyz"
110110
LOCALAI_SERVE_ADDR: "0.0.0.0:50051"
111111
LOCALAI_ADVERTISE_ADDR: "worker-1:50051"
112112
LOCALAI_ADVERTISE_HTTP_ADDR: "worker-1:50050"
@@ -200,10 +200,9 @@ services:
200200
- |
201201
apt-get update -qq && apt-get install -y -qq docker.io >/dev/null 2>&1
202202
exec /entrypoint.sh agent-worker
203-
# The agent worker is NATS-only — no HTTP server to probe. Disable the
204-
# image's inherited HEALTHCHECK so the container doesn't show unhealthy.
205-
healthcheck:
206-
disable: true
203+
# The agent worker is NATS-only — no HTTP server to probe. The image's
204+
# healthcheck detects that mode and reports healthy rather than probing a
205+
# port that will never bind, so no override is needed here.
207206
environment:
208207
LOCALAI_NATS_URL: "nats://nats:4222"
209208
LOCALAI_REGISTER_TO: "http://localai:8080"

docs/content/features/distributed-mode.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,19 @@ local-ai worker \
232232
**HTTP file transfer:** Each worker also runs a small HTTP server for file transfer (model files, configs). By default it listens on the gRPC base port - 1 (e.g., if gRPC base is 50051, HTTP is on 50050). gRPC ports grow upward from the base port as additional models are loaded. Set `--advertise-http-addr` if the auto-detected address is not routable from the frontend.
233233
{{% /notice %}}
234234

235+
### Worker Health Probes
236+
237+
The worker's HTTP server (base port - 1, default 50050) exposes two unauthenticated probes:
238+
239+
| Endpoint | Meaning |
240+
|----------|---------|
241+
| `/healthz` | **Liveness.** 200 whenever the process is up and serving. Deliberately independent of readiness, so a brief NATS outage does not trigger a restart storm across every worker. |
242+
| `/readyz` | **Readiness.** 200 only when the worker is registered *and* its NATS connection is live; 503 otherwise. |
243+
244+
`/readyz` reports something the frontend cannot see on its own. The node registry's `status` and `last_heartbeat` are driven by an HTTP heartbeat to the frontend, which is a different network path from NATS — a worker can keep heartbeating while its NATS link is dead, and so appear `healthy` in the registry while being unable to receive any work. The local probe closes that gap.
245+
246+
The container image's `HEALTHCHECK` detects worker mode and probes this endpoint automatically; no `HEALTHCHECK_ENDPOINT` override is needed. Set `HEALTHCHECK_ENDPOINT` only to pin an explicit URL.
247+
235248
### Worker Address Configuration
236249

237250
The simplest way to configure a worker's network address is with a single variable:

0 commit comments

Comments
 (0)