Skip to content

Slim the image with package, cabal store, and symbol cleanup - #42

Merged
onslaughtq merged 2 commits into
mainfrom
slim-down-image
Aug 7, 2026
Merged

Slim the image with package, cabal store, and symbol cleanup#42
onslaughtq merged 2 commits into
mainfrom
slim-down-image

Conversation

@onslaughtq

@onslaughtq onslaughtq commented Aug 7, 2026

Copy link
Copy Markdown
Member

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and file from 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 thread Dockerfile Outdated
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-*

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
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>

@telser telser left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great saving!

@onslaughtq
onslaughtq merged commit 4917bcd into main Aug 7, 2026
3 checks passed
@onslaughtq
onslaughtq deleted the slim-down-image branch August 7, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants