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: 8 additions & 0 deletions docs/reference/search-api-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>` (`.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 <container> 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
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/search-indexer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>` (`.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 <container> 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
Expand Down
46 changes: 39 additions & 7 deletions packages/search-api-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
51 changes: 42 additions & 9 deletions packages/search-indexer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <image> --check` reaches the CLI, instead of replacing it.
ENTRYPOINT ["/nodejs/bin/node", "dist/cli.js"]
5 changes: 3 additions & 2 deletions tools/docker-smoke-indexer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion tools/docker-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down