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); }); });