Skip to main content

max / alloy

Cut 370 MB by cleaning caches in the layer that fills them Two accidents, both the same mistake: a delete in a later layer is a whiteout, not a refund, so a cleanup only pays inside the step that made the mess. terra-release is 674 bytes and its layer measured 238 MB, all of it repo metadata that a later `dnf clean all` removed from the filesystem and could never remove from the image. Moving the clean into that layer and the copr-plugin layer takes ~200 MB. Checked against the objection that it only shifts the cost downstream, by building a three-layer chain both ways: 272 MB before, 114 MB after. bibata-cursor-theme installs fourteen themes and skel names one, so it was the third-largest package in the image at 179 MB. Fedora ships no per-variant subpackage; the prune runs inside the install RUN for the same reason as above, and keeps 27 MB. The prune cannot read the name it keeps, because skel is copied 600 lines later, so the guard is at the other end: an assertion after the copy reads the cursor name back out of all five files that declare it and fails if the directory is gone, plus a mirror loop that fails if any unnamed theme survived. That second one catches the prune silently not running, which costs 159 MB rather than a visible broken cursor. All three paths tested against the pre-change image. Measured 6.19 GB to 5.82 GB, and 5.26 GB to 5.11 GB deployed. docs/IMAGE.md gains a size section accounting for every layer over 100 MB, including the two structural costs that belong to no package: 551 MB of duplicated rpm database across twelve layers, and removal layers that cost 60 MB each and refund nothing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 21:13 UTC
Signed with PGP, not checked
Commit: 068d6cbbb339a31fbc065cc2e831fa71e128d552
Parent: 61d5a59
2 files changed, +190 insertions, -3 deletions
M Containerfile +98 -3
@@ -707,13 +707,39 @@
707 707 # starship, satty, and some others). Does not carry Rust binaries like
708 708 # nushell or yazi; COPRs below handle those.
709 709 # https://terra.fyralabs.com/
710 + #
711 + # `dnf clean all` matters more here than the install does. terra-release is
712 + # 674 BYTES; the layer measured 238 MB, and every byte of the difference was
713 + # repo metadata in /var/cache/libdnf5 (fedora 64 MB, updates 37 MB,
714 + # updates-archive 43 MB, terra 3.7 MB, and their solv caches). A later layer's
715 + # `dnf clean all` deletes it, which is why the built image shows 28 KB of
716 + # cache and the size stays: a delete in a later layer is a whiteout, not a
717 + # refund. Cleaning inside the layer that filled it is the only thing that
718 + # reclaims the bytes.
719 + #
720 + # It does not merely move the cost to the next layer. Measured 2026-08-17 on
721 + # fedora-bootc, this three-layer chain against itself:
722 + # as it was: 188 MB + 41.2 MB + 42.5 MB = 272 MB
723 + # as it is: 34.5 MB + 37 MB + 42.8 MB = 114 MB
724 + # The next dnf line re-downloads the metadata and deletes it in the same
725 + # layer, so it pays nothing to keep. What is left in each number is the
726 + # ~34 MB rpmdb rewrite, which is the floor every dnf layer pays and the
727 + # largest unattributed cost in the image. docs/IMAGE.md#size has it.
728 + #
729 + # One new line in the build log comes with this and is not a fault. The next
730 + # dnf run now meets a cold cache, so it prints
731 + # >>> repomd.xml GPG signature verification error: Signing key not found
732 + # for Terra and Tailscale before importing the key from the RPM-GPG-KEY-*
733 + # files terra-release itself installed, and then proceeds. It was invisible
734 + # before only because the cache the clean now removes was still warm.
710 735 RUN dnf install -y --nogpgcheck \
711 736 --repofrompath='terra,https://repos.fyralabs.com/terra$releasever' \
712 - terra-release
737 + terra-release \
738 + && dnf clean all
713 739
714 740 # dnf5's copr plugin isn't in the base bootc image; pull it so we
715 741 # can `dnf copr enable` for the Rust-Wayland stragglers.
716 - RUN dnf install -y 'dnf5-command(copr)'
742 + RUN dnf install -y 'dnf5-command(copr)' && dnf clean all
717 743
718 744 # ublue-os/staging is the community-maintained COPR that packages
719 745 # much of the Wayland / Rust ecosystem for atomic Fedora derivatives.
@@ -1222,7 +1248,30 @@
1222 1248 # was installed without it, so both watchers failed at every login
1223 1249 # and the clipboard history could never be populated or pasted from.
1224 1250 wl-clipboard \
1225 - && dnf clean all; \
1251 + && dnf clean all \
1252 + # bibata-cursor-theme installs FOURTEEN directories and Alloy names one.
1253 + # At 179 MB it was the third-largest package in the image, behind the
1254 + # browser and helix's parsers, and ahead of rustc. Fedora ships no
1255 + # per-variant subpackage, so a prune is the only lever.
1256 + #
1257 + # Measured in the built image: Bibata-Modern-{Amber,Classic,Ice} at 27 MB
1258 + # each, Bibata-Original-{Amber,Classic,Ice} at 12 MB, and a -Right variant
1259 + # of all six at 11-12 MB. Keeping Bibata-Modern-Classic drops 159 MB.
1260 + #
1261 + # It runs INSIDE this RUN on purpose. Deleting them in a later layer
1262 + # writes whiteouts and reclaims nothing from the image, the same trap the
1263 + # terra metadata was in.
1264 + #
1265 + # KEEP is spelled here rather than read from /etc/skel, which is copied in
1266 + # further down and does not exist yet. The rename this cannot see is caught
1267 + # at the other end instead: the assertion after the skel copy reads the
1268 + # theme name back out of skel and fails if its directory is not here.
1269 + && KEEP=Bibata-Modern-Classic \
1270 + && test -d "/usr/share/icons/$KEEP" \
1271 + && for theme in /usr/share/icons/Bibata-*; do \
1272 + [ "$theme" = "/usr/share/icons/$KEEP" ] || rm -rf "$theme"; \
1273 + done \
1274 + && test -d "/usr/share/icons/$KEEP"; \
1226 1275 else \
1227 1276 # The asserting else. Five sentinels, one per reason a package could
1228 1277 # have landed here anyway: sway is a direct name in the `then` branch,
@@ -1638,6 +1687,52 @@
1638 1687 COPY usr/ /usr/
1639 1688 COPY --from=rust-build /staged-skel/ /
1640 1689
1690 + # =====================================================================
1691 + # The cursor theme skel names has to be the one the prune kept.
1692 + # =====================================================================
1693 + # bibata-cursor-theme ships fourteen themes and the package block above
1694 + # deletes thirteen of them, 159 MB, keeping the one string spelled there.
1695 + # That prune runs 600 lines before skel exists, so it cannot read the name
1696 + # it is keeping; this is the other end of that, and it is the only thing
1697 + # standing between a rename and a session that starts with the X11 default
1698 + # cursor because its theme directory was thrown away at build time.
1699 + #
1700 + # Read from skel rather than compared against a constant. Five files declare
1701 + # the cursor and each has its own syntax, so a rename that reaches four of
1702 + # them and not the fifth is the realistic failure, and it is one this catches
1703 + # where a single hardcoded name would not.
1704 + #
1705 + # The second loop is the mirror: every theme still on disk must be one skel
1706 + # asked for. It fails if the prune silently stopped running, which is the
1707 + # case that costs 159 MB rather than a broken cursor, and which nothing else
1708 + # here would notice.
1709 + RUN set -eu; \
1710 + if [ "$PROFILE" = client ]; then \
1711 + names="$( { \
1712 + sed -n 's/^[[:space:]]*Inherits[[:space:]]*=[[:space:]]*//p' \
1713 + /etc/skel/.icons/default/index.theme; \
1714 + sed -n 's/^[[:space:]]*gtk-cursor-theme-name[[:space:]]*=[[:space:]]*//p' \
1715 + /etc/skel/.config/gtk-3.0/settings.ini \
1716 + /etc/skel/.config/gtk-4.0/settings.ini; \
1717 + sed -n 's/^[[:space:]]*seat[[:space:]]\+[^[:space:]]\+[[:space:]]\+xcursor_theme[[:space:]]\+\([^[:space:]]\+\).*/\1/p' \
1718 + /etc/skel/.config/sway/config; \
1719 + sed -n 's/^.*XCURSOR_THEME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \
1720 + /etc/skel/.config/nushell/env.nu; \
1721 + } | sed 's/[[:space:]]*$//' | sort -u )"; \
1722 + [ -n "$names" ] \
1723 + || { echo "no cursor theme is declared anywhere in /etc/skel; the five declarations moved and the prune in the package block is now keeping a directory nothing asks for" >&2; exit 1; }; \
1724 + for n in $names; do \
1725 + test -d "/usr/share/icons/$n" \
1726 + || { echo "skel asks for cursor theme '$n' and /usr/share/icons/$n is not in the image; the prune in the package block kept a different name" >&2; exit 1; }; \
1727 + done; \
1728 + for theme in /usr/share/icons/Bibata-*; do \
1729 + [ -d "$theme" ] || continue; \
1730 + echo "$names" | grep -qx "$(basename "$theme")" \
1731 + || { echo "$(basename "$theme") survived and skel does not name it; the prune in the package block stopped running and the image is carrying ~12-27 MB per unused theme" >&2; exit 1; }; \
1732 + done; \
1733 + echo "cursor: $(echo "$names" | tr '\n' ' ')kept, every other Bibata theme pruned"; \
1734 + fi
1735 +
1641 1736 # =====================================================================
1642 1737 # The installer's ssh door — assert both halves of the gate are intact.
1643 1738 # =====================================================================
@@ -110,6 +110,98 @@
110 110
111 111 The browser ships as an RPM baked into the image: one code path, no first-boot delay, and no first-run network dependency. Alloy ships no configuration for it at all, which is why it is the browser Alloy ships (see [STACK.md](STACK.md#browser)). The `flatpak` client is included so users can pull Flathub-only apps on demand post-install; no Flatpaks are provisioned at build or first-boot time.
112 112
113 + ## Size
114 +
115 + Measured 2026-08-17 against `localhost/alloy:clip-client`, the client profile with
116 + `BROWSER=helium LANGS=rust,go TRIM=unused`. It was 6.19 GB as an image and 5.26 GB
117 + deployed, and the 0.9 GB between those two numbers is the subject of half this section.
118 +
119 + Two sizes, and they answer different questions. **Image size** is the sum of the layers:
120 + what a registry stores and what `bootc upgrade` moves. **Deployed size** is the final
121 + filesystem: what the disk holds and what the ISO squashes. A file written in one layer
122 + and deleted in a later one is gone from the second number and permanent in the first,
123 + because a delete across a layer boundary is a whiteout rather than a refund. Most of
124 + what looked like waste here was that one mechanic.
125 +
126 + ### Layers over 100 MB
127 +
128 + | Size | Layer | Why it is that size |
129 + |------|-------|---------------------|
130 + | 1.44 GB | sway, `xdg-desktop-portal{,-gtk,-wlr}`, the session | The product. This is the desktop. |
131 + | 778 MB | `LANGS=rust,go` | Chosen, and argued at `ARG LANGS`. Go is 230 MB of it. |
132 + | 674 MB | `helium-browser-bin` | The product, and the one stack pick Alloy declines to make. |
133 + | 663 MB | Base package list | The stack, per [STACK.md](STACK.md). |
134 + | 170 MB | initramfs + `rpmostree-unpackaged-content` | Bootable image. |
135 + | 147 MB | cups + cups-filters | Printing, client profile only; the server profile presets it off. |
136 + | 126 MB | `python3-botocore` (base) | Arrives in fedora-bootc. `TRIM=unused` removes it from the filesystem and cannot remove it from the image. |
137 + | 113 MB | `kernel-modules` | Bootable image. |
138 + | 107 MB | `nvidia-gpu-firmware` | Deliberate, and asserted: the trim block refuses to run if firmware is missing, because a medium built here cannot know what hardware will boot it. |
139 + | 90 MB | `kernel-modules-core` | Bootable image. |
140 +
141 + ### Packages that look wrong and are not
142 +
143 + - **`helix-parsers`, 185 MB.** Named explicitly at alloy@11291bd rather than losing
144 + syntax highlighting. Settled.
145 + - **`rust-std-static`, 166 MB.** The name suggests static-musl targets nothing here
146 + builds. It is the standard library: `rust` carries a versioned hard requirement on it,
147 + and removing it takes `rust` and `cargo` with it. Not separable from `LANGS=rust`.
148 + - **`llvm-libs`, 139 MB.** `rpm -q --whatrequires llvm-libs` returns nothing, which is
149 + what makes it look orphaned, and the query is the thing that is wrong: dependents
150 + require the soname. `libLLVM.so.21.1()(64bit)` is required by `mesa-dri-drivers`,
151 + `mesa-vulkan-drivers` and `rust`. Load-bearing three times over.
152 + - **`mesa-vulkan-drivers`, 153 MB.** Twelve ICDs, no rpm dependents at all, because the
153 + Vulkan loader opens one by dlopen off a JSON manifest. The x86_64 ones are load-bearing:
154 + `intel` (21 MB), `intel_hasvk` (17 MB), `radeon` (16 MB), `nouveau` (13 MB), `lvp` (12 MB,
155 + llvmpipe, the software fallback), `virtio` (1 MB, the VM path the QEMU punch list runs on).
156 + The other six are not, and are the one measured cut in this section that has NOT been
157 + taken — see below.
158 +
159 + ### Measured and not taken: the ARM-SoC Vulkan drivers, 67 MB
160 +
161 + `mesa-vulkan-drivers` ships `panfrost` (Mali), `freedreno` (Adreno), `asahi` (Apple),
162 + `powervr`, `broadcom` (VideoCore) and `dzn` (the Direct3D-to-Vulkan translation layer, for
163 + Windows). This image is x86_64 and no x86_64 machine has any of those GPUs, so the 67 MB
164 + is unreachable rather than merely unused.
165 +
166 + It is left in because it is a stack decision rather than an accident, and it differs from
167 + the cursor prune in one way that matters: cursor themes are data and these are drivers, so
168 + pruning files out of the package puts `rpm -V` permanently in disagreement with the image
169 + over a set that Fedora may re-partition at any release. The firmware reasoning does not
170 + apply — that is about hardware the build cannot predict, and a Mali GPU on an x86_64 board
171 + is not unpredictable — so if this is taken, take it as a prune inside the package layer with
172 + the kept list asserted, the same shape as the cursor themes.
173 +
174 + ### Two structural costs, neither attributable to any package
175 +
176 + **The rpmdb rewrite, ~495 MB.** `/usr/share/rpm/rpmdb.sqlite` grows from 32 MB to 56 MB
177 + over the build, and every `dnf` layer rewrites it whole. Twelve layers in this image carry
178 + a full copy, 551 MB of which the last 56 MB is the live one. It shows up in no per-package
179 + accounting and is roughly 8% of the image. The fix is fewer dnf layers, which trades
180 + against the block structure this file is organised by and against build caching, so it is
181 + recorded rather than taken.
182 +
183 + **Removal layers cost and refund nothing.** `dnf remove` runs twice near the end, 61.7 MB
184 + and 61.5 MB, and both are rpmdb plus whiteouts. They shrink the deployed filesystem and
185 + grow the image. That is the right trade for `TRIM=unused` (botocore, toolbox, the
186 + qemu-user-static set are ~290 MB off the disk) and worth knowing before anyone tries to
187 + shrink the image by removing more.
188 +
189 + ### What was cut, 2026-08-17
190 +
191 + - **Repo metadata, ~200 MB.** `terra-release` is 674 bytes and its layer was 238 MB of
192 + `/var/cache/libdnf5`, cleaned by a later layer and therefore never reclaimed. `dnf clean
193 + all` moved into the layer that fills it. Measured on a three-layer chain: 272 MB before,
194 + 114 MB after, and the next dnf line pays nothing extra because it re-downloads and
195 + cleans within its own layer.
196 + - **Thirteen cursor themes, 159 MB.** `bibata-cursor-theme` installs fourteen and skel
197 + names one. Fedora ships no per-variant subpackage, so the package block prunes in place
198 + and an assertion after the skel copy reads the name back out of skel and fails if the
199 + directory it asks for is gone.
200 +
201 + Neither is a race to a number. Two of the four largest layers are the desktop and the
202 + browser and both are the product; the point is that every large thing in this image is now
203 + either explained above or gone.
204 +
113 205 ## Version fields
114 206
115 207 Three numbers describe an Alloy machine, and they move on three different clocks. That is why os-release carries three fields rather than one, and why `alloy --version` prints all of them: