Skip to content

build(docker): run the search images on distroless and strip the install layer - #749

Merged
ddeboer merged 1 commit into
mainfrom
worktree-issue-748-image-size
Aug 20, 2026
Merged

build(docker): run the search images on distroless and strip the install layer#749
ddeboer merged 1 commit into
mainfrom
worktree-issue-748-image-size

Conversation

@ddeboer

@ddeboer ddeboer commented Aug 20, 2026

Copy link
Copy Markdown
Member

Fix #748.

Items 1–3 of the issue. Measured with docker save | gzip -6, on arm64 (the
issue’s numbers are amd64):

before after
search-indexer 93.2 MB 63.5 MB
search-api-server 66.1 MB 59.8 MB

The install layer

Both images now clean the npm cache and delete every file Node never opens
under node_modules in the same RUN as the install – a later step could
only add a layer on top of the one carrying those bytes, never shrink it.

The weight is in source maps (19 MB uncompressed in the indexer’s tree) and
type declarations (18 MB): dropping those two alone takes the indexer from
72.1 to 64.4 MB compressed. Documentation and licence files add 3.0 MB
uncompressed, 0.9 MB compressed.

Note that this includes the dependencies’ licence files, and that is a
deliberate call rather than an oversight: MIT, BSD and Apache-2.0 all ask for
the notice to travel with redistributed code, and publishing to ghcr.io is
redistribution. It is 282 files and 0.42 MB in the indexer’s tree. Happy to
put them back, or to generate a single aggregated THIRD-PARTY-NOTICES at
build time instead, if we would rather stay on the safe side.

npm has no built-in for this – --omit=dev and npm prune work at package
granularity, and nothing in npm removes files within an installed package.
Yarn 1 had exactly this feature (.yarnclean) and Yarn 2 dropped it. The
packaged equivalents, node-prune and modclean, were both last published in
2022; a find avoids taking an unmaintained build dependency for one line.

Distroless runtime

The runtime stage is gcr.io/distroless/nodejs24-debian12 – the Node binary
and the libraries it links, with no npm, yarn, shell or package manager. The
install stage moves from node:24-alpine to node:24-bookworm-slim to match
the runtime’s libc: a musl-linked prebuilt binary picked up by an Alpine
install would not load in a glibc runtime.

Consequences handled here:

  • No shell. HEALTHCHECK probes /health with node -e instead of
    wget, in exec form. docker-smoke.sh now asserts the container actually
    reports healthy (with --health-interval=2s so the test does not wait out
    the image’s 30s cycle) – otherwise a broken shell-less healthcheck fails
    silently, leaving a container that serves traffic while orchestrators read
    it as unhealthy.
  • Node outside PATH. The base image keeps its Node in /nodejs/bin,
    which is not on PATH, so a Kubernetes manifest overriding command with
    the usual ["node", "dist/cli.js"] would fail to exec. Both images prepend
    it.
  • No mkdir at runtime. /data and /provenance are created in the
    install stage and copied over with --chown=1000:1000, keeping the
    named-volume ownership behaviour of search-indexer image (uid 1000) silently fails to write PROVENANCE_FILE to a root-owned dir, disabling skip-unchanged #661.
  • No node account. USER 1000:1000 by number – uid 1000 is what a mount
    sees, and it is what the docs already document.
  • Entrypoint. The base image’s entrypoint is the Node binary; naming the
    CLI there too means docker run <image> --check reaches the indexer instead
    of replacing it. docker-smoke-indexer.sh drops its node dist/cli.js
    prefix accordingly.

Breaking for image consumers. With the CLI as the entrypoint, a caller that
passed node dist/cli.js as the container command – compose command:, or
docker run <image> node dist/cli.js --check, which is what
docker-smoke-indexer.sh itself did before this commit – now hands those two
strings to the CLI as arguments, and the indexer takes them for dataset IRIs
rather than failing loudly. Nothing in this repo still does it; the commit
carries a BREAKING CHANGE: footer so it reaches the changelog. Kubernetes
command: overrides are fine, given the PATH fix above.

The HEALTHCHECK runs on a 10s timeout and a 30s start period rather than
wget’s 3s: a Node start costs 0.5–2.5s on a CPU-throttled host, and at
--cpus=0.1 it exceeded 3s outright, which would have flipped a healthy
container to unhealthy on a constrained deployment.

Logs are unaffected – both CLIs write to stdout/stderr, which the container
runtime captures as before – and so is any OpenTelemetry instrumentation,
which is library code plus NODE_OPTIONS. What goes is
kubectl exec … -- sh; kubectl debug with an ephemeral container replaces
it. Documented in both reference pages.

Not in this PR

The dependency-tree items from the issue (rdf-parse in
@lde/distribution-probe, dockerode in @lde/task-runner-docker).
The issue’s third bullet – jsonld-context-parser declaring @types/node as
a runtime dependency – turns out not to be a bug: its published
FetchDocumentLoader.d.ts exposes RequestInit and Response, so a consumer
compiling without the DOM lib needs it. It costs these images nothing anyway,
being all .d.ts.

@ddeboer
ddeboer force-pushed the worktree-issue-748-image-size branch 3 times, most recently from 0c7ebc1 to fc50269 Compare August 20, 2026 11:06
…all layer

- 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 <image> --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.
@ddeboer
ddeboer force-pushed the worktree-issue-748-image-size branch from fc50269 to 6693ed4 Compare August 20, 2026 11:19
@ddeboer
ddeboer merged commit c1ffb55 into main Aug 20, 2026
4 checks passed
@ddeboer
ddeboer deleted the worktree-issue-748-image-size branch August 20, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shrink the search-indexer and search-api-server images: npm cache and type/map files in the install layer

1 participant