From 3c481749fff8be31fb7dac5f78de5b875257191f Mon Sep 17 00:00:00 2001 From: dkijania Date: Mon, 29 Jun 2026 08:27:52 +0200 Subject: [PATCH 1/3] docs: add reference deployment manifests (k8s + prod Compose) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were no production deployment artifacts — operators had npm/Docker/Compose for dev but no opinionated manifest with probes, resource limits, and a hardened runtime. Add deploy/: - kubernetes.yaml — Deployment + Service + HPA (+ placeholder Secret) with liveness (/healthcheck) and readiness (/readiness) probes, resource requests/limits, a 2→6 CPU autoscaler, Prometheus scrape annotations for /metrics, a hardened pod securityContext (non-root, readOnlyRootFilesystem, no privilege escalation, all caps dropped, RuntimeDefault seccomp), and a 30s termination grace period matching the graceful-shutdown drain. - docker-compose.prod.yml — the published image against an external read-only Postgres, with CPU/memory caps. - README.md — usage and how this maps to the security deployment contract. Linked from the root README. References the probe/metrics endpoints delivered by the sibling P1 PRs. Closes #179. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6 --- README.md | 4 ++ deploy/README.md | 45 ++++++++++++ deploy/docker-compose.prod.yml | 22 ++++++ deploy/kubernetes.yaml | 123 +++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.prod.yml create mode 100644 deploy/kubernetes.yaml diff --git a/README.md b/README.md index b795ccf0..16ec5412 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,10 @@ deployment. For SLOs, capacity guidance, what to monitor, and incident response, see the [operations runbook](./docs/runbook.md). +## Deployment + +Reference Kubernetes and production Docker Compose manifests — with liveness/readiness probes, resource limits, autoscaling, and a hardened pod security context — live in [`deploy/`](./deploy/). Read [`docs/security.md`](./docs/security.md) for the deployment contract (TLS gateway, read-only DB role, private Postgres). + ## Contributing - AI coding agents: read [`AGENTS.md`](./AGENTS.md) first. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..0ad2ffea --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,45 @@ +# Reference deployment artifacts + +Opinionated starting points for running the Archive Node API in production. They +are references to adapt, not turnkey configs — review image tags, sizing, and +secret management for your environment. Read [`docs/security.md`](../docs/security.md) +first for the deployment contract (TLS gateway, read-only DB role, private +Postgres). + +## Kubernetes — [`kubernetes.yaml`](./kubernetes.yaml) + +A `Deployment` + `Service` + `HorizontalPodAutoscaler` (and a placeholder +`Secret`) with the production defaults baked in: + +- **Liveness** probe on `/healthcheck` (process up) and **readiness** probe on + `/readiness` (database reachable) — a node with a dead DB stops receiving + traffic without being restarted. +- **Resource** requests/limits and a 2→6 replica HPA on CPU. +- Hardened pod: non-root, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: +false`, all capabilities dropped, `RuntimeDefault` seccomp. +- Prometheus scrape annotations pointing at `/metrics`. +- `terminationGracePeriodSeconds: 30` to match the app's graceful-shutdown drain. + +```sh +# edit the Secret's PG_CONN (use a read-only role) and the image tag first +kubectl apply -f deploy/kubernetes.yaml +``` + +Put a TLS-terminating Ingress/gateway in front (it must set `X-Forwarded-For` +for per-client rate limiting) — see [`docs/security.md`](../docs/security.md). + +## Docker Compose — [`docker-compose.prod.yml`](./docker-compose.prod.yml) + +Runs only the published image against an external Postgres (contrast with the +repo-root `docker-compose.yml`, which is for local dev with a bundled DB). + +```sh +PG_CONN='postgres://archive_api_ro:...@db:5432/archive' \ + docker compose -f deploy/docker-compose.prod.yml up -d +``` + +## Sizing + +The bottleneck is Postgres, not this server; point `PG_CONN` at read replicas for +throughput. See the benchmark note in the root [`README.md`](../README.md#hardware-requirements) +and use `npm run benchmark` to size your own deployment. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 00000000..ec3e04f1 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,22 @@ +# Production-shaped Compose for the Archive Node API alone (bring your own +# Postgres). Unlike the repo-root docker-compose.yml — which stands up Postgres + +# Jaeger for local development — this runs only the published image against an +# external, read-only archive database. See deploy/README.md and docs/security.md. +services: + archive-node-api: + # Pin a specific version in production rather than :latest. + image: ghcr.io/o1-labs/archive-node-api:latest + restart: unless-stopped + ports: + - '8080:8080' + environment: + # Required. Point at a read-only Postgres role (see docs/security.md). + PG_CONN: ${PG_CONN:?set PG_CONN to your archive-node Postgres connection string} + PORT: '8080' + # Restrict cross-origin access — leave unset for same-origin only. + # CORS_ORIGIN: 'https://app.example.com' + # Resource caps (Compose v2). + cpus: 1.0 + mem_limit: 512m + # The image ships a HEALTHCHECK against /healthcheck; Compose surfaces it as + # the container health status. diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml new file mode 100644 index 00000000..60374ba2 --- /dev/null +++ b/deploy/kubernetes.yaml @@ -0,0 +1,123 @@ +# Reference Kubernetes manifest for the Archive Node API. +# +# This is a starting point, not a turnkey production deploy — review the image +# tag, replica count, resource sizing, and secret management for your cluster. +# See deploy/README.md and docs/security.md for the full deployment contract. +--- +apiVersion: v1 +kind: Secret +metadata: + name: archive-node-api +type: Opaque +stringData: + # Point at a read-only Postgres role (see docs/security.md). Replace before use. + PG_CONN: 'postgres://archive_api_ro:CHANGE_ME@postgres:5432/archive' +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: archive-node-api + labels: + app: archive-node-api +spec: + replicas: 2 + selector: + matchLabels: + app: archive-node-api + template: + metadata: + labels: + app: archive-node-api + annotations: + prometheus.io/scrape: 'true' + prometheus.io/port: '8080' + prometheus.io/path: /metrics + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + seccompProfile: + type: RuntimeDefault + containers: + - name: archive-node-api + # Pin a specific version in production rather than :latest. + image: ghcr.io/o1-labs/archive-node-api:latest + ports: + - containerPort: 8080 + env: + - name: PORT + value: '8080' + - name: PG_CONN + valueFrom: + secretKeyRef: + name: archive-node-api + key: PG_CONN + # Restrict cross-origin access (see docs/security.md). Leave unset for + # same-origin only, or set an explicit allowlist. + # - name: CORS_ORIGIN + # value: 'https://app.example.com' + resources: + requests: + cpu: '250m' + memory: '256Mi' + limits: + cpu: '1' + memory: '512Mi' + # Liveness: process is up. Readiness: the database is reachable. + livenessProbe: + httpGet: + path: /healthcheck + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /readiness + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + # Give in-flight requests time to drain on rollout (matches the app's + # graceful-shutdown window). + terminationGracePeriodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: archive-node-api + labels: + app: archive-node-api +spec: + type: ClusterIP + selector: + app: archive-node-api + ports: + - name: http + port: 80 + targetPort: 8080 +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: archive-node-api +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: archive-node-api + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 From e9b4b41f7b113ffaefcd10bc0226b24d3daad4b4 Mon Sep 17 00:00:00 2001 From: dkijania Date: Fri, 17 Jul 2026 09:42:06 +0200 Subject: [PATCH 2/3] docs(deploy): pin image to 1.0.0, set CORS explicitly, fix replica claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readiness probe targets /readiness, which only exists from 1.0.0, but the manifests pulled :latest — today that resolves to 0.0.6. Applied as-is the probe would 404 forever, no pod would reach Ready, and the Service would be left with zero endpoints: a total outage from a manifest offered as the copy-paste reference. Both manifests now pin 1.0.0 and state the requirement up front. The /metrics scrape annotations had the same dependency. CORS_ORIGIN shipped commented out, described as "leave unset for same-origin only". Unset blocks every cross-origin browser client — the mina-explorer included — and does so silently, with nothing in the server logs. Since the primary consumers are browsers, it now ships set, with the trade-off spelled out. deploy/README.md repeated the root README's claim that pointing PG_CONN at replicas buys throughput. It buys failover: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Real read scaling needs a balancer in front of Postgres. Also sets TRUST_PROXY=1 in the k8s manifest (an ingress adds a hop, and the default of 0 would bucket every client together) and corrects terminationGracePeriodSeconds from "matches" to "exceeds" SHUTDOWN_TIMEOUT_MS, which is the property that actually matters. Verified with kubectl apply --dry-run=client and docker compose config. Addresses review feedback on #196. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/README.md | 27 +++++++++++++++++++------- deploy/docker-compose.prod.yml | 16 ++++++++++++---- deploy/kubernetes.yaml | 35 ++++++++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 0ad2ffea..d9c17c05 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -6,6 +6,11 @@ secret management for your environment. Read [`docs/security.md`](../docs/securi first for the deployment contract (TLS gateway, read-only DB role, private Postgres). +> **These manifests require image `>= 1.0.0`** and are pinned to it. The +> readiness probe and `/metrics` scrape target endpoints that `0.0.x` images do +> not serve — on an older image the readiness probe 404s forever, no pod goes +> Ready, and the Service ends up with no endpoints at all. + ## Kubernetes — [`kubernetes.yaml`](./kubernetes.yaml) A `Deployment` + `Service` + `HorizontalPodAutoscaler` (and a placeholder @@ -18,15 +23,19 @@ A `Deployment` + `Service` + `HorizontalPodAutoscaler` (and a placeholder - Hardened pod: non-root, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: false`, all capabilities dropped, `RuntimeDefault` seccomp. - Prometheus scrape annotations pointing at `/metrics`. -- `terminationGracePeriodSeconds: 30` to match the app's graceful-shutdown drain. +- `terminationGracePeriodSeconds: 30`, comfortably above the app's own 10s + `SHUTDOWN_TIMEOUT_MS`, so the drain completes before SIGKILL. ```sh -# edit the Secret's PG_CONN (use a read-only role) and the image tag first +# edit the Secret's PG_CONN (use a read-only role) first kubectl apply -f deploy/kubernetes.yaml ``` -Put a TLS-terminating Ingress/gateway in front (it must set `X-Forwarded-For` -for per-client rate limiting) — see [`docs/security.md`](../docs/security.md). +Put a TLS-terminating Ingress/gateway in front and set **`TRUST_PROXY` to the +number of hops** it adds. The gateway must set `X-Forwarded-For`, but the API +ignores that header while `TRUST_PROXY=0` (the safe default for a directly +exposed server), so leaving it unset behind an ingress collapses every client +into a single rate-limit bucket. See [`docs/security.md`](../docs/security.md). ## Docker Compose — [`docker-compose.prod.yml`](./docker-compose.prod.yml) @@ -40,6 +49,10 @@ PG_CONN='postgres://archive_api_ro:...@db:5432/archive' \ ## Sizing -The bottleneck is Postgres, not this server; point `PG_CONN` at read replicas for -throughput. See the benchmark note in the root [`README.md`](../README.md#hardware-requirements) -and use `npm run benchmark` to size your own deployment. +The bottleneck is Postgres, not this server. Note that listing several hosts in +`PG_CONN` gives you **failover, not read throughput** — the client sticks to the +first host and only moves on when the connection fails. To spread reads across +replicas, put a load balancer (PgBouncer, HAProxy, a managed reader endpoint) in +front of Postgres and point `PG_CONN` at it. See the benchmark note in the root +[`README.md`](../README.md#hardware-requirements) and use `npm run benchmark` to +size your own deployment. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index ec3e04f1..e149974b 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -4,8 +4,8 @@ # external, read-only archive database. See deploy/README.md and docs/security.md. services: archive-node-api: - # Pin a specific version in production rather than :latest. - image: ghcr.io/o1-labs/archive-node-api:latest + # Pinned, not :latest. Readiness/metrics endpoints require >= 1.0.0. + image: ghcr.io/o1-labs/archive-node-api:1.0.0 restart: unless-stopped ports: - '8080:8080' @@ -13,8 +13,16 @@ services: # Required. Point at a read-only Postgres role (see docs/security.md). PG_CONN: ${PG_CONN:?set PG_CONN to your archive-node Postgres connection string} PORT: '8080' - # Restrict cross-origin access — leave unset for same-origin only. - # CORS_ORIGIN: 'https://app.example.com' + # Browser clients are cross-origin and CANNOT reach this API unless their + # origin is allowed here — and they fail silently, with nothing in the + # server logs. Set this deliberately (see docs/security.md): + # '*' public API any browser may call + # 'https://app.example.com' known, fixed front-ends (comma-separate) + # Unset means same-origin only, which blocks every browser client. + CORS_ORIGIN: '*' + # Proxy hops in front of the API. Rate limiting keys on the socket address + # while this is 0, so every client behind a proxy shares one bucket. + TRUST_PROXY: '0' # Resource caps (Compose v2). cpus: 1.0 mem_limit: 512m diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml index 60374ba2..68103423 100644 --- a/deploy/kubernetes.yaml +++ b/deploy/kubernetes.yaml @@ -3,6 +3,12 @@ # This is a starting point, not a turnkey production deploy — review the image # tag, replica count, resource sizing, and secret management for your cluster. # See deploy/README.md and docs/security.md for the full deployment contract. +# +# REQUIRES image >= 1.0.0. The readiness probe below targets /readiness, which +# earlier images do not serve: on 0.0.x the probe 404s forever, no pod ever goes +# Ready, and the Service is left with zero endpoints — a total outage. The +# /metrics scrape annotations need >= 1.0.0 for the same reason. The image is +# pinned accordingly; do not move it back to :latest. --- apiVersion: v1 kind: Secret @@ -40,8 +46,8 @@ spec: type: RuntimeDefault containers: - name: archive-node-api - # Pin a specific version in production rather than :latest. - image: ghcr.io/o1-labs/archive-node-api:latest + # Pinned, not :latest — see the version requirement at the top. + image: ghcr.io/o1-labs/archive-node-api:1.0.0 ports: - containerPort: 8080 env: @@ -52,10 +58,23 @@ spec: secretKeyRef: name: archive-node-api key: PG_CONN - # Restrict cross-origin access (see docs/security.md). Leave unset for - # same-origin only, or set an explicit allowlist. - # - name: CORS_ORIGIN - # value: 'https://app.example.com' + # Browser clients are cross-origin and CANNOT reach this API unless + # their origin is allowed here — and they fail silently, with nothing + # in the server logs. Set this deliberately (see docs/security.md): + # '*' public API any browser may call + # 'https://app.example.com' known, fixed front-ends (comma-separate) + # Unset means same-origin only, which blocks every browser client. + - name: CORS_ORIGIN + value: '*' + # Must be < terminationGracePeriodSeconds below, so the app finishes + # draining before the kubelet sends SIGKILL. + - name: SHUTDOWN_TIMEOUT_MS + value: '10000' + # Number of proxy hops in front of the API. Rate limiting keys on the + # socket address while this is 0, so every client behind an ingress + # shares one bucket; set it to the real hop count. + - name: TRUST_PROXY + value: '1' resources: requests: cpu: '250m' @@ -84,8 +103,8 @@ spec: capabilities: drop: - ALL - # Give in-flight requests time to drain on rollout (matches the app's - # graceful-shutdown window). + # Must exceed the app's own SHUTDOWN_TIMEOUT_MS (10s above) so the drain, + # trace flush, and pool close all complete before SIGKILL. terminationGracePeriodSeconds: 30 --- apiVersion: v1 From 8cc2bd2d1e80b5fd183988ac7646aee7c5818fa0 Mon Sep 17 00:00:00 2001 From: dkijania Date: Wed, 19 Aug 2026 13:50:11 +0200 Subject: [PATCH 3/3] docs(deploy): harden reference manifests --- README.md | 65 ++++++++++++++++------------ deploy/README.md | 32 +++++++++----- deploy/docker-compose.prod.yml | 12 +++--- deploy/kubernetes.yaml | 79 ++++++++++++++++++++++++---------- deploy/secret.example.yaml | 8 ++++ 5 files changed, 131 insertions(+), 65 deletions(-) create mode 100644 deploy/secret.example.yaml diff --git a/README.md b/README.md index 16ec5412..6157b3bb 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,20 @@ A GraphQL server that exposes [Mina archive-node](https://docs.minaprotocol.com/ ```graphql query { events(input: { address: "B62..." }) { - blockInfo { height stateHash timestamp chainStatus } - eventData { data } - transactionInfo { status hash memo } + blockInfo { + height + stateHash + timestamp + chainStatus + } + eventData { + data + } + transactionInfo { + status + hash + memo + } } } ``` @@ -22,11 +33,11 @@ The full surface lives in [`schema.graphql`](./schema.graphql). Pick the path that matches your situation. Each one is fully covered in [`docs/getting-started.md`](./docs/getting-started.md). -| Path | When to use it | -| --- | --- | -| **[npm](./docs/getting-started.md#path-a--npm-bring-your-own-database)** | You already have an archive-node Postgres reachable. Lightest weight. | +| Path | When to use it | +| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| **[npm](./docs/getting-started.md#path-a--npm-bring-your-own-database)** | You already have an archive-node Postgres reachable. Lightest weight. | | **[Prebuilt Docker image](./docs/getting-started.md#path-b--prebuilt-docker-image-bring-your-own-database)** | You have a Postgres reachable but don't want a Node toolchain locally. | -| **[Docker Compose + DB snapshot](./docs/getting-started.md#path-c--docker-compose-with-database-snapshot)** | No archive-node DB available — Compose stands one up from a snapshot. | +| **[Docker Compose + DB snapshot](./docs/getting-started.md#path-c--docker-compose-with-database-snapshot)** | No archive-node DB available — Compose stands one up from a snapshot. | ```sh # the 30-second taste (Path A) @@ -41,31 +52,31 @@ PG_CONN='postgres://postgres:postgres@localhost:5432/archive' \ `PG_CONN` is the only required environment variable. The most common knobs: -| Variable | Default | Description | -| --- | --- | --- | -| `PG_CONN` | *(required)* | Postgres connection string for the archive-node DB | -| `PORT` | `8080` | Port the GraphQL server listens on | -| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` | -| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection | -| `ENABLE_LOGGING` | `false` | Enable request logging | -| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` | -| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector | -| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` | +| Variable | Default | Description | +| ---------------------- | ------------ | -------------------------------------------------- | +| `PG_CONN` | _(required)_ | Postgres connection string for the archive-node DB | +| `PORT` | `8080` | Port the GraphQL server listens on | +| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` | +| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection | +| `ENABLE_LOGGING` | `false` | Enable request logging | +| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` | +| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector | +| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` | Full reference, including HA / multi-host `PG_CONN` syntax, in [`docs/getting-started.md#configuration`](./docs/getting-started.md#configuration). ## Development -| Command | What it does | -| --- | --- | -| `npm run dev` | Run the server with hot reload (reads `.env`) | -| `npm run build` | Compile TypeScript to `build/` | -| `npm run start` | Run the compiled server | -| `npm run lint` | ESLint over `*.ts` | -| `npm run test:unit` | Unit tests (no DB required) | -| `npm run test` | Full test suite — needs a running [Lightnet](https://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet) | -| `npm run codegen` | Regenerate `src/resolvers-types.ts` from `schema.graphql` | -| `npm run benchmark` | Artillery load test against a running server | +| Command | What it does | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| `npm run dev` | Run the server with hot reload (reads `.env`) | +| `npm run build` | Compile TypeScript to `build/` | +| `npm run start` | Run the compiled server | +| `npm run lint` | ESLint over `*.ts` | +| `npm run test:unit` | Unit tests (no DB required) | +| `npm run test` | Full test suite — needs a running [Lightnet](https://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet) | +| `npm run codegen` | Regenerate `src/resolvers-types.ts` from `schema.graphql` | +| `npm run benchmark` | Artillery load test against a running server | Running the full suite requires Lightnet plus a populated DB: diff --git a/deploy/README.md b/deploy/README.md index d9c17c05..8c8e95aa 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -13,21 +13,31 @@ Postgres). ## Kubernetes — [`kubernetes.yaml`](./kubernetes.yaml) -A `Deployment` + `Service` + `HorizontalPodAutoscaler` (and a placeholder -`Secret`) with the production defaults baked in: +A `Deployment` + `Service` + `HorizontalPodAutoscaler` + +`PodDisruptionBudget` with production defaults baked in. Create the Postgres +`Secret` separately so routine `kubectl apply` runs never overwrite a real +connection string with the example placeholder. - **Liveness** probe on `/healthcheck` (process up) and **readiness** probe on `/readiness` (database reachable) — a node with a dead DB stops receiving - traffic without being restarted. -- **Resource** requests/limits and a 2→6 replica HPA on CPU. -- Hardened pod: non-root, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: -false`, all capabilities dropped, `RuntimeDefault` seccomp. -- Prometheus scrape annotations pointing at `/metrics`. -- `terminationGracePeriodSeconds: 30`, comfortably above the app's own 10s - `SHUTDOWN_TIMEOUT_MS`, so the drain completes before SIGKILL. + traffic without being restarted. Readiness tolerates up to 60s of database + slowness before removing endpoints. +- **Resource** requests/limits and a 2 to 6 replica HPA on CPU; the HPA owns the + replica count. +- Hardened pod: non-root, `readOnlyRootFilesystem`, + `allowPrivilegeEscalation: false`, all capabilities dropped, `RuntimeDefault` + seccomp, no mounted service account token, and a scratch `emptyDir` mounted at + `/tmp`. +- Prometheus scrape annotations pointing at `/metrics`, with + `ENABLE_METRICS=true`. +- `preStop` sleeps 15s before SIGTERM, then `terminationGracePeriodSeconds: 45` + leaves room for the app's own 10s `SHUTDOWN_TIMEOUT_MS` drain before SIGKILL. ```sh -# edit the Secret's PG_CONN (use a read-only role) first +# once, with your real connection string (never committed): +kubectl create secret generic archive-node-api \ + --from-literal=PG_CONN='postgres://archive_api_ro:...@postgres:5432/archive' + kubectl apply -f deploy/kubernetes.yaml ``` @@ -41,6 +51,8 @@ into a single rate-limit bucket. See [`docs/security.md`](../docs/security.md). Runs only the published image against an external Postgres (contrast with the repo-root `docker-compose.yml`, which is for local dev with a bundled DB). +Compose surfaces the image's `/healthcheck` as container health for images +`>= 1.0.0`; this is a liveness check, not database-aware readiness. ```sh PG_CONN='postgres://archive_api_ro:...@db:5432/archive' \ diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index e149974b..d97720fe 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -13,6 +13,7 @@ services: # Required. Point at a read-only Postgres role (see docs/security.md). PG_CONN: ${PG_CONN:?set PG_CONN to your archive-node Postgres connection string} PORT: '8080' + NODE_OPTIONS: '--max-old-space-size=768' # Browser clients are cross-origin and CANNOT reach this API unless their # origin is allowed here — and they fail silently, with nothing in the # server logs. Set this deliberately (see docs/security.md): @@ -20,11 +21,12 @@ services: # 'https://app.example.com' known, fixed front-ends (comma-separate) # Unset means same-origin only, which blocks every browser client. CORS_ORIGIN: '*' + ENABLE_METRICS: 'true' # Proxy hops in front of the API. Rate limiting keys on the socket address # while this is 0, so every client behind a proxy shares one bucket. TRUST_PROXY: '0' - # Resource caps (Compose v2). - cpus: 1.0 - mem_limit: 512m - # The image ships a HEALTHCHECK against /healthcheck; Compose surfaces it as - # the container health status. + RATE_LIMIT_MAX: '1200' + # Memory cap (Compose v2). NODE_OPTIONS keeps V8 below this container limit. + mem_limit: 1g + # Images >= 1.0.0 ship a HEALTHCHECK against /healthcheck; Compose surfaces + # it as the container health status. diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml index 68103423..bac7fe53 100644 --- a/deploy/kubernetes.yaml +++ b/deploy/kubernetes.yaml @@ -1,8 +1,9 @@ # Reference Kubernetes manifest for the Archive Node API. # # This is a starting point, not a turnkey production deploy — review the image -# tag, replica count, resource sizing, and secret management for your cluster. -# See deploy/README.md and docs/security.md for the full deployment contract. +# tag, resource sizing, and secret management for your cluster. Create the +# archive-node-api Secret separately; see deploy/secret.example.yaml and +# deploy/README.md. # # REQUIRES image >= 1.0.0. The readiness probe below targets /readiness, which # earlier images do not serve: on 0.0.x the probe 404s forever, no pod ever goes @@ -10,15 +11,6 @@ # /metrics scrape annotations need >= 1.0.0 for the same reason. The image is # pinned accordingly; do not move it back to :latest. --- -apiVersion: v1 -kind: Secret -metadata: - name: archive-node-api -type: Opaque -stringData: - # Point at a read-only Postgres role (see docs/security.md). Replace before use. - PG_CONN: 'postgres://archive_api_ro:CHANGE_ME@postgres:5432/archive' ---- apiVersion: apps/v1 kind: Deployment metadata: @@ -26,7 +18,6 @@ metadata: labels: app: archive-node-api spec: - replicas: 2 selector: matchLabels: app: archive-node-api @@ -42,8 +33,11 @@ spec: securityContext: runAsNonRoot: true runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 seccompProfile: type: RuntimeDefault + automountServiceAccountToken: false containers: - name: archive-node-api # Pinned, not :latest — see the version requirement at the top. @@ -58,6 +52,10 @@ spec: secretKeyRef: name: archive-node-api key: PG_CONN + - name: NODE_OPTIONS + # Keep V8 below limits.memory so large responses fail in-process + # before the kernel OOM-kills the pod mid-request. + value: '--max-old-space-size=768' # Browser clients are cross-origin and CANNOT reach this API unless # their origin is allowed here — and they fail silently, with nothing # in the server logs. Set this deliberately (see docs/security.md): @@ -66,6 +64,8 @@ spec: # Unset means same-origin only, which blocks every browser client. - name: CORS_ORIGIN value: '*' + - name: ENABLE_METRICS + value: 'true' # Must be < terminationGracePeriodSeconds below, so the app finishes # draining before the kubelet sends SIGKILL. - name: SHUTDOWN_TIMEOUT_MS @@ -75,37 +75,60 @@ spec: # shares one bucket; set it to the real hop count. - name: TRUST_PROXY value: '1' + - name: RATE_LIMIT_MAX + # Per client IP per 60s, counted per replica. The HPA moves + # replicas 2 to 6, so the effective global ceiling varies with scale. + value: '1200' resources: requests: - cpu: '250m' - memory: '256Mi' - limits: - cpu: '1' + cpu: '500m' memory: '512Mi' + limits: + # No CPU limit: CFS throttling hurts Node.js p99 latency. The CPU + # request above is the scheduling guarantee. + memory: '1Gi' + lifecycle: + preStop: + # Endpoint removal is async and concurrent with SIGTERM. Give + # kube-proxy/ingress time to stop routing here before Node closes. + exec: + command: ['/bin/sh', '-c', 'sleep 15'] # Liveness: process is up. Readiness: the database is reachable. livenessProbe: httpGet: path: /healthcheck port: 8080 - initialDelaySeconds: 10 periodSeconds: 15 - timeoutSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 readinessProbe: httpGet: path: /readiness port: 8080 - initialDelaySeconds: 5 periodSeconds: 10 - timeoutSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 6 + successThreshold: 1 + startupProbe: + httpGet: + path: /healthcheck + port: 8080 + periodSeconds: 3 + failureThreshold: 20 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL - # Must exceed the app's own SHUTDOWN_TIMEOUT_MS (10s above) so the drain, - # trace flush, and pool close all complete before SIGKILL. - terminationGracePeriodSeconds: 30 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + # 15s preStop + SHUTDOWN_TIMEOUT_MS + margin before SIGKILL. + terminationGracePeriodSeconds: 45 --- apiVersion: v1 kind: Service @@ -140,3 +163,13 @@ spec: target: type: Utilization averageUtilization: 70 +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: archive-node-api +spec: + minAvailable: 1 + selector: + matchLabels: + app: archive-node-api diff --git a/deploy/secret.example.yaml b/deploy/secret.example.yaml new file mode 100644 index 00000000..7d842756 --- /dev/null +++ b/deploy/secret.example.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: archive-node-api +type: Opaque +stringData: + # Point at a read-only Postgres role (see docs/security.md). Replace before use. + PG_CONN: 'postgres://archive_api_ro:CHANGE_ME@postgres:5432/archive'