Repository navigation
perf(provider): cut the docker image from 1.84 GB to 376 MB (distroless) - #3322
Merged
Merged
Conversation
forgetso
force-pushed
the
perf/slim-provider-image
branch
2 times, most recently
from
September 22, 2026 10:06
9e1b2d5 to
3cce1ba
Compare
forgetso
force-pushed
the
perf/slim-provider-image
branch
2 times, most recently
from
September 22, 2026 10:46
eb45bfa to
d2d916b
Compare
node:24 is 1.63 GB -- 89% of the old image -- and ships a build toolchain this container never uses. The build is now two stages: a node:24-slim builder does everything needing a shell, apt or the network, and the runtime stage is distroless with the result copied in. Alpine was rejected rather than overlooked: all four native addons are linux-x64-gnu and musl needs separately compiled binaries. The distroless runtime is Debian 12, matching the builder. BREAKING for operators: no shell, no npm, no npx in the runtime image. npx was only human convenience; every call site now invokes node explicitly as `/nodejs/bin/node /usr/src/app/provider.cli.bundle.js <command>`. Healthchecks move from curl to `node -e "fetch(...)"`. `docker exec -it provider1 bash` no longer works. Updated here: the provider_image CI smoke test, both provider healthchecks in docker-compose.provider.yml, SELF_HOSTING.md and datasets-fs/README.md. The ansible playbooks and RUNBOOK are updated in captcha-private. Verified as a full stack: healthz ok, healthcheck healthy, MaxMind readers up, dataset loads, all three captcha types serve, no errors logged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forgetso
force-pushed
the
perf/slim-provider-image
branch
from
September 22, 2026 10:50
d2d916b to
a006053
Compare
forgetso
enabled auto-merge (squash)
September 22, 2026 10:51
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.
What changed
The provider image goes from 1.84 GB to 376 MB — a 80% cut — by making the build two-stage and moving the runtime to distroless.
node:24)node:24-slimWhy it was so large
It was never our code.
docker historyon the old image:node:24basenpm i sharpnode:24carries a full build toolchain — gcc, python, git, the apt cache — for a container that runs a pre-bundled JS file and installs one dependency from a prebuilt binary.A
node:24-slimbuilder now does everything needing a shell, apt or the network (install curl, fetch the GeoLite databases,npm i sharp), and the runtime stage isgcr.io/distroless/nodejs24-debian12with the result copied in. The three GeoLiteRUNs became one layer, and the npm cache is cleaned in the layer that creates it.Why not Alpine
It would save more and break everything. All four native addons are
linux-x64-**gnu**:Alpine is musl; each needs separately compiled binaries. The distroless runtime is Debian 12, matching the builder, so the glibc the addons were resolved against is the one that loads them.
Runs as non-root
The runtime stage is
gcr.io/distroless/nodejs24-debian12:nonrootwith an explicitUSER 65532:65532, and the copied application tree is chowned to that user. Closes captcha-private#4749.This has a hard deployment prerequisite. The provider writes at runtime to a host bind mount (
./data/detector-pool.N→/app/data/detector-pool) — the pool-replace endpoint doesmkdir+writeFile, andassignSecretdrops a0600secret alongside. Those directories are root-owned today. Tested against the built image:EACCES600preservedThat failure is silent — an unreadable pool is not an error,
getDetectorBundlePool()returns empty andrunEmptyDetectorPoolPowFallbackserves proof of work, so scoring would degrade fleet-wide with nothing in the logs.prosopo/captcha-private#4750 must be deployed and verified before this image reaches any pronode.
Self-hosted deployments are unaffected:
docker-compose.self-hosted.ymlmounts no pool directory, so the path is created inside the container by the runtime user. Privileged ports are a non-issue — Docker setsip_unprivileged_port_start=0, and the API listens on 9229 with Caddy fronting 80/443.There is no shell, no
npmand nonpxin the runtime image.npx provider <command>was only ever human convenience, so every call site is now an explicit node invocation:docker exec -it provider1 bashno longer works. That is the real cost of this change.Call sites updated in this PR:
provider_imageCI smoke test — ran/bin/sh -c '... npx provider version', which would have failed on the first builddocker/docker-compose.provider.yml—curl→node -e "fetch(...)"docker-compose.self-hosted.ymlSELF_HOSTING.mdandpackages/datasets-fs/README.mdUpdated alongside, outside this repo:
providerEnsureIndexes,providerLoadDataset,providerMigrateIps,providerUpdateSpamEmailDomainsplaybooks (all useddocker exec ... bash -c "npx provider ...") andRUNBOOK.mdI swept the repos for any remaining
bash/npx/curlassumption against this container and found none.docker-compose.provider-mock.ymlstill uses a curl healthcheck, correctly — that isprosopo/provider-mock, a different image built fromdemos/provider-mock/Dockerfile.Test coverage
No unit tests — it's a Dockerfile. Built all three variants from the same bundle and ran the distroless one as a full stack against Mongo and Redis:
/healthzreturns ok; the node-based container healthcheck reportshealthyIpInfoService initializedversion,ensure_indexes,provider_set_data_set(dataset loads),site_key_registerpow,puzzle(29 KB WebP returned, sosharprenders), andimagefrom a loaded dataset withsignaturepresenterrororfatallines in the provider log across the whole run600Left for later
Moving the GeoLite2 databases to a mounted volume would take this to ~290 MB, but operators would then have to supply them — a separate operational change.
Below that means dropping the Node runtime: the
nodebinary alone is 120.7 MB (125.3 MB on musl), so 50–100 MB is not reachable without a rewrite in a compiled language.🤖 Generated with Claude Code