feat(telegraf): Add distroless container based on scratch base-image#892
feat(telegraf): Add distroless container based on scratch base-image#892victor-gama wants to merge 1 commit into
Conversation
srebhan
left a comment
There was a problem hiding this comment.
Thanks a lot for your work @victor-gama!
I don't think we need the staging approach as we can utilize the docker commands to get, check and unpack the released Telegraf binary.
Furthermore, I would really love to learn on how the circle-test.sh approach works. Do we need to execute this manually or is this something auto-executed?
Please also test at least the following:
- use a plugin to connect to an https endpoint for verifying TLS works
- use a plugin to parse times with timezone information to make sure the timezone-info is available
53f8cf0 to
d81672b
Compare
dece662 to
d587fc1
Compare
srebhan
left a comment
There was a problem hiding this comment.
Thanks for the update @victor-gama! The PR looks mostly good, I have some small comments/questions...
Furthermore, can you please remove everything except v1.39!? I'm not planning to add this for older versions...
Another point are the testing scripts. Can we please remove them as we don't have those anywhere else and I don't want to add to credit consumption here...
d587fc1 to
caf14d7
Compare
caf14d7 to
37369f2
Compare
| # unmapped userns id -> overflow: https://man7.org/linux/man-pages/man7/user_namespaces.7.html | ||
| # distroless nonroot = 65532: https://github.com/GoogleContainerTools/distroless/blob/main/common/variables.bzl | ||
| # (nobody is kept in the map so 65534-owned files still resolve; we just never run as it.) | ||
| printf 'root:x:0:0:root:/root:/sbin/nologin\nnobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin\nnonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin\n' > /rootfs/etc/passwd; \ |
There was a problem hiding this comment.
I would name the uid 65532 entry "telegraf" rather than "nonroot" (keeping the numeric USER and uid exactly as they are). The name is about to become a contract: procstat's user tag, anything else resolving the uid, and our docs all say "telegraf" in the other variants, and renaming after release is a visible behavior change. "nonroot" buys familiarity with the distroless ecosystem, but for a telegraf image I think parity with the siblings wins. Happy to be argued out of it, but let's decide now rather than after the tag exists. @srebhan please let me know you thoughts on this.
There was a problem hiding this comment.
I would also prefer telegraf if this doesn't collide with k8s conventions of "non-root".
| # abort under set -eux. A numeric id skips the passwd lookup entirely. | ||
| # busybox chown name lookup + err string (xget_uidgid; mirror linked from busybox.net/source.html): | ||
| # https://github.com/vda-linux/busybox_mirror/blob/ec0c5cc142f1f9ea57235df5d093fbe180ad9c7d/libpwdgrp/uidgid_get.c#L77-L80 | ||
| mkdir -p /rootfs/home/nonroot /rootfs/tmp; chown 65532:65532 /rootfs/home/nonroot /rootfs/tmp |
There was a problem hiding this comment.
Worth a note in the image docs: /tmp and /home/nonroot are writable only by uid 65532, so runtimes that assign an arbitrary uid (OpenShift's default SCC, an explicit runAsUser) get no writable /tmp or HOME, unlike the siblings whose base images ship /tmp as 1777. Given the buildah mode-preservation caveat you documented, I think documenting the constraint is the right call rather than fighting it, but it should be written down where users will find it.
There was a problem hiding this comment.
The question is really: How about for buildx? If any of those does preserve the mode I would set it.
There was a problem hiding this comment.
buildx does preserve it, mode and ownership both. I built the same shape the Dockerfile uses:
FROM alpine:3.23 AS fetch
RUN mkdir -p /rootfs/tmp /rootfs/home/nonroot; \
chmod 1777 /rootfs/tmp; chmod 0750 /rootfs/home/nonroot; \
chown 65532:65532 /rootfs/tmp /rootfs/home/nonroot
FROM scratch
COPY --from=fetch /rootfs/ /
Exporting the final image (buildx v0.25.0) gives:
drwxrwxrwt 65532 65532 tmp/
drwxr-x--- 65532 65532 home/nonroot/
So the sticky bit survives, and since official-images publishes through BuildKit that is the path that matters here. Worth setting chmod 1777 on /rootfs/tmp: it takes effect under buildx, and under buildah it is a no-op that costs nothing.
That also closes something I was going to raise separately. As it stands /tmp is writable only by uid 65532, so a runtime that assigns an arbitrary uid (OpenShift's default SCC, an explicit runAsUser) gets no writable /tmp, unlike the alpine and debian variants whose bases ship /tmp as 1777. With the mode set, that difference goes away rather than needing a caveat in the docs.
I only verified the buildx half, so I can't speak to whether the buildah behavior in the comment is accurate. Either way the comment needs a reword, since as written it reads as "mode bits do not survive COPY" in general when it is specific to the builder we do not publish with.
Add a
distrolessTelegraf variant (1.39)This adds an opt-in
distrolessvariant for Telegraf 1.39, producingtelegraf:1.39-distroless. The image is builtFROM scratchand ships only the statictelegrafbinary plus the handful of files it needs at runtime (no shell, no libc, no package manager, no OS userland), and runs non-root (uid65532). The change is purely additive: the existingdefaultandalpineimages are untouched.What to review
telegraf/1.39/distroless/Dockerfilefetch→scratch). The heart of the PR.telegraf/manifest.jsondistrolessfor1.39onamd64+arm64v8.1.39-distrolessmaps to Telegraf release 1.39.1 (ENV TELEGRAF_VERSION 1.39.1).How the Dockerfile works
It's a two-stage build:
fetchstage downloads the release withwget, GPG-verifies the tarball against InfluxData's signing key (24C975CBA61A024EE1B631787C3D57159FC2F927— the same key thedefaultandalpinesiblings use), unpacks it, and assembles/rootfsas the exact final image tree (binary + config + CA bundle + tzdata +nsswitch.conf+passwd/group+home/tmp).scratchstage brings that tree over with a single plainCOPY --from=fetch /rootfs/ /— noADD --unpack, noCOPY --parents, no other BuildKit-only feature — so it isn't tied to any one builder.gpg --batch --verifycheck in the fetch stage; the build runs underset -euxand fails loudly if verification fails. Nothing from alpine reaches the final image except the files explicitly staged under/rootfs.scratch, so there are no OS packages to track or bump.What the final image contains: the complete inventory (everything a distro base would add is deliberately omitted)
/usr/bin/telegrafCGO_ENABLED=0) binary/etc/telegraf/{telegraf.conf,telegraf.d}/etc/ssl/certs/ca-certificates.crtinfluxdb_v2)/usr/share/zoneinfotime.LoadLocation,json_timezone, cron schedules/etc/nsswitch.confhosts: files dnsso Go's resolver checks/etc/hostsbefore DNS/etc/passwd,/etc/grouproot,nobody(65534),nonroot(65532)/home/nonroot(owned 65532)$HOMEfor uid 65532/tmp(owned 65532)os.TempDir()default;scratchships neitherNot a drop-in replacement
This is a hardening-focused variant, not a swap-in for the
default/alpineimages. Because there is no shell and nosetcap/setpriv, it does not support the ICMPpinginput, privileged ports (below 1024), or shell-out plugins likeexec,snmp, andsensors. It targets network-listener and push workloads. The Dockerfile header notes the image ships no shell or OS userland — the root cause of these limitations.Why
scratch, and why a tag not a digestMost CVEs flagged against the
default/alpineimages come from the parent image's userland (distro packages, the shell, coreutils) rather than from Telegraf itself (e.g. CVE-2026-48962), which is why we periodically swap between the Debian- and Alpine-based images to stay ahead of scanners. BuildingFROM scratchdrops that whole layer, so CVE exposure comes down to the Telegraf binary plus a maintained CA bundle and tzdata. The image also runs non-root, so it satisfies KubernetesrunAsNonRootout of the box.Two integrity/versioning choices in this revision, per @srebhan's feedback:
FROM scratch, not a distroless base image. Rather than depend ongcr.io/distroless/static, we assemble the rootfs ourselves in the alpine stage. That gives a complete inventory of the image (see the table above) with nothing we don't use, and one fewer external base to track.TELEGRAF_VERSIONand GPG-verified; the fetch base is thealpine:3.23tag. Neither issha256-pinned. This matches how InfluxData publishes to Docker Hub — a new release is picked up by bumping the version and rebuilding, with no per-release digest to hand-edit. (An earlier revisionsha256-pinned both the tarball viaADD --checksum=and agcr.io/distroless/staticbase, each of which would need a manual digest bump every release.) Integrity is still enforced, now by the GPG signature instead of a committed hash.The tradeoff we accept: we own refreshing the CA bundle and tzdata, both sourced from the
alpine:3.23fetch base at build time. Because3.23is a patch-rolling tag, a plain rebuild picks up alpine's latest CA/tzdata patches within the 3.23 series — so a periodic rebuild keeps the data fresh. Once 3.23 reaches EOL, the fetch base must be bumped to the next series (an explicit part of that accepted cost). One scanning caveat: ascratchimage exposes no OS-package database, so scanners report zero OS packages — a cleaner report, but the copied CA bundle and tzdata aren't scanner-visible.Testing
Built and run locally on
linux/arm64with Docker (plain build, no--privileged). The fetch-stage GPG check gates every build.docker buildsucceeds and the fetch stage reportsGood signature from "InfluxData Package Signing Key"; the build aborts if verification fails.telegraf --version→Telegraf 1.39.1.USERis65532:65532, and aprocstatrun (pid_finder = "native",--test) emitsuser="nonroot"— i.e. uid 65532 resolves to thenonrootname from the image's own/etc/passwd.pgrepin the image —procstat's defaultpgrepfinder errors withcould not find pgrep binary, and the pure-Gonativefinder is required.runAsNonRoot: in a localkindcluster, a pod withsecurityContext.runAsNonRoot: truereferencing this image is admitted and runs (Completed), confirming the numericUSER 65532passes the kubelet's non-root check. (A namedUSERwould fail that check — hence numeric.)Reproduce locally: