<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Buildah on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/buildah/</link><description>Recent content in Buildah on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 01 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/buildah/index.xml" rel="self" type="application/rss+xml"/><item><title>The builder had been archived for a year</title><link>https://phpboyscout.uk/the-builder-had-been-archived-for-a-year/</link><pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-builder-had-been-archived-for-a-year/</guid><description>&lt;img src="https://phpboyscout.uk/the-builder-had-been-archived-for-a-year/cover-the-builder-had-been-archived-for-a-year.png" alt="Featured image of post The builder had been archived for a year" /&gt;&lt;p&gt;It started with me tidying up, which is how a fair few of these things start. I was waiting on a pipeline and idly poking about in the container registry for one of the CI images, and there were forty tags in it, nearly all of them &lt;code&gt;sha-&lt;/code&gt; something, one per commit to main, all of which should have been identical to each other and none of which anything was actually using.&lt;/p&gt;
&lt;p&gt;So, a bit of a cleanup then. And while I was in there, a thought I&amp;rsquo;d had before and never chased: if every commit to main builds and pushes an image, and a tag pipeline then builds the same tree again, why not just retag the one we already had? That&amp;rsquo;s only safe if two builds of one commit come out the same, and I had a vague memory that kaniko had a &lt;code&gt;--reproducible&lt;/code&gt; flag for more or less this.&lt;/p&gt;
&lt;h2 id="i-asked-a-model-and-then-i-read-the-readme"&gt;I asked a model, and then I read the README
&lt;/h2&gt;&lt;p&gt;I asked Gemini what it took to get a consistent digest out of kaniko and it came back with a perfectly sensible list&amp;hellip; timestamps, layer caching, the usual. It read like advice about a tool that was alive and well.&lt;/p&gt;
&lt;p&gt;Then I went to the kaniko repository to check a flag name and the banner across the top said the project had been archived in June 2025. Not deprecated, not &amp;ldquo;in maintenance mode&amp;rdquo;&amp;hellip; archived, code kept for historic purposes, more than a year before I was sat there reading it.&lt;/p&gt;
&lt;p&gt;And all eight of my image repositories were pinned to &lt;code&gt;v1.23.2&lt;/code&gt;, which was cut in July 2024. Fourteen months old, one release behind a tool that had no more releases to give, and nothing was ever going to fix a CVE in it.&lt;/p&gt;
&lt;p&gt;Well then.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d love to tell you I felt clever for checking. Mostly I felt a bit like a man who&amp;rsquo;d been asking a very confident stranger for directions to a shop that shut down last year. The model wasn&amp;rsquo;t wrong about anything it said, it just had no way of telling me the one thing I most needed to know, because a summary of an archived tool reads just like a summary of a live one. I&amp;rsquo;ve a standing rule about checking that whatever I&amp;rsquo;m reading is current, and I&amp;rsquo;d applied it to the docs and the flags and not, that afternoon, to the tool itself (silly old me).&lt;/p&gt;
&lt;p&gt;So the question kinda changed under me. Reproducibility could wait a bit, and the builder had to go first.&lt;/p&gt;
&lt;h2 id="choosing-a-replacement-without-inviting-a-daemon-in"&gt;Choosing a replacement without inviting a daemon in
&lt;/h2&gt;&lt;p&gt;The shortlist was BuildKit, Buildah, apko and ko, and the constraint I cared about most was the one kaniko had been chosen for in the first place: no Docker daemon. I&amp;rsquo;d rather not run &lt;code&gt;docker:dind&lt;/code&gt; as a service on every image build, and I&amp;rsquo;d rather not depend on a mounted host socket either, on a runner fleet that may or may not be in AWS next month.&lt;/p&gt;
&lt;p&gt;ko went first (it builds a single Go application, and these images are a toolchain, so it&amp;rsquo;s the wrong category, not a compromise). apko was the tantalising one: Chainguard&amp;rsquo;s tool, reproducible by design, and &lt;code&gt;ci-base&lt;/code&gt; already derives from a Wolfi image, so the ecosystem matched. But apko has no &lt;code&gt;RUN&lt;/code&gt;. It composes apk packages and nothing else, and every one of these images curls a release asset, or compiles &lt;code&gt;govulncheck&lt;/code&gt; at build time, or unpacks a vendor tarball. Moving to it would have meant packaging Go, golangci-lint, goreleaser, syft and the ONNX runtime myself with melange, and then looking after those packages for ever. Lovely in principle, but not this month.&lt;/p&gt;
&lt;p&gt;BuildKit is maintained, it&amp;rsquo;s already on the runner, and it reads the existing Dockerfiles unchanged, but &lt;code&gt;docker buildx&lt;/code&gt; wants a daemon, and the daemonless rootless flavour is a different thing again that I didn&amp;rsquo;t get as far as trying. Buildah, on the other hand, runs as the job image with no daemon at all, which is just the shape kaniko had. And the objection you usually hear (that Buildah inside a container falls back to the &lt;code&gt;vfs&lt;/code&gt; storage driver and crawls) didn&amp;rsquo;t happen: given &lt;code&gt;/dev/fuse&lt;/code&gt; and two &lt;code&gt;security_opt&lt;/code&gt; relaxations it used &lt;code&gt;overlay&lt;/code&gt; and built the biggest image unprivileged in about a hundred seconds on my workstation, within noise of kaniko.&lt;/p&gt;
&lt;p&gt;Buildah it was, then.&lt;/p&gt;
&lt;p&gt;And, because I&amp;rsquo;d had my nose in the registry all afternoon, I had one more question. &lt;code&gt;go-tools&lt;/code&gt; was 1.8 GB. How on earth did it get so big! (I said something a good deal ruder than that at the time.)&lt;/p&gt;
&lt;h2 id="the-builder-was-never-the-problem"&gt;The builder was never the problem
&lt;/h2&gt;&lt;p&gt;Now, before switching anything I wanted to know what reproducibility would actually buy me, so I measured it, and this turned out to be the interesting bit.&lt;/p&gt;
&lt;p&gt;I built &lt;code&gt;go-tools&lt;/code&gt; twice with kaniko and its &lt;code&gt;--reproducible&lt;/code&gt; flag: fifteen of twenty layers came out identical. Then twice with BuildKit, &lt;code&gt;SOURCE_DATE_EPOCH&lt;/code&gt; pinned and timestamps rewritten, no cache: sixteen of twenty-one. And the layers that differed were the &lt;em&gt;same five&lt;/em&gt; under both builders. Two tools with different implementations and different timestamp handling, producing the identical set of non-reproducible layers.&lt;/p&gt;
&lt;p&gt;Whatever was going on, it wasn&amp;rsquo;t the builder, and that left the Dockerfile.&lt;/p&gt;
&lt;p&gt;So I went looking in the layers.&lt;/p&gt;
&lt;p&gt;Each of those five layers ran a tool that left something behind. Go&amp;rsquo;s telemetry writes counter files with the build &lt;em&gt;date&lt;/em&gt; in the filename, so even the final sanity-check &lt;code&gt;RUN&lt;/code&gt; that only prints version numbers changed the filesystem. &lt;code&gt;ldconfig&lt;/code&gt; keeps a cache keyed on library mtimes. And the last one took diffing a layer file by file to find: one entry out of 21,288, a 188-byte file under &lt;code&gt;$GOPATH/pkg/sumdb&lt;/code&gt; holding the checksum database&amp;rsquo;s signed tree head, which advances as the transparency log grows, so every build fetched a slightly different one. &lt;code&gt;go clean -modcache&lt;/code&gt; doesn&amp;rsquo;t touch it. &lt;code&gt;GOTELEMETRY=off&lt;/code&gt; doesn&amp;rsquo;t work either, though &lt;code&gt;go telemetry off&lt;/code&gt; does, and I&amp;rsquo;m not sure I&amp;rsquo;d have believed that if I hadn&amp;rsquo;t watched it.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/images/go-tools/-/blob/b4bcbd11/Dockerfile#L309-312" target="_blank" rel="noopener"
 &gt;Two Dockerfile lines and three flags&lt;/a&gt;, and two uncached builds minutes apart came out with the same manifest digest, byte for byte. The images were already deterministic in what they &lt;em&gt;shipped&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;What needed removing was litter.&lt;/p&gt;
&lt;h2 id="seven-kinds-of-litter"&gt;Seven kinds of litter
&lt;/h2&gt;&lt;p&gt;That was one image, though. There are eight, and rolling the same scrub out across the other seven is where the tidy story got its corners knocked off. &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0098-spike-replace-kaniko" target="_blank" rel="noopener"
 &gt;The spec on the wiki&lt;/a&gt; of the &lt;a class="link" href="https://cicd.phpboyscout.uk/" target="_blank" rel="noopener"
 &gt;cicd project&lt;/a&gt; records four things the spike got wrong, and I&amp;rsquo;ve written them down because each one cost me a rebuild to rediscover.&lt;/p&gt;
&lt;p&gt;The first is that three causes on &lt;code&gt;go-tools&lt;/code&gt; turned out to be seven across the set, and hardly any of them carried over from one image to the next. Python&amp;rsquo;s &lt;code&gt;.pyc&lt;/code&gt; files embed the source mtime pip set at install time (536 of them in &lt;code&gt;docs-tools&lt;/code&gt;). Node keeps a V8 compile cache under &lt;code&gt;/tmp&lt;/code&gt;. tflint caches a version check, sigstore caches TUF metadata, playwright&amp;rsquo;s Chromium leaves fontconfig caches behind. All of them were a tool caching its own state next to the content, and &lt;code&gt;release-tools&lt;/code&gt; needed no scrub at all, which is the useful counter-example: applying one image&amp;rsquo;s scrub to another would have been cargo-culting.&lt;/p&gt;
&lt;p&gt;The second is that a digest comparison can lie to you in two ways, and both cost me a rebuild. Chromium leaves a &lt;em&gt;dotfile&lt;/em&gt; in &lt;code&gt;/tmp&lt;/code&gt;, and &lt;code&gt;rm -rf /tmp/*&lt;/code&gt; does not match dotfiles, so the scrub that worked everywhere else sailed straight past it. And &lt;code&gt;rust-tools&lt;/code&gt; had a 634 MB layer differ with zero differing file contents across nine thousand entries&amp;hellip; one leftover &lt;code&gt;mktemp&lt;/code&gt; directory with a random name, which changed the &lt;em&gt;order&lt;/em&gt; of the tar entries and nothing else. A content-level diff reports nothing and looks like agreement, so it&amp;rsquo;s tar entries you want to be diffing, not just files.&lt;/p&gt;
&lt;p&gt;The third is the trap, and it&amp;rsquo;s a good one. Buildah defaults to the OCI image format, and OCI has no &lt;code&gt;SHELL&lt;/code&gt; instruction. All eight Dockerfiles set &lt;code&gt;SHELL [&amp;quot;/bin/bash&amp;quot;, &amp;quot;-o&amp;quot;, &amp;quot;pipefail&amp;quot;, &amp;quot;-c&amp;quot;]&lt;/code&gt;, and under the default format Buildah throws it away with a warning and carries on. A piped &lt;code&gt;RUN&lt;/code&gt; silently stops failing. I measured it on a two-line Dockerfile, &lt;code&gt;RUN false | true&lt;/code&gt;: exit 0 under &lt;code&gt;oci&lt;/code&gt;, exit 1 under &lt;code&gt;docker&lt;/code&gt;. That shipped in &lt;code&gt;ci-base&lt;/code&gt; v0.2.0 before anyone noticed, because all the checks I had looked at the build&amp;rsquo;s &lt;em&gt;output&lt;/em&gt; (digest stability, &lt;code&gt;crane validate&lt;/code&gt;, hadolint, the scan handoff) and a discarded &lt;code&gt;SHELL&lt;/code&gt; changes none of them. The warning was sat there in plain text in the build log, and nothing read it. &lt;a class="link" href="https://gitlab.com/phpboyscout/images/go-tools/-/blob/b4bcbd11/.gitlab-ci.yml#L110-115" target="_blank" rel="noopener"
 &gt;&lt;code&gt;--format docker&lt;/code&gt; is mandatory now&lt;/a&gt;, with a comment that says why.&lt;/p&gt;
&lt;p&gt;The fourth is that it&amp;rsquo;s slower.&lt;/p&gt;
&lt;p&gt;The spike had Buildah at a hundred seconds on my workstation against kaniko&amp;rsquo;s 116 on CI, and I let myself read that as a win. Measured properly on the runner, last kaniko build against first Buildah build, it&amp;rsquo;s slower on all eight images, by 14% on &lt;code&gt;ci-base&lt;/code&gt; and 67% on &lt;code&gt;node-tools&lt;/code&gt;, with &lt;code&gt;go-tools&lt;/code&gt; going from 111 seconds to 179. The switch still stands on its own argument (an archived builder, a fourteen-month-old pin, no daemon) but it&amp;rsquo;s a cost, not a saving, and every repo now runs a &lt;code&gt;reproducible&lt;/code&gt; job that builds the tree twice and compares digests, so a merge request costs roughly three builds where it used to cost one. I&amp;rsquo;ve kept that job off the nightly on purpose.&lt;/p&gt;
&lt;h2 id="what-reproducible-is-allowed-to-mean"&gt;What &amp;ldquo;reproducible&amp;rdquo; is allowed to mean
&lt;/h2&gt;&lt;p&gt;Now I want to be a tad careful with the word, because the claim I can make is narrower than the flag suggests.&lt;/p&gt;
&lt;p&gt;Two builds of one commit, minutes apart, on the same runner, are bit-identical. That&amp;rsquo;s the property I was chasing, because it&amp;rsquo;s what makes a release the artefact that was scanned a few minutes earlier, and not a fresh build that merely resembles it. Two builds a month apart may still differ, because &lt;code&gt;apk add build-base&lt;/code&gt; resolves against a rolling Wolfi repository and I&amp;rsquo;ve left it that way on purpose; pinning it matters for reproducibility across time and not at all for the thing I&amp;rsquo;m asserting. And anything keyed on the calendar date, not the clock, passes the gate in both builds and is invisible to it.&lt;/p&gt;
&lt;p&gt;So run-to-run, then, and not across the years. I suspect that&amp;rsquo;s still enough to turn the retag idea I started with into a decision about saving six to eleven minutes per release instead of a correctness question, and that&amp;rsquo;s a much nicer kind of decision to have.&lt;/p&gt;
&lt;p&gt;The forty &lt;code&gt;sha-&lt;/code&gt; tags are on a retention rule now. I never did get back to the tidying.&lt;/p&gt;</description></item></channel></rss>