Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
# No shell, no package manager, no tar, no apt, no curl β€” the entire OS toolchain
# CVE surface present in bookworm-slim is eliminated.
# β€’ :nonroot variant runs as uid 65532 (nobody) by default β€” no USER directive needed.
# β€’ HEALTHCHECK uses Node built-in `http` module (curl unavailable in distroless).
# β€’ HEALTHCHECK uses Node http (distroless has no curl). Equivalent to:
# curl -fsS http://127.0.0.1:3000/health || exit 1
# Use /health (liveness) only β€” not /ready (Redis/DB); deploy gate matches this.

# ---- Stage 1: Build --------------------------------------------------------
# Pinned to specific version to prevent supply chain attacks.
Expand Down Expand Up @@ -80,7 +82,9 @@ COPY healthcheck.js ./healthcheck.js
EXPOSE 3000

# Exec-form required β€” distroless has no shell to expand shell-form commands.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# start-period must cover cold start (OTel, env, Fastify listen); interval allows
# timely transition starting β†’ healthy once /health returns 200.
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD ["/nodejs/bin/node", "/app/healthcheck.js"]

CMD ["dist/server.js"]
47 changes: 35 additions & 12 deletions healthcheck.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
// Lightweight health probe for distroless containers (no curl available).
// Runs as the HEALTHCHECK CMD: /nodejs/bin/node /app/healthcheck.js
// Exits 0 when /health returns HTTP 200, exits 1 on any error or non-200 response.
//
// CommonJS (not ESM) β€” this file is copied to /app/healthcheck.js in the
// container where the repo root package.json (no "type":"module") applies.
// Distroless-compatible liveness probe (no curl). Semantics align with:
// curl -fsS http://127.0.0.1:3000/health || exit 1
// - 127.0.0.1 + IPv4 only (avoid ::1 / dual-stack quirks)
// - exit 0 only on HTTP 200; any other status or error β†’ exit 1
// - bounded wall time < Docker --timeout (5s)
'use strict';

const http = require('http');

const TIMEOUT_MS = 4500;
let settled = false;

function finish(code) {
if (settled) {
return;
}
settled = true;
process.exit(code);
}

const req = http.request(
{ host: '127.0.0.1', port: 3000, path: '/health', method: 'GET' },
{
host: '127.0.0.1',
port: 3000,
path: '/health',
method: 'GET',
family: 4,
},
(res) => {
process.exitCode = res.statusCode === 200 ? 0 : 1;
res.resume(); // drain response so socket closes cleanly
}
res.on('data', () => {});
res.on('end', () => {
finish(res.statusCode === 200 ? 0 : 1);
});
res.on('error', () => finish(1));
},
);

req.on('error', () => { process.exitCode = 1; });
req.setTimeout(4000, () => { req.destroy(); process.exitCode = 1; });
req.on('error', () => finish(1));
req.setTimeout(TIMEOUT_MS, () => {
req.destroy();
finish(1);
});
req.end();
20 changes: 17 additions & 3 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -217,19 +217,33 @@ _ft_final_state() {
# ---------------------------------------------------------------------------
# DOCKER HEALTH GATE
# ---------------------------------------------------------------------------
_ft_dump_container_health_json() {
local name="$1"
local json
json=$(docker inspect "$name" --format '{{json .State.Health}}' 2>/dev/null || echo "{}")
_ft_log "msg='State.Health (docker inspect)' container=$name json=$json"
}

_ft_wait_docker_health() {
local name="$1" i=1 STATUS
while [ "$i" -le 30 ]; do
# Allow start-period (30s) + several intervals (10s) + retries β€” 45Γ—2s β‰ˆ 90s
local max_attempts=45
while [ "$i" -le "$max_attempts" ]; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$name" 2>/dev/null || echo "none")
case "$STATUS" in
healthy) _ft_log "msg='docker health check passed' container=$name"; return 0 ;;
unhealthy) _ft_error "msg='docker health check failed' container=$name status=unhealthy"; return 1 ;;
unhealthy)
_ft_error "msg='docker health check failed' container=$name status=unhealthy"
_ft_dump_container_health_json "$name"
return 1
;;
none) _ft_error "msg='docker HEALTHCHECK not found β€” add HEALTHCHECK to Dockerfile; required for deploy gate' container=$name status=none"; return 1 ;;
esac
[ $(( i % 5 )) -eq 0 ] && _ft_log "msg='waiting for docker health' attempt=$i/30 status=$STATUS container=$name"
[ $(( i % 5 )) -eq 0 ] && _ft_log "msg='waiting for docker health' attempt=$i/$max_attempts status=$STATUS container=$name"
sleep 2; i=$(( i + 1 ))
done
_ft_error "msg='docker health timeout' container=$name last_status=$STATUS"
_ft_dump_container_health_json "$name"
return 1
}

Expand Down
6 changes: 4 additions & 2 deletions src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { shouldStartWorkers, areWorkersStarted, getExpectedWorkerCount } from ".

// Bootstrap flag: set to true only after Fastify has fully initialised
// (plugins registered, routes attached, app.listen() resolved).
// /health returns 503 until this is set β€” prevents the deploy gate from
// treating a partially-initialised process as healthy.
// /health returns 503 until this is set β€” prevents the deploy gate and Docker
// HEALTHCHECK (127.0.0.1:3000/health) from treating a partial boot as healthy.
// /ready is separate: deep checks (Redis, Supabase, queues) β€” never use it for
// Docker HEALTHCHECK or deploy.sh; workers must not block liveness.
let isBootstrapped = false;

export function setBootstrapped(): void {
Expand Down
Loading