build(docker): run the search images on distroless and strip the install layer - #749
Merged
Conversation
ddeboer
force-pushed
the
worktree-issue-748-image-size
branch
3 times, most recently
from
August 20, 2026 11:06
0c7ebc1 to
fc50269
Compare
…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
force-pushed
the
worktree-issue-748-image-size
branch
from
August 20, 2026 11:19
fc50269 to
6693ed4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix #748.
Items 1–3 of the issue. Measured with
docker save | gzip -6, on arm64 (theissue’s numbers are amd64):
The install layer
Both images now clean the npm cache and delete every file Node never opens
under
node_modulesin the sameRUNas the install – a later step couldonly 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-NOTICESatbuild time instead, if we would rather stay on the safe side.
npm has no built-in for this –
--omit=devandnpm prunework at packagegranularity, and nothing in npm removes files within an installed package.
Yarn 1 had exactly this feature (
.yarnclean) and Yarn 2 dropped it. Thepackaged equivalents,
node-pruneandmodclean, were both last published in2022; a
findavoids taking an unmaintained build dependency for one line.Distroless runtime
The runtime stage is
gcr.io/distroless/nodejs24-debian12– the Node binaryand the libraries it links, with no npm, yarn, shell or package manager. The
install stage moves from
node:24-alpinetonode:24-bookworm-slimto matchthe runtime’s libc: a musl-linked prebuilt binary picked up by an Alpine
install would not load in a glibc runtime.
Consequences handled here:
HEALTHCHECKprobes/healthwithnode -einstead ofwget, in exec form.docker-smoke.shnow asserts the container actuallyreports
healthy(with--health-interval=2sso the test does not wait outthe image’s 30s cycle) – otherwise a broken shell-less healthcheck fails
silently, leaving a container that serves traffic while orchestrators read
it as unhealthy.
PATH. The base image keeps its Node in/nodejs/bin,which is not on
PATH, so a Kubernetes manifest overridingcommandwiththe usual
["node", "dist/cli.js"]would fail to exec. Both images prependit.
mkdirat runtime./dataand/provenanceare created in theinstall stage and copied over with
--chown=1000:1000, keeping thenamed-volume ownership behaviour of search-indexer image (uid 1000) silently fails to write PROVENANCE_FILE to a root-owned dir, disabling skip-unchanged #661.
nodeaccount.USER 1000:1000by number – uid 1000 is what a mountsees, and it is what the docs already document.
CLI there too means
docker run <image> --checkreaches the indexer insteadof replacing it.
docker-smoke-indexer.shdrops itsnode dist/cli.jsprefix accordingly.
Breaking for image consumers. With the CLI as the entrypoint, a caller that
passed
node dist/cli.jsas the container command – composecommand:, ordocker run <image> node dist/cli.js --check, which is whatdocker-smoke-indexer.shitself did before this commit – now hands those twostrings 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. Kubernetescommand:overrides are fine, given thePATHfix above.The
HEALTHCHECKruns on a 10s timeout and a 30s start period rather thanwget’s 3s: a Node start costs 0.5–2.5s on a CPU-throttled host, and at--cpus=0.1it exceeded 3s outright, which would have flipped a healthycontainer to
unhealthyon 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 iskubectl exec … -- sh;kubectl debugwith an ephemeral container replacesit. Documented in both reference pages.
Not in this PR
The dependency-tree items from the issue (
rdf-parsein@lde/distribution-probe,dockerodein@lde/task-runner-docker).The issue’s third bullet –
jsonld-context-parserdeclaring@types/nodeasa runtime dependency – turns out not to be a bug: its published
FetchDocumentLoader.d.tsexposesRequestInitandResponse, so a consumercompiling without the DOM lib needs it. It costs these images nothing anyway,
being all
.d.ts.