build(docker): switch runtime image to distroless static - #4452
build(docker): switch runtime image to distroless static#4452stevenvegt wants to merge 5 commits into
Conversation
main() logged errors returned by cmd.Execute but always exited with status 0, so failures of client commands (e.g. 'nuts status' against a node that is down) were invisible to scripts and to the Docker healthcheck. The server command is unaffected: it reports startup errors via logrus.Fatal, which already exits 1. Assisted-by: AI
The alpine-based runtime image accumulated known-fixed CVEs between releases (openssl, musl, zlib) because pre-installed packages were never upgraded and the image is only rebuilt on release. The nuts binary is pure static Go and only needed alpine for tzdata and curl: distroless static ships CA certificates and tzdata, and the curl-based healthcheck is replaced by the existing 'nuts status' client command, which probes the internal API without needing a shell. Assisted-by: AI
❌ 1 blocking issue (1 total)
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (1)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
The '.*' pattern in .dockerignore also matches the literal path '.', so BuildKit's CopyIgnoredFile check flagged 'COPY . .' as copying an excluded file. '.?*' requires at least one character after the dot, which excludes the same set of dotfiles without matching the context root. Assisted-by: AI
Distroless publishes no version tags, only 'latest' and variant tags, so pinning means pinning the multi-arch index digest. Dependabot's docker ecosystem (already configured) keeps the digest updated via weekly PRs, so base image updates become explicit and reviewable instead of implicit at build time. Assisted-by: AI
The distroless runtime image contains no shell or userland, so tests can no longer exec curl or rm inside the node container. Deleting connections.db now happens on the host (the data dirs are bind mounts and the services run as the host user), and HTTP calls that need the compose network run in a dedicated curlimages/curl helper service, gated behind the 'tools' profile so 'up' does not start it. The rfc002 helper mounts nodeB's client certificate to keep acting as nodeB. Assisted-by: AI
|
What operational habits change? If things are really breaking, we might have to schedule this for v7. We also have a development image; if we stop supporting derived images, we also need to formally retire that. T.b.h., I use |

Problem
Our runtime image is based on a Linux distribution (alpine). A distro ships packages, and packages become outdated: our images are only rebuilt on release, so anything installed in the image slowly drifts behind and can accumulate known problems until the next release. This is what image scanners (Docker Hub among them) periodically flag.
We don't actually need those packages. The nuts binary is a pure static Go build (no cgo, pure-Go sqlite). The distro is only in the image for two things: tzdata (the auth module renders login contracts in the Europe/Amsterdam timezone) and curl (the Docker HEALTHCHECK).
Proposal: go distroless
Base the runtime image on
gcr.io/distroless/static-debian13. It contains CA certificates and tzdata out of the box, so both remaining needs are covered, but no shell, package manager, or other userland.Distroless publishes no version tags (only
latestand variant tags), so the base is pinned by its multi-arch index digest. Dependabot's docker ecosystem (already configured for the Dockerfile) bumps the digest in weekly PRs, making base image updates explicit and reviewable instead of implicit at build time.The one thing that needs a new solution is the HEALTHCHECK, since there is no curl or shell to run it with. This PR uses the existing
nuts statusclient command in exec form:CMD ["/usr/bin/nuts", "status"]. It performs an HTTP GET on the internal API and exits non-zero on failure. This surfaced a bug:main()logged command errors but always exited 0, so any failure was invisible to Docker (and to scripts). The first commit fixes that.Perspective: distroless vs upgrading packages
The alternative fix is to keep alpine and add
apk -U upgrade --no-cacheto the runtime stage, so every build picks up current packages.What distroless buys us:
What we sacrifice:
docker exec <container> shno longer works for live debugging. Alternatives:docker debug, Kubernetes ephemeral debug containers.FROM nutsfoundation/nuts-node+RUN apk add ...), for example to add a private CA, will break. Alternatives: bind-mount over/etc/ssl/certs/ca-certificates.crt, or setSSL_CERT_FILE/SSL_CERT_DIR(honored by Go).For the supported 5.x and 6.x release branches the
apk upgradevariant remains the right fix: it is a rebuild-only change with no behavioral impact, suitable for patch releases.What's in this PR
fix(cli): exit with status 1 when a command fails. The server command is unaffected; it reports startup errors vialogrus.Fataland graceful shutdown still exits 0.build(docker): switch the runtime stage to distroless static with thenuts statushealthcheck. NumericUSER 18081:18081is kept, so volume ownership semantics do not change.build(docker): fix the long-standingCopyIgnoredFilebuild check warning: the.*pattern in.dockerignorealso matches the literal path., so BuildKit flaggedCOPY . .as copying an excluded file..?*excludes the same dotfiles without matching the context root.docker build --check .is now clean.build(docker): pin the distroless base image by digest (see above).test(e2e): a few tests exec'dcurlorrminside the node container, which no longer works. File deletion moved to the host (the data dirs are bind mounts), and HTTP calls that need the compose network now run in a dedicatedcurlimages/curlhelper service, gated behind atoolsprofile soupdoes not start it. All affected tests (openid4vp, rfc002, rfc021, private-transactions, network-issuance) pass locally against the distroless image.Verification
starting->healthyonce the node is up; probes exit 1 while it is down;docker stopexits 0./etc/ssl/certs/ca-certificates.crt.Europe/Amsterdamzoneinfo is present.intervaland inherit the image's healthcheck command, so they work unchanged.go test ./cmd/... ./core/status/...passes.Notes
/status/diagnostics(vianuts status) instead of/status, and its output is stored in the Docker health log. A dedicated lighternuts healthchecksubcommand probing/statusis a possible follow-up.Assisted-by: AI