Slim the image with package, cabal store, and symbol cleanup - #42
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR reduces the size of the haskell-tools container image by removing unnecessary Debian packages and aggressively trimming debug/build artifacts produced during tool compilation, while preserving runtime requirements (notably for HLS).
Changes:
- Removed
libgmp10,libtinfo6,wget, andfilefrom the base apt install list to avoid redundantly marking transitive/base packages as manually installed. - Reduced Cabal store footprint after compiling HLS by deleting compilation-only artifacts and stripping shared libraries / executables.
- Stripped Cabal-installed tool binaries (weeder/fourmolu/ghcid/hlint/shellcheck/stan) to reduce final image size.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+95
to
+97
| find ~/.local/state/cabal/store \( -name '*.a' -o -name '*.hi' -o -name '*.dyn_hi' \) -delete && \ | ||
| find ~/.local/state/cabal/store -name '*.so' -exec strip --strip-unneeded '{}' + && \ | ||
| strip /usr/local/.ghcup/bin/haskell-language-server-* |
Member
Author
There was a problem hiding this comment.
This is resolved by setting the XDG_HOME_STATE of the hls build. This leaves the cabal store completely empty, which is more desirable I feel
libgmp10 and libtinfo6 are already present in the pinned dhi.io/debian-base:trixie-dev image (verified with dpkg-query against the digest in the FROM line), and are in any case pulled in as transitive dependencies of libgmp-dev and libncurses-dev (https://packages.debian.org/trixie/libgmp-dev, https://packages.debian.org/trixie/libncurses-dev), so listing them only re-marked base packages as manually installed. wget and file were added in 593a4ee for the get-ghcup.sh bootstrap script, which used curl as its default downloader with wget only as an opt-in alternative (BOOTSTRAP_HASKELL_DOWNLOADER) and did not use file at all. That script was replaced in d828de6 by a direct curl fetch of a pinned ghcup binary. GHCup's documented Debian requirements (https://www.haskell.org/ghcup/install/#system-requirements) include neither package, and a sweep of every repo in the mothership manifest found nothing that runs wget or file inside this image: the compose healthchecks that invoke wget all target release-runtime images that install their own, and the wget-using maintenance scripts (fetch_postals, telamon's tzmap update) are host scripts that also need tools this image has never shipped (unzip, python3). The one exception, api-client-libraries installing yq via wget in an inline Dockerfile, will be converted to curl before that repo picks up a tag built from this commit. Compiling HLS was leaving 1.7GB of cabal build state in the final image: the layer's rm -rf ~/.cache predates cabal's switch to XDG directories (cabal >= 3.10, https://cabal.readthedocs.io/en/latest/config.html), which moved the store to ~/.local/state/cabal. The compiled hls executable is dynamically linked against the store's .so libraries, so a store must ship in the image, but its .a and .hi files exist only for compiling against the store. Because the default store path is also where downstream cabal builds (run as root in these containers) resolve already-installed packages, pruning it in place could break a build that resolves a dependency to a unit hash already in the store. The hls build therefore redirects XDG_STATE_HOME to give hls a store of its own under /usr/local/.ghcup (the path is baked into the binary's RUNPATH at link time), leaving the default store empty for downstream use. With the store isolated, deleting the .a and .hi files and stripping the .so libraries takes it from 1.7GB to 683MB. cabal also does not strip the executables it installs (unlike the stack and ghciwatch binaries, which already arrive stripped), and debug symbols account for roughly a third of each: stripping takes fourmolu from 109MB to 69MB and hlint from 96MB to 63MB. Each tool stage now strips what it built, as does the hls layer. The base stage was rebuilt to confirm the trimmed apt layer still resolves libgmp/libtinfo/libncurses via ldconfig -p, which stack's GHC bindist selection depends on. The store and strip commands were verified by running them in the previously built image and exercising every tool afterwards, including hls dynamically loading its stripped libraries. Altogether this removes about 1.2GB from a 5.03GB image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onslaughtq
force-pushed
the
slim-down-image
branch
from
August 7, 2026 19:56
2c9f089 to
519cea3
Compare
onslaughtq
marked this pull request as ready for review
August 7, 2026 19:59
PR feedback asked whether cabal could handle the stripping instead of each tool stage running strip afterwards. It can: --enable-executable-stripping makes cabal run strip as part of installing the executable, and a test against the cabal-3.14.2.0 in this image confirmed the flag produces a stripped binary while the v2 install default leaves symbols in. This also means a stage now fails at flag parsing rather than depending on a glob if the install layout ever changes. The hls layer keeps its explicit find/strip: ghcup copies the hls binaries into .ghcup/bin and leaves the runtime libraries in the cabal store outside of cabal's install step, which is where the install-time stripping flags take effect, so the manual strip there is the mechanism that has been verified end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
libgmp10 and libtinfo6 are already present in the pinned
dhi.io/debian-base:trixie-dev image (verified with dpkg-query against
the digest in the FROM line), and are in any case pulled in as
transitive dependencies of libgmp-dev and libncurses-dev
(https://packages.debian.org/trixie/libgmp-dev,
https://packages.debian.org/trixie/libncurses-dev), so listing them
only re-marked base packages as manually installed.
wget and file were added in 593a4ee for the get-ghcup.sh bootstrap
script, which used curl as its default downloader with wget only as an
opt-in alternative (BOOTSTRAP_HASKELL_DOWNLOADER) and did not use file
at all. That script was replaced in d828de6 by a direct curl fetch of a
pinned ghcup binary. GHCup's documented Debian requirements
(https://www.haskell.org/ghcup/install/#system-requirements) include
neither package, and a sweep of every repo in the mothership manifest
found nothing that runs wget or file inside this image: the compose
healthchecks that invoke wget all target release-runtime images that
install their own, and the wget-using maintenance scripts
(fetch_postals, telamon's tzmap update) are host scripts that also need
tools this image has never shipped (unzip, python3). The one exception,
api-client-libraries installing yq via wget in an inline Dockerfile,
will be converted to curl before that repo picks up a tag built from
this commit.
Compiling HLS was leaving 1.7GB of cabal build state in the final
image: the layer's rm -rf ~/.cache predates cabal's switch to XDG
directories (cabal >= 3.10,
https://cabal.readthedocs.io/en/latest/config.html), which moved the
store to ~/.local/state/cabal. The compiled hls executable is
dynamically linked against the store's .so libraries, so a store must
ship in the image, but its .a and .hi files exist only for compiling
against the store.
Because the default store path is also where
downstream cabal builds (run as root in these containers) resolve
already-installed packages, pruning it in place could break a build
that resolves a dependency to a unit hash already in the store. The
hls build therefore redirects XDG_STATE_HOME to give hls a store of
its own under /usr/local/.ghcup (the path is baked into the binary's
RUNPATH at link time), leaving the default store empty for downstream
use. With the store isolated, deleting the .a and .hi files and
stripping the .so libraries takes it from 1.7GB to 683MB.
cabal also does not strip the executables it installs (unlike the
stack and ghciwatch binaries, which already arrive stripped), and
debug symbols account for roughly a third of each: stripping takes
fourmolu from 109MB to 69MB and hlint from 96MB to 63MB. Each tool
stage now strips what it built, as does the hls layer.
The base stage was rebuilt to confirm the trimmed apt layer still
resolves libgmp/libtinfo/libncurses via ldconfig -p, which stack's GHC
bindist selection depends on. The store and strip commands were
verified by running them in the previously built image and exercising
every tool afterwards, including hls dynamically loading its stripped
libraries. Altogether this removes about 1.2GB from a 5.03GB image.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com