The published image is 15.4 MB compressed (measured on ghcr.io/ferrlabs/lfsx:latest): roughly 10 MB of distroless/cc-debian12 and 5.5 MB of binary. The release artifacts agree, lfsx-server-x86_64-unknown-linux-gnu.tar.gz is 5.21 MB and the musl one 5.41 MB.
Two independent steps get that to somewhere around 5 to 6 MB. They are worth doing in this order, because the first is measurable without touching the runtime and the second is the one that can cost throughput.
Step one: a release profile
There is no [profile.release] section in the workspace at all, so every release binary is built with Cargo defaults: no LTO, codegen-units = 16, symbols kept.
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
Fifteen to thirty percent off the binary is the usual result, and LTO across crate boundaries tends to be slightly faster rather than slower. The cost is build time in CI, which matters here because a release compiles seven targets.
Not panic = "abort". Today a panic in a handler kills that task and the server keeps serving; with abort it takes the process down. A few hundred kilobytes do not buy that.
Step two: musl on distroless/static
The Dockerfile builds x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu and lands them on distroless/cc-debian12, which is where the ~10 MB goes. Building the musl targets instead and using distroless/static removes glibc and libgcc entirely, for well under a megabyte of base.
Nothing needs glibc at runtime: rustls is pinned to ring, the trust roots are bundled through webpki-roots rather than read from the system, and zstd is compiled in. The release workflow already produces musl binaries for both architectures, so the toolchain path is proven and only the image would change.
Two things to check rather than assume:
- The musl allocator is slow under heavy multithreaded allocation, which is exactly the shape of a streaming server moving four-megabyte frames through zstd.
bench/throughput.sh and the bench.yml workflow already measure this, so the acceptance condition is a run before and after on the same runner. If it moves, mimalloc as the global allocator is the usual answer, and that is a decision worth taking on its own evidence rather than smuggling in with an image change.
- DNS. musl resolves through
/etc/resolv.conf and does not do NSS. On Kubernetes that is what happens anyway, but the forge lookups depend on it, so it deserves a real check rather than an assumption.
What this is and is not worth
Operationally, close to nothing: eight megabytes pulled once per node, for a server whose job is to hold gigabytes. What it does buy is the claim in the README being true with a number attached, a smaller runtime surface, and parity with the sub-10 MB figure the nearest alternative advertises.
It ranks below a comparative benchmark harness, and it is small, self-contained and entirely measurable, which is why it is worth writing down rather than doing by feel.
The published image is 15.4 MB compressed (measured on
ghcr.io/ferrlabs/lfsx:latest): roughly 10 MB ofdistroless/cc-debian12and 5.5 MB of binary. The release artifacts agree,lfsx-server-x86_64-unknown-linux-gnu.tar.gzis 5.21 MB and the musl one 5.41 MB.Two independent steps get that to somewhere around 5 to 6 MB. They are worth doing in this order, because the first is measurable without touching the runtime and the second is the one that can cost throughput.
Step one: a release profile
There is no
[profile.release]section in the workspace at all, so every release binary is built with Cargo defaults: no LTO,codegen-units = 16, symbols kept.Fifteen to thirty percent off the binary is the usual result, and LTO across crate boundaries tends to be slightly faster rather than slower. The cost is build time in CI, which matters here because a release compiles seven targets.
Not
panic = "abort". Today a panic in a handler kills that task and the server keeps serving; with abort it takes the process down. A few hundred kilobytes do not buy that.Step two: musl on
distroless/staticThe Dockerfile builds
x86_64-unknown-linux-gnuandaarch64-unknown-linux-gnuand lands them ondistroless/cc-debian12, which is where the ~10 MB goes. Building the musl targets instead and usingdistroless/staticremoves glibc and libgcc entirely, for well under a megabyte of base.Nothing needs glibc at runtime: rustls is pinned to ring, the trust roots are bundled through webpki-roots rather than read from the system, and zstd is compiled in. The release workflow already produces musl binaries for both architectures, so the toolchain path is proven and only the image would change.
Two things to check rather than assume:
bench/throughput.shand thebench.ymlworkflow already measure this, so the acceptance condition is a run before and after on the same runner. If it moves, mimalloc as the global allocator is the usual answer, and that is a decision worth taking on its own evidence rather than smuggling in with an image change./etc/resolv.confand does not do NSS. On Kubernetes that is what happens anyway, but the forge lookups depend on it, so it deserves a real check rather than an assumption.What this is and is not worth
Operationally, close to nothing: eight megabytes pulled once per node, for a server whose job is to hold gigabytes. What it does buy is the claim in the README being true with a number attached, a smaller runtime surface, and parity with the sub-10 MB figure the nearest alternative advertises.
It ranks below a comparative benchmark harness, and it is small, self-contained and entirely measurable, which is why it is worth writing down rather than doing by feel.