Skip to main content

max / alloy

Weigh the base trim instead of quoting dnf, and the push instead of assuming it Both claims committed last were repeated from the tools that made them rather than measured against the artifact, and both are wrong in the same direction: they promise a saving to whoever reads them next. THE TRIM. dnf reports 297 MiB freed and /usr does shrink by 304 MiB, so the number was never invented. The image on disk shrinks by 3.8 MiB. Every file in the base's /usr is a hardlink into /sysroot/ostree/repo/objects, which the image carries too, so removing the /usr path drops one link of two and frees no blocks; the repo still holds the object and cannot be pruned, because those objects belong to the base commit and it is still referenced. dnf is accounting for the rpm database, which is a different question from what the medium carries. Both profiles were built the same hour so the package versions match and the delta is the trim rather than a base bump. That takes the build-speed argument with it. The ext4 populate, the squashfs, the write to a stick and the install all scale with the image, and the image did not move, so the trim is paid for four times by nobody. It stays, because the packages are genuinely unreachable from an Alloy install and because the installed system is built from /usr rather than from the image's repo and is where the saving should land. That has not been weighed, and the comment now says so rather than implying the medium got smaller. The label loses its "(297 MiB)". A number in a picker is a promise, and this one would be read as the size of the ISO the user is about to build. THE PUSH. A registry moves layers to the target, which is true and is why the script exists. The push into it is not incremental: skopeo re-copies all 125 blobs on every run, 22s twice in a row with no "Skipping fetch of repeat blob", because a containers-storage source records no compression metadata, so each layer is recompressed before its digest is known. The volume still earns its keep, for the target's next pull rather than for this end. The script said "first push moves the image; later ones move what changed" on every run, so anyone timing it saw the second push cost what the first did and had nothing to reconcile it against. Recorded while measuring: the registry binds every interface and takes no authentication, because a VM reaches it at 10.0.2.2 and a real target by tailnet name. Same trust boundary as the tailnet, and still a reason to keep it up only while a push is in flight. base_trim.rs keeps its subject, which was never the size: the trim list is a thing people extend, and firmware is what they must not take. The reason to hold that line is now the one that survives measurement. A list that looks like it buys hundreds of megabytes is exactly the list somebody extends.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 18:36 UTC
Signed with PGP, not checked
Commit: d608c613c956ee3a8c4903ee6a09bffd0660071c
Parent: 403a303
7 files changed, +86 insertions, -43 deletions
M Cargo.lock +22 -18
@@ -2173,19 +2173,23 @@
2173 2173
2174 2174 [[patch.unused]]
2175 2175 name = "quasi-axum"
2176 - version = "0.4.0"
2176 + version = "0.8.0"
2177 +
2178 + [[patch.unused]]
2179 + name = "quasi-basics"
2180 + version = "0.8.0"
2177 2181
2178 2182 [[patch.unused]]
2179 2183 name = "quasi-http"
2180 - version = "0.4.0"
2184 + version = "0.8.0"
2181 2185
2182 2186 [[patch.unused]]
2183 2187 name = "quasi-immediate"
2184 - version = "0.4.0"
2188 + version = "0.8.0"
2185 2189
2186 2190 [[patch.unused]]
2187 2191 name = "quasi-router"
2188 - version = "0.4.0"
2192 + version = "0.8.0"
2189 2193
2190 2194 [[patch.unused]]
2191 2195 name = "quasi-store"
@@ -2193,23 +2197,11 @@
2193 2197
2194 2198 [[patch.unused]]
2195 2199 name = "quasi-tauri"
2196 - version = "0.4.0"
2197 -
2198 - [[patch.unused]]
2199 - name = "quasi-webview"
2200 - version = "0.4.0"
2201 -
2202 - [[patch.unused]]
2203 - name = "synckit-client"
2204 2200 version = "0.8.0"
2205 2201
2206 2202 [[patch.unused]]
2207 - name = "synckit-config"
2208 - version = "0.2.0"
2209 -
2210 - [[patch.unused]]
2211 - name = "docengine"
2212 - version = "0.7.0"
2203 + name = "quasi-webview"
2204 + version = "0.8.0"
2213 2205
2214 2206 [[patch.unused]]
2215 2207 name = "kberg"
@@ -2226,3 +2218,15 @@
2226 2218 [[patch.unused]]
2227 2219 name = "tagtree"
2228 2220 version = "0.4.0"
2221 +
2222 + [[patch.unused]]
2223 + name = "synckit-client"
2224 + version = "0.8.0"
2225 +
2226 + [[patch.unused]]
2227 + name = "synckit-config"
2228 + version = "0.2.0"
2229 +
2230 + [[patch.unused]]
2231 + name = "docengine"
2232 + version = "0.7.0"
M Containerfile +21 -6
@@ -536,13 +536,28 @@
536 536 # carries are unreachable from any Alloy install however the machine is used:
537 537 # python3-botocore (an AWS SDK, with boto3 and s3transfer behind it),
538 538 # qemu-user-static for eighteen foreign architectures, and toolbox, whose job
539 - # distrobox already does here. Measured 2026-08-15 against this image: 297 MiB
540 - # across 22 packages, and nothing else in the image requires any of them.
539 + # distrobox already does here. 22 packages, and nothing else in the image
540 + # requires any of them.
541 541 #
542 - # It is a build-speed choice as much as a size one. Everything downstream of
543 - # the image is proportional to its size — mkfs and the squashfs on the way to
544 - # an ISO, the dd to a stick, the install onto a disk — so the base's dead
545 - # weight is paid four times by whoever builds and installs.
542 + # WHAT IT IS WORTH, MEASURED RATHER THAN ASSUMED (2026-08-15, both profiles
543 + # built the same hour so the package versions match). dnf reports 297 MiB
544 + # freed, and `/usr` does shrink by 304 MiB: 170 MiB out of /usr/bin, 123 MiB
545 + # out of /usr/lib. **The image on disk shrinks by 3.8 MiB.**
546 + #
547 + # The difference is how the base is composed. Every file in the base's /usr is
548 + # a hardlink into /sysroot/ostree/repo/objects — `stat` reports nlink 2 and the
549 + # object is findable with `find -samefile` — so removing the /usr path drops a
550 + # link and frees no blocks, because the repo still holds the other one. dnf is
551 + # accounting for the rpm database, which is not the same question as what the
552 + # medium carries. Nothing here can prune the repo either: those objects belong
553 + # to the base commit, which is still referenced.
554 + #
555 + # So it is not the build-speed lever it looks like. What it should still buy is
556 + # the *installed* system, whose deployment is built from /usr rather than from
557 + # the image's own repo, and that has not been measured yet — believe it when an
558 + # install has been weighed both ways. Kept because the packages are genuinely
559 + # unreachable and because whoever measures that wants the switch to already
560 + # exist, not because the medium got smaller.
546 561 #
547 562 # WHAT THIS NEVER TOUCHES IS FIRMWARE, and that is a decision rather than an
548 563 # oversight. `nvidia-gpu-firmware` alone is 101 MiB and is the largest single
@@ -17,8 +17,16 @@
17 17 # Why a registry and not a file. bootc reads several transports, and the
18 18 # obvious ones move the whole image every time: `containers-storage` is not
19 19 # reachable from another machine at all, and an archive is 3 GB down the wire
20 - # per rebuild. A registry moves layers, and a config-only rebuild changes the
21 - # tail of the image, so the second push and every push after it is megabytes.
20 + # per rebuild. A registry stores layers, so the target pulls only the ones it
21 + # does not have, and a config-only rebuild changes the tail of the image.
22 + #
23 + # The push itself is NOT incremental, and measuring it is the only way anyone
24 + # would know. skopeo re-copies all 125 blobs on every run — 22s, twice in a
25 + # row, with no "Skipping fetch of repeat blob" — because a containers-storage
26 + # source carries no compression metadata, so each layer has to be recompressed
27 + # before its digest is even known. 22s is the fixed price of a push here. What
28 + # the registry buys is the wire to the target, not this end.
29 + #
22 30 # The registry is a dev tool that runs on this box and is torn down with
23 31 # `--stop`; nothing about it is the distribution story, which stays "no image
24 32 # is published anywhere" (docs/IMAGE.md, "Registry").
@@ -44,6 +52,13 @@
44 52 # drop-in file, it is a development machine talking to a registry on the same
45 53 # desk, and `build/vmtest` is the intended target. Do not carry it onto a
46 54 # machine that matters.
55 + #
56 + # The registry itself binds every interface, because a VM reaches it at
57 + # 10.0.2.2 and a real target reaches it by tailnet name, and it takes no
58 + # authentication: while it is up, anyone who can reach this box can read the
59 + # image and push one. That is the same trust boundary as the tailnet and it is
60 + # still a reason to run it only while a push is in flight. `--stop` is one
61 + # command and the next push starts it again.
47 62
48 63 set -euo pipefail
49 64
@@ -57,9 +72,9 @@
57 72 IMAGE="localhost/alloy:local"
58 73 REGISTRY_IMAGE="docker.io/library/registry:2"
59 74 CONTAINER="alloy-dev-registry"
60 - # A named volume rather than a tmpfs, so the blobs of the last push survive a
61 - # restart. That is the whole economy of this script: what makes the second
62 - # push cheap is the registry already holding the layers that did not change.
75 + # A named volume rather than a tmpfs, so the blobs survive a restart of the
76 + # registry. What that protects is the target's next pull: with the layers still
77 + # here, a machine that already fetched them fetches only what changed.
63 78 VOLUME="alloy-dev-registry"
64 79 PORT=5000
65 80 ADDRESS=""
@@ -85,7 +100,7 @@
85 100 say "stopping $CONTAINER"
86 101 privc podman rm -f "$CONTAINER" >/dev/null 2>&1 || true
87 102 privc podman volume rm "$VOLUME" >/dev/null 2>&1 || true
88 - say "stopped. The next push starts from an empty registry and moves the whole image."
103 + say "stopped. The next target to switch to it pulls the whole image again."
89 104 exit 0
90 105 fi
91 106
@@ -110,7 +125,7 @@
110 125 # keep current. --network host so 127.0.0.1 means this box rather than the
111 126 # skopeo container's own loopback, which is the failure that looks like the
112 127 # registry being down.
113 - say "pushing $IMAGE (first push moves the image; later ones move what changed)"
128 + say "pushing $IMAGE (about 20s; skopeo recompresses every layer, see the header)"
114 129 priv podman run --rm --privileged --network host \
115 130 --security-opt label=type:unconfined_t \
116 131 -v /var/lib/containers/storage:/var/lib/containers/storage \
M docs/IMAGE.md +1 -1
@@ -86,7 +86,7 @@
86 86
87 87 That is A/B staged with rollback, the same mechanism a registry-fed update would use, with the image coming off local container storage instead of the network. bootc 1.16.3 accepts registry, oci, oci-archive, docker-daemon and containers-storage.
88 88
89 - That is the same machine adopting its own build. For a *different* machine, and above all for a VM under `build/vmtest`, the loop that matters is `build/dev-push.sh`: it runs a registry on the dev box, pushes `localhost/alloy:local` into it, and prints the `bootc switch` line to run on the target. A registry rather than an archive because a registry moves layers, so a config-only rebuild is megabytes rather than three gigabytes down the wire. It is a development tool that lives and dies with a `--stop`, and it changes nothing about the position above: no image is published anywhere.
89 + That is the same machine adopting its own build. For a *different* machine, and above all for a VM under `build/vmtest`, the loop that matters is `build/dev-push.sh`: it runs a registry on the dev box, pushes `localhost/alloy:local` into it, and prints the `bootc switch` line to run on the target. A registry rather than an archive because the target then pulls only the layers it lacks, so a config-only rebuild is megabytes rather than three gigabytes down the wire. The push out of local storage is a flat ~20s either way: skopeo recompresses every layer because containers-storage records no compression metadata, so it cannot know a blob's digest before it has made it. It is a development tool that lives and dies with a `--stop`, and it changes nothing about the position above: no image is published anywhere.
90 90
91 91 The reason it exists is the measured shape of the loop. A config-only change is fifteen seconds of `podman build` and then four minutes of ISO assembly, and the ISO is installer media nobody is going to install from when the question is whether a sway binding works. Building one to see a config change is the single most expensive habit available here.
92 92
@@ -88,14 +88,19 @@
88 88 The Fedora base is a general-purpose server image, and three of the things it
89 89 carries cannot be reached from an Alloy install: an AWS SDK, eighteen
90 90 architectures of `qemu-user-static`, and toolbox, whose job distrobox already
91 - does. The build drops them, which is 297 MB off the image and therefore off
92 - the squashfs, the stick and the install.
91 + does. The build drops them.
93 92
94 93 build/build-iso.sh --build-arg TRIM=keep
95 94
96 95 keeps them, and the one capability that comes back with them is running
97 96 containers built for a foreign architecture.
98 97
98 + Do not expect a smaller ISO from this. Removing a base package takes 304 MB
99 + out of `/usr` and 3.8 MB off the image, because the base hardlinks its `/usr`
100 + into an ostree repo that ships in the image and that the removal cannot prune.
101 + The packages are gone from the installed system, which is where the saving
102 + should land; the medium you write is the same size either way.
103 +
99 104 Firmware is never trimmed. A medium you build has to boot hardware nobody
100 105 asked about when it was built, so every firmware package the base ships stays
101 106 in, and the build fails rather than producing an image that lost one.
@@ -275,9 +275,10 @@
275 275 /// which anything in Alloy reaches; the Containerfile's `ARG TRIM` argues the
276 276 /// list and holds the measurement.
277 277 ///
278 - /// Size here is build time everywhere else: the ext4 populate, the squashfs,
279 - /// the write to a stick and the install all scale with it, so a base package
280 - /// nobody can reach is paid for four times.
278 + /// Read the measurement before quoting a number at anyone. It takes 304 MiB
279 + /// out of `/usr` and 3.8 MiB off the image, because the base hardlinks its
280 + /// `/usr` into an ostree repo the removal cannot prune. The installed system
281 + /// is where it should pay, and that has not been weighed yet.
281 282 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282 283 pub(crate) enum Trim {
283 284 /// Drop them. The default, because the capability lost with them is
@@ -300,7 +301,7 @@
300 301
301 302 const fn label(self) -> &'static str {
302 303 match self {
303 - Trim::Unused => "drop what nothing here reaches (297 MiB)",
304 + Trim::Unused => "drop what nothing here reaches",
304 305 Trim::Keep => "keep the base as it ships",
305 306 }
306 307 }
@@ -1,10 +1,13 @@
1 1 //! `TRIM` may never reach firmware, and the Containerfile is where that holds.
2 2 //!
3 - //! The trim exists because size in the image is build time everywhere after it:
4 - //! the ext4 populate, the squashfs, the write to a stick, the install. That
5 - //! makes the list of packages it removes a thing people will want to extend,
6 - //! and the largest single candidate a naive extension finds is
7 - //! `nvidia-gpu-firmware` at 101 MiB, with the rest of `*-firmware` behind it.
3 + //! The trim removes base packages nothing in Alloy can reach. It looks like a
4 + //! size lever and mostly is not one — the base hardlinks its `/usr` into an
5 + //! ostree repo the removal cannot prune, so the image loses 3.8 MiB for 304 MiB
6 + //! of files (the Containerfile's `ARG TRIM` holds the measurement). That is
7 + //! exactly why this file exists: a list that looks like it buys hundreds of
8 + //! megabytes is a list people extend, and the largest single candidate a naive
9 + //! extension finds is `nvidia-gpu-firmware` at 101 MiB, with the rest of
10 + //! `*-firmware` behind it.
8 11 //!
9 12 //! Taking any of it would be wrong in a way no build catches. A medium built
10 13 //! here has to boot hardware nobody was asked about at build time, and astra