From 6693ed4daedfc2d49eb69126d765ced17efa1413 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Thu, 20 Aug 2026 12:31:08 +0200 Subject: [PATCH] build(docker): run the search images on distroless and strip the install layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop npm's cache and every file Node never opens – source maps, type declarations, documentation and the dependencies' licence files – from node_modules in the same RUN as the install, so the layer that carries them is the one that shrinks - run the install on node:24-bookworm-slim and the runtime on gcr.io/distroless/nodejs24-debian12: the Node binary and its libraries, without npm, yarn, a shell or a package manager. Debian for the install too, so no musl-linked prebuilt binary lands in a glibc runtime - put the base image's Node on PATH, so a manifest overriding the command with the usual 'node …' still finds it - own /data and /provenance as uid 1000 by number, and create them in the install stage: the runtime has no shell to mkdir with - probe /health with node -e instead of wget, over a 10s timeout and a 30s start period, since a Node start costs more than wget did on a throttled host; assert the container reports healthy in the smoke test, because a shell-less HEALTHCHECK would otherwise fail silently - make the CLI the entrypoint, so 'docker run --check' reaches it Measured with 'docker save | gzip -6' on arm64: the indexer goes from 93 to 63 MB compressed, the API server from 66 to 60 MB. BREAKING CHANGE: the images' entrypoint is now the CLI, so a caller that passed 'node dist/cli.js' as the container command must drop that prefix. --- docs/reference/search-api-server.md | 8 +++++ docs/reference/search-indexer.md | 9 +++++ packages/search-api-server/Dockerfile | 46 ++++++++++++++++++++---- packages/search-indexer/Dockerfile | 51 ++++++++++++++++++++++----- tools/docker-smoke-indexer.sh | 5 +-- tools/docker-smoke.sh | 25 ++++++++++++- 6 files changed, 125 insertions(+), 19 deletions(-) diff --git a/docs/reference/search-api-server.md b/docs/reference/search-api-server.md index 6337b498..630b5860 100644 --- a/docs/reference/search-api-server.md +++ b/docs/reference/search-api-server.md @@ -153,6 +153,14 @@ npx nx run @lde/search-api-server:docker:smoke # boots it and probes /health + CI runs `docker:smoke` for affected PRs; each release rebuilds and pushes `ghcr.io/ldelements/search-api-server:` (`.github/workflows/docker.yml`). +The runtime stage is `gcr.io/distroless/nodejs24-debian12` – the Node binary +and the libraries it links, with no shell, no npm and no package manager – and +the install stage drops npm’s cache and every `*.map` and `*.d.ts` under +`node_modules`, none of which Node reads. Together they take about a tenth +off the compressed image. The missing shell is the +trade: `docker exec sh` no longer works, and the `HEALTHCHECK` +probes `/health` with `node -e` rather than `wget`. + ## Programmatic use The bin is a thin wrapper over the exported API, usable in tests or a custom diff --git a/docs/reference/search-indexer.md b/docs/reference/search-indexer.md index 3bb20156..124eb342 100644 --- a/docs/reference/search-indexer.md +++ b/docs/reference/search-indexer.md @@ -185,6 +185,15 @@ npx nx run @lde/search-indexer:docker:smoke # boots it with --check CI runs `docker:smoke` for affected PRs; each release rebuilds and pushes `ghcr.io/ldelements/search-indexer:` (`.github/workflows/docker.yml`). +The runtime stage is `gcr.io/distroless/nodejs24-debian12` – the Node binary +and the libraries it links, with no shell, no npm and no package manager – and +the install stage drops npm’s cache and every `*.map` and `*.d.ts` under +`node_modules`, none of which Node reads. Together they take about a third +off the compressed image. The missing shell is the +trade: `docker exec sh` no longer works, so inspect a running +container with `docker cp`, or mount its volumes into a shell-bearing image. +The entrypoint is the CLI itself, so `docker run … --check` reaches it. + ## Programmatic use The bin is a thin wrapper over the exported API, usable in tests or a custom diff --git a/packages/search-api-server/Dockerfile b/packages/search-api-server/Dockerfile index e3a63224..6088c825 100644 --- a/packages/search-api-server/Dockerfile +++ b/packages/search-api-server/Dockerfile @@ -6,8 +6,11 @@ # dist-docker/, so the image never waits on – or drifts from – an npm publish. # CI smoke-tests the running image (docker:smoke); releases push it to # ghcr.io/ldelements/search-api-server (.github/workflows/docker.yml). -FROM node:24-alpine -LABEL org.opencontainers.image.source="https://github.com/ldelements/lde" +# +# The install runs on Debian, not Alpine, because the runtime stage is a glibc +# distroless image: a musl-linked prebuilt binary picked up by an Alpine +# install would not load there. +FROM node:24-bookworm-slim AS install WORKDIR /app ENV NODE_ENV=production @@ -19,12 +22,41 @@ COPY dist-docker/package.json dist-docker/package-lock.json ./ # deliberately leaves them (and their SPARQL dependency tree) out. The # docker:smoke boot test guards this: a future value import would crash # startup with ERR_MODULE_NOT_FOUND. -RUN npm ci --omit=dev --legacy-peer-deps && npm cache clean --force +# +# The npm cache and every file Node never opens go in the same RUN as the +# install: a later step can only add a layer on top of the one that already +# carries them, never shrink it. That is source maps, type declarations and +# documentation – including the dependencies' licence files, which is a +# deliberate call, since the image redistributes their code. +RUN npm ci --omit=dev --legacy-peer-deps \ + && npm cache clean --force \ + && find node_modules -type f \( -name '*.map' -o -name '*.d.ts' -o -name '*.md' \ + -o -iname 'licen[sc]e*' -o -iname 'notice*' -o -iname 'copying*' \ + -o -iname 'readme*' -o -iname 'changelog*' -o -iname 'authors*' \) -delete # Lays the same-commit workspace modules over the registry-installed tree. COPY dist-docker/ ./ -USER node +# Only the Node binary and the libraries it links: no npm, no yarn, no shell, +# no package manager, and so nothing to patch or exploit outside Node itself. +FROM gcr.io/distroless/nodejs24-debian12 +LABEL org.opencontainers.image.source="https://github.com/ldelements/lde" + +WORKDIR /app +ENV NODE_ENV=production +# The base image leaves its Node outside PATH, so a Kubernetes manifest that +# overrides `command` with the usual `node …` would not find it. +ENV PATH="/nodejs/bin:${PATH}" + +# uid 1000 by number: distroless ships no `node` account. +COPY --from=install --chown=1000:1000 /app /app + +USER 1000:1000 EXPOSE 4000 -HEALTHCHECK --interval=30s --timeout=3s \ - CMD wget --quiet --output-document=- http://127.0.0.1:${PORT:-4000}/health || exit 1 -CMD ["node", "dist/cli.js"] +# There is no shell and no wget to call out to, so the probe is Node itself. +# It costs a Node start per probe, hence the timeout well above wget's 3s and +# a start period that keeps a slow boot on a throttled host from counting. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \ + CMD ["/nodejs/bin/node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || 4000) + '/health').then(response => process.exit(response.ok ? 0 : 1), () => process.exit(1))"] +# The base image’s entrypoint is the Node binary; naming the script here fixes +# the image’s command, which the server – it takes no arguments – wants anyway. +ENTRYPOINT ["/nodejs/bin/node", "dist/cli.js"] diff --git a/packages/search-indexer/Dockerfile b/packages/search-indexer/Dockerfile index 585c27f9..4d6827bd 100644 --- a/packages/search-indexer/Dockerfile +++ b/packages/search-indexer/Dockerfile @@ -6,8 +6,11 @@ # dist-docker/, so the image never waits on – or drifts from – an npm publish. # CI smoke-tests the running image (docker:smoke); releases push it to # ghcr.io/ldelements/search-indexer (.github/workflows/docker.yml). -FROM node:24-alpine -LABEL org.opencontainers.image.source="https://github.com/ldelements/lde" +# +# The install runs on Debian, not Alpine, because the runtime stage is a glibc +# distroless image: a musl-linked prebuilt binary picked up by an Alpine +# install would not load there. +FROM node:24-bookworm-slim AS install WORKDIR /app ENV NODE_ENV=production @@ -17,16 +20,46 @@ COPY dist-docker/package.json dist-docker/package-lock.json ./ # installs with, or `npm ci` rejects the lockfile over peers that step left # out (typesense declares a `@babel/runtime` peer no runtime import needs). # The docker:smoke boot test guards that nothing genuinely needed is missing. -RUN npm ci --omit=dev --legacy-peer-deps +# +# The npm cache and every file Node never opens go in the same RUN as the +# install: a later step can only add a layer on top of the one that already +# carries them, never shrink it. That is source maps, type declarations and +# documentation – including the dependencies' licence files, which is a +# deliberate call, since the image redistributes their code. +RUN npm ci --omit=dev --legacy-peer-deps \ + && npm cache clean --force \ + && find node_modules -type f \( -name '*.map' -o -name '*.d.ts' -o -name '*.md' \ + -o -iname 'licen[sc]e*' -o -iname 'notice*' -o -iname 'copying*' \ + -o -iname 'readme*' -o -iname 'changelog*' -o -iname 'authors*' \) -delete # Lays the same-commit workspace modules over the registry-installed tree. COPY dist-docker/ ./ # Default DATA_DIR for the QLever import path; a real deployment mounts a # volume here (and the same host path into the sibling QLever container). -# /provenance is the conventional home of PROVENANCE_FILE. Both are owned by -# the runtime user so a fresh named volume mounted there – which inherits the -# image directory’s ownership – is writable by uid 1000 (issue #661). -RUN mkdir -p /data /provenance && chown node:node /data /provenance +# /provenance is the conventional home of PROVENANCE_FILE. Both are created +# here and copied into the runtime stage, which has no shell to create them. +RUN mkdir -p /data /provenance + +# Only the Node binary and the libraries it links: no npm, no yarn, no shell, +# no package manager, and so nothing to patch or exploit outside Node itself. +FROM gcr.io/distroless/nodejs24-debian12 +LABEL org.opencontainers.image.source="https://github.com/ldelements/lde" + +WORKDIR /app +ENV NODE_ENV=production +# The base image leaves its Node outside PATH, so a Kubernetes manifest that +# overrides `command` with the usual `node …` would not find it. +ENV PATH="/nodejs/bin:${PATH}" + +# uid 1000 by number: distroless ships no `node` account, and the number is +# what a mount sees. Owning /data and /provenance keeps a fresh named volume +# mounted there – which inherits the image directory’s ownership – writable +# by the runtime user (issue #661). +COPY --from=install --chown=1000:1000 /app /app +COPY --from=install --chown=1000:1000 /data /data +COPY --from=install --chown=1000:1000 /provenance /provenance -USER node -CMD ["node", "dist/cli.js"] +USER 1000:1000 +# The base image’s entrypoint is the Node binary; naming the script here too +# means `docker run --check` reaches the CLI, instead of replacing it. +ENTRYPOINT ["/nodejs/bin/node", "dist/cli.js"] diff --git a/tools/docker-smoke-indexer.sh b/tools/docker-smoke-indexer.sh index df590dea..bd0b65eb 100755 --- a/tools/docker-smoke-indexer.sh +++ b/tools/docker-smoke-indexer.sh @@ -17,13 +17,14 @@ image="$1" # Dummy values: we test module resolution and startup, not behaviour. Nothing # connects during --check. The schema module ships into the container only for -# this smoke via the test fixture bind mount. +# this smoke via the test fixture bind mount. The image's entrypoint already +# names the CLI, so the arguments below are its flags alone. if output="$(docker run --rm \ -e REGISTRY_ENDPOINT=https://registry.invalid/sparql \ -e TYPESENSE_HOST=localhost \ -e TYPESENSE_API_KEY=dummy \ -v "$(pwd)/packages/search-indexer/test/fixtures/search-schema.mjs:/config/search-schema.mjs:ro" \ - "$image" node dist/cli.js --check 2>&1)"; then + "$image" --check 2>&1)"; then if echo "$output" | grep -q "configuration and schema module .* are valid"; then echo "PASS: $image booted, loaded the schema module and validated configuration" exit 0 diff --git a/tools/docker-smoke.sh b/tools/docker-smoke.sh index 4cbcc4ea..28da4245 100755 --- a/tools/docker-smoke.sh +++ b/tools/docker-smoke.sh @@ -20,7 +20,12 @@ trap cleanup EXIT # Typesense client connects lazily, so an unreachable host does not block boot. # The schema module ships in the image only for this smoke via the test # fixture bind mount. +# --health-interval shortens the image's 30s probe cycle for the test; the +# probe command itself is the image's own. It is worth asserting: the runtime +# stage has no shell, so a HEALTHCHECK that shells out fails silently, leaving +# a container that serves traffic while orchestrators read it as unhealthy. docker run -d --name "$container" -p 4000 \ + --health-interval=2s \ -e SCHEMA_MODULE=/config/search-schema.mjs \ -e TYPESENSE_HOST=localhost \ -e TYPESENSE_API_KEY=dummy \ @@ -54,7 +59,25 @@ for _ in $(seq 1 30); do echo "$sdl" | head -5 exit 1 fi - echo "PASS: $image reached startup and serves /health and /graphql?sdl" + # Wait for `healthy`, not merely for `starting` to end: a probe that times + # out on a loaded runner reports `unhealthy` and recovers on the next one. + # The `if .State.Health` guard keeps `docker inspect` from failing the + # whole script when the image has no HEALTHCHECK at all – which is the + # regression this assertion exists to catch. + health="" + for _ in $(seq 1 30); do + health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container")" + if [ "$health" = "healthy" ]; then + break + fi + sleep 1 + done + if [ "$health" != "healthy" ]; then + echo "FAIL: $image serves /health but its HEALTHCHECK reports ${health:-none}:" + docker inspect -f '{{json .State.Health}}' "$container" + exit 1 + fi + echo "PASS: $image reached startup, serves /health and /graphql?sdl, and reports healthy" exit 0 fi sleep 1