From 65e35761eca16a73954b9f7f6c336e1c39ce048b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:53:20 +0100 Subject: [PATCH] build: probe container health with Node's fetch instead of wget The probe now needs nothing but the node binary the image already runs, which frees the base image from carrying wget. The image HEALTHCHECK and the compose healthcheck use the same one-liner: a non-2xx answer, a refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout. The boot-storm guard reads the abort from the fetch and refuses a probe that names wget or curl. --- Dockerfile | 7 ++++-- docker-compose.yml | 8 ++++--- docs/self-hosting/scaling.md | 2 +- src/__tests__/healthcheck-boot-storm.test.ts | 25 ++++++++++++++------ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 37143e516..9b6a9a6d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -369,9 +369,12 @@ ENV HOSTNAME="0.0.0.0" # migration waiting out a held lock retries for up to about eight minutes. # Five retries: a pool saturated for a minute during a boot storm makes the # probe's 2 s database check miss once or twice without marking the container -# unhealthy (docker-compose.yml explains the numbers). +# unhealthy (docker-compose.yml explains the numbers). The probe is Node's own +# fetch: the Debian base carries neither wget nor curl, and installing one for +# this line would add a TLS stack to the image's scan surface. A non-2xx +# answer, a refused connection and the 4 s abort all exit 1. HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \ - CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1 + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] ENTRYPOINT ["./docker-entrypoint.sh"] CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index b7e7faef3..d71dd0066 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -557,8 +557,10 @@ services: healthcheck: test: [ - "CMD-SHELL", - "wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1", + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))", ] interval: 30s timeout: 5s @@ -567,7 +569,7 @@ services: # past that, so a boot storm that saturates the pool for a minute after # the start period (backfills, a restore, another tenant's import) costs # a probe or two, not the container's health and the proxy's routing. - # wget's own 4 s timeout ends the request inside Docker's 5 s, so a + # The probe's own 4 s abort ends the request inside Docker's 5 s, so a # stalled connection reads as one clean miss rather than a killed probe. # A database that stays away for 2.5 minutes is a real outage and shows. retries: 5 diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 6ecac0731..16add088b 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -131,7 +131,7 @@ and restart the worker together with the web containers. ## Healthchecks -- `app` (web) healthcheck: `wget /api/health` every 30s. +- `app` (web) healthcheck: a `node -e` fetch of `/api/health` every 30s. - `app-worker` does not expose a port; its liveness is tracked in-memory inside the process (`src/lib/jobs/worker-status.ts`) and read back by `/api/admin/status`. In a web/worker split this means diff --git a/src/__tests__/healthcheck-boot-storm.test.ts b/src/__tests__/healthcheck-boot-storm.test.ts index e91b7401b..6a780da5b 100644 --- a/src/__tests__/healthcheck-boot-storm.test.ts +++ b/src/__tests__/healthcheck-boot-storm.test.ts @@ -79,10 +79,21 @@ function imageCheck(): Check { }; } -function wgetTimeout(command: string): number { - const m = /wget[^|]*--timeout=(\d+)/.exec(command); - if (!m) throw new Error(`no wget timeout in: ${command}`); - return Number(m[1]); +/** + * The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The + * image carries no wget or curl, so a probe that names one would fail every + * time and mark a healthy container unhealthy. + */ +function probeTimeout(command: string): number { + expect(command, "the probe needs a binary the image has").not.toMatch( + /\b(wget|curl)\b/, + ); + const m = + /fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health',\{signal:AbortSignal\.timeout\((\d+)\)\}\)/.exec( + command, + ); + if (!m) throw new Error(`no aborting /api/health fetch in: ${command}`); + return Number(m[1]) / 1000; } describe.each([ @@ -96,9 +107,9 @@ describe.each([ it("ends the request inside Docker's timeout, after the database probe's", () => { const check = read(); - const wget = wgetTimeout(check.command); - expect(wget).toBeGreaterThan(probeTimeoutSeconds); - expect(wget).toBeLessThan(check.timeout); + const abort = probeTimeout(check.command); + expect(abort).toBeGreaterThan(probeTimeoutSeconds); + expect(abort).toBeLessThan(check.timeout); }); });