Skip to content

containers: switch environmentd and clusterd to a distroless base image - #37526

Merged
jasonhernandez merged 14 commits into
mainfrom
jason/distroless-debug-999
Jul 13, 2026
Merged

containers: switch environmentd and clusterd to a distroless base image#37526
jasonhernandez merged 14 commits into
mainfrom
jason/distroless-debug-999

Conversation

@jasonhernandez

@jasonhernandez jasonhernandez commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Migrate the environmentd and clusterd container images from Ubuntu to a distroless base, shrinking the image and its attack surface.
This is a lighter approach compared to #36099 / #36101 / #36876 which attempted to entirely remove the shell. I've closed those.


We keep a shell, but it becomes busybox and we aim for pure POSIX / sh compatibility instead of implicitly depending on bash.

Base image

  • Uses gcr.io/distroless/cc-debian13:debug-nonroot. The debug variant ships a busybox shell at /busybox/sh. Keeping a shell lets entrypoint.sh and kubectl exec continue to work. This is a deliberate, incremental step off Ubuntu; dropping the shell is a possible future hardening.
  • Runs as a materialize user with uid/gid 999, created in a Debian builder stage and copied into the final image (distroless has no shell to run useradd). uid/gid 999 matches the pod securityContext the orchestrator already applies (runAsUser/runAsGroup/fsGroup = 999), so nothing on the orchestrator side changes and existing uid-999 persistent volumes need no re-chown.

Runtime dependencies copied in

The distroless base is minimal, so the image explicitly provides what the binaries need at runtime:

  • static tini as PID 1, to forward signals and reap zombies (e.g. ssh-tunnel subprocesses).
  • static ssh, since distroless ships none and SSH tunnels require it. (busybox includes some SSH features but we prefer to use openssh built with aws-lc for eventual FIPS support + stronger cryptographic foundations)
  • libfdb_c.so (FoundationDB) and liblzma.so.5 (xz), both dynamically linked by the binaries. Without liblzma they abort at startup with error while loading shared libraries.
  • a nsswitch.conf using the files/dns backends, which avoids a glibc compat-backend segfault in the statically-linked ssh.

This might look ugly, but we know what we're shipping vs. the entire Ubuntu userspace!

Entrypoint

entrypoint.sh (for both binaries) is POSIX sh: #!/bin/sh, set -eu, and hostname -f (works under both busybox and GNU). The Dockerfiles invoke it as /busybox/sh entrypoint.sh, since distroless has no /bin/sh. Under Kubernetes it advertises the pod FQDN for the CTP peer check and derives the process ordinal from the StatefulSet pod name.

Scope

Container layer only, no product-code changes: image definitions, the two entrypoints, cloudtest adjustments (pod processes are signalled via kubectl exec … sh -c 'kill'), and dependabot coverage for the new base images.

Test plan

  • Standalone image on the pinned base runs as uid 999 with HOME=/home/materialize; the entrypoint executes end-to-end and hostname -f resolves.
  • CI nightly cloudtest / orchestratord / k8s-recovery jobs pass: managed replicas come up healthy as uid 999 and the CTP check passes against the in-cluster pod FQDN. (One Cloudtest shard flakes independently of this change; it also fails on main.)

@jasonhernandez
jasonhernandez force-pushed the jason/distroless-debug-999 branch 2 times, most recently from f64cc90 to 38ea068 Compare July 9, 2026 05:55
jasonhernandez and others added 12 commits July 8, 2026 23:27
Replace Debian-based prod images with distroless. Remove shell
entrypoint scripts (no shell in distroless). Add libeatmydata to
distroless-prod-base for CI compatibility. Update clusterd mzcompose
service to drop shell-dependent options.

Distroless ships no init, so add a tini-static mzbuild image (static
tini built from source, mirroring openssh-static) and copy it into
distroless-prod-base. tini stays PID 1 to forward signals and reap
zombies, notably the ssh subprocesses spawned by the SSH tunnel
feature. Leaf images wrap their binary in ENTRYPOINT ["/usr/bin/tini",
"--", ...].

The deleted environmentd entrypoint slept forever on graceful exit so a
fenced-out generation would not be restarted. Distroless has no shell to
do this, and a Kubernetes StatefulSet (restartPolicy: Always) would
crash-loop a process that exits. Replace it with an in-process idle:
when fenced out and supervised directly by Kubernetes, environmentd
idles until the orchestrator deletes the StatefulSet (SIGTERM, then
SIGKILL) instead of calling exit!(0). The all-in-one image keeps its
entrypoint and exit-0 behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nightly cloudtest and orchestratord runs crash-looped every distroless
environmentd/clusterd pod with:

  error while loading shared libraries: libfdb_c.so: cannot open shared
  object file: No such file or directory

ubuntu-base has copied libfdb_c.so from the foundationdb image since
FoundationDB consensus/timestamp-oracle support landed (#33819), which
postdates the original distroless base. Copy it the same way, and carry
over ubuntu-base's RUST_BACKTRACE/RUST_LIB_BACKTRACE settings so crashes
in distroless images print backtraces too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_crash_storage and the secrets restart test killed clusterd with
kubectl exec ... bash -c 'kill -9 `pidof clusterd`'. Distroless clusterd
images ship no shell, so the exec fails with exit 1 and the tests fail.
Force-delete the pod instead (SIGKILL, no grace period), matching the
crash-simulation idiom in test_replica_restart. The StatefulSet recreates
the pod under the same name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The distroless base ships glibc's example nsswitch.conf, which routes
passwd/group lookups through the `compat` NSS backend. `compat` is not
built into glibc, so a lookup dlopens the image's libnss_compat.so.2.
The statically-linked `ssh` embeds a different glibc than that module
was built for, and the mismatch jumps through a garbage pointer:

  audit: ANOM_ABEND comm="ssh" exe="/usr/bin/ssh" sig=11

which the SSH tunnel manager surfaced as "failed to connect to the
remote host: No such file or directory" in the Nightly ssh tunnel
cloudtest. Ship a files/dns nsswitch.conf instead. Those backends are
built into glibc (2.33+), so lookups never dlopen, for the static ssh
and the dynamically-linked Rust binaries alike.

Verified by rebuilding distroless-prod-base and running the CI-built
static ssh inside it as uid 65532 and 65534 (`ssh -G` exits 0, was
SIGSEGV).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same no-shell-in-distroless problem as the clusterd kills: replace the
exec'd shell kill of environmentd with a force pod delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Distroless environmentd/clusterd images ship no shell, so
kubectl exec ... bash -c 'kill ...' cannot work. Add
signal_process_in_pod, which execs into the kind node hosting the pod
(a Docker container with a full userland) and signals the process
found via crictl. This keeps the tests' original semantics: the
process dies or suspends in place and the container restarts within
the same pod, unlike a pod delete. It also covers the shared-fate
SIGSTOP/SIGCONT cases, which simulate a hung peer and cannot be
expressed as a pod delete at all.

The helper finds the target among the container's init and its direct
children, covering both distroless images (tini is init, target is its
child) and Ubuntu-based images (the entrypoint execs the target).

Convert test_crash, test_secrets, and both shared-fate suites to the
helper, including reverting the earlier pod-delete workaround.
Mechanics verified against a local kind cluster (crictl discovery,
comm matching, kill -s from the node).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new tini-static image was pinned to ubuntu:noble-20260210.1, the
stale base copied from openssh-static before #37354 bumped it. Match the
repo's current base (noble-20260610, per ubuntu-base #37449). Build-stage
only; the shipped artifact is the static tini binary from a FROM scratch
stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match ubuntu-base and tini-static at noble-20260610 (openssh-static was
one dependabot cycle behind at noble-20260509.1). Aligning all static
builder bases to one tag means the distroless build pulls a single ubuntu
image rather than several. Build-stage only; the artifact is the static
ssh binary from a FROM scratch stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tini-static and distroless-prod-base are new images introduced by this
migration. Register both with the docker dependabot ecosystem so their
base-image pins (ubuntu/debian/gcr.io distroless) stay current on the
same weekly cadence as ubuntu-base and openssh-static, rather than
drifting until someone notices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…prod-base

The libfdb_c.so tag must stay in lockstep with the identical COPY in
ubuntu-base and the foundationdb crate's fdb-7_3 API feature. Spell that
out so a dependabot bump to one of the three gets recognized as a
drift bug in review rather than merged as routine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lint suite requires a copyright header on config files; the
nsswitch.conf added for the static-ssh fix was missing one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft variant of the distroless migration that keeps a shell and matches the
orchestrator's uid, as an incremental step off the Ubuntu base.

- distroless-prod-base: switch to the `debug-nonroot` variant of the same
  distroless build the nonroot pin tracked, which ships a busybox shell at
  /busybox/sh. Create a `materialize` user at uid/gid 999 in a Debian builder
  stage and copy its passwd/group/home into the final image, so the process
  uid matches the orchestrator's hardcoded runAsUser/fsGroup and no
  orchestrator UID gate is needed.
- Restore src/clusterd/ci/entrypoint.sh and src/environmentd/ci/entrypoint.sh,
  ported to busybox ash (#!/busybox/sh, `hostname -f`, POSIX `[`). The leaf
  Dockerfiles run them under tini again.
- Revert the cloudtest kill helpers to shelling into the pod, now that the
  image has a shell, using `sh` (busybox) rather than `bash`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ztDqVCrRgMdtjJxvPWa4B
@jasonhernandez
jasonhernandez force-pushed the jason/distroless-debug-999 branch from 38ea068 to b45239c Compare July 9, 2026 06:29
@jasonhernandez jasonhernandez added the ci-nightly PR CI control: also trigger Nightly label Jul 9, 2026
@jasonhernandez
jasonhernandez marked this pull request as ready for review July 9, 2026 06:33
@jasonhernandez
jasonhernandez requested review from a team as code owners July 9, 2026 06:33
@jasonhernandez jasonhernandez changed the title containers: distroless-debug base for environmentd/clusterd, running as uid 999 (draft, single-PR alt) containers: distroless-debug base for environmentd/clusterd, running as uid 999 (single-PR alt) Jul 9, 2026
jasonhernandez and others added 2 commits July 8, 2026 23:34
Empty commit to regenerate the CI pipeline now that the PR is ready and
labeled ci-nightly, so the image-triggered nightly (cloudtest / orchestratord /
k8s) runs against the 999 debug image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
environmentd and clusterd dynamically link liblzma.so.5 (xz), which the
distroless base does not include (ubuntu-base gets it implicitly as a system
package). Without it both binaries abort at startup with
"error while loading shared libraries: liblzma.so.5: cannot open shared object
file", so every pod crash-loops and all orchestratord/cloudtest/k8s nightly
jobs fail. Copy it from the Debian stage, matching the ubuntu-base runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jasonhernandez jasonhernandez changed the title containers: distroless-debug base for environmentd/clusterd, running as uid 999 (single-PR alt) containers: switch environmentd and clusterd to a distroless base image Jul 9, 2026
@jasonhernandez

Copy link
Copy Markdown
Contributor Author

FYI I also have a path to shrink the discrepancy between materialized and these repos - moving materialized to debian:13-slim is feasible with a bump to postgres 17.

@def-

def- commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

How does this relate to #36099 ?

Copy link
Copy Markdown
Contributor Author

I'm closing #36099 and its related PRs - this will supersede it as a simpler, incremental move to dramatically slim down our footprint. I think removing the shell requires more significant work that I'm leaving out of scope for now.

@def- def- left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If CI is happy, I'm happy. I don't know if this causes any downstream effects for the Cloud team though.

@jasonhernandez
jasonhernandez merged commit f7d2c38 into main Jul 13, 2026
354 of 356 checks passed
@jasonhernandez
jasonhernandez deleted the jason/distroless-debug-999 branch July 13, 2026 16:25
@def-

def- commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

CI was not happy! https://buildkite.com/materialize/nightly/builds/17100 failed in Cloudtest, related to this PR.
Edit: I'll try to fix

@def-

def- commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Terraform and Canary Deploy in Staging Cloud also seem to fail in main related to this. I'm trying to get it fixed: https://buildkite.com/materialize/nightly/builds/17334

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-nightly PR CI control: also trigger Nightly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants