From 519cea3a84b1c473bb9d66226538b430d985bdde Mon Sep 17 00:00:00 2001 From: Paul Burns Date: Fri, 7 Aug 2026 14:21:26 -0400 Subject: [PATCH 1/2] Slim the image with package, cabal store, and symbol cleanup 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 --- Dockerfile | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index dff22a4..c674a17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,8 @@ ENV GHCUP_INSTALL_BASE_PREFIX=/usr/local RUN apt-get update \ && apt-get install -qq -y --no-install-recommends \ curl build-essential git libffi-dev libffi8 libgmp-dev \ - libgmp10 libncurses-dev libncurses6 libtinfo6 zlib1g-dev openssh-client \ - procps libnuma-dev pkg-config jq wget file \ + libncurses-dev libncurses6 zlib1g-dev openssh-client \ + procps libnuma-dev pkg-config jq \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && ldconfig @@ -82,10 +82,24 @@ FROM with-ghc-cabal AS with-hls # a particular release. We make sure to remove the cache as part of # building this layer to avoid extra space being taken up in the final # image. +# +# The hls executable is dynamically linked against the .so libraries in +# the cabal store it was built from, so that store must ship in the +# image. XDG_STATE_HOME gives the build a store of its own: the default +# store (~/.local/state/cabal/store) is also where cabal builds run in +# downstream containers (as root) resolve already-installed packages, +# so it must not contain packages whose compile- and link-time +# artifacts have been pruned. With the store isolated, everything hls +# does not load at runtime (.a and .hi files) is deleted and the .so +# libraries are stripped. ARG HLS_VERSION -RUN ghcup compile hls -g "$HLS_VERSION" --ghc "$GHC_VERSION" --cabal-update -- --flags="-hlint" && \ +RUN XDG_STATE_HOME=/usr/local/.ghcup/hls-cabal \ + ghcup compile hls -g "$HLS_VERSION" --ghc "$GHC_VERSION" --cabal-update -- --flags="-hlint" && \ ghcup gc --share-dir --tmpdirs && \ - rm -rf ~/.cache + rm -rf ~/.cache && \ + find /usr/local/.ghcup/hls-cabal/cabal/store \( -name '*.a' -o -name '*.hi' -o -name '*.dyn_hi' \) -delete && \ + find /usr/local/.ghcup/hls-cabal/cabal/store -name '*.so' -exec strip --strip-unneeded '{}' + && \ + strip /usr/local/.ghcup/bin/haskell-language-server-* # Each tool below is built in its own stage (all versions are managed in # tool-versions.env) so that bumping one tool's version rebuilds only that @@ -98,6 +112,10 @@ RUN ghcup compile hls -g "$HLS_VERSION" --ghc "$GHC_VERSION" --cabal-update -- - # version we want of a tool cannot compile with our lts. Since these # tools are all binary executables copied into the final image they # don't need to share dependency versions with each other or the lts. +# +# cabal does not strip the executables it installs, and the debug +# symbols account for roughly a third of each binary, so each stage +# strips what it built before the final image copies it in. FROM base AS tool-ghciwatch ARG GHCIWATCH_VERSION @@ -107,27 +125,27 @@ RUN curl --fail -Lo /ghciwatch \ FROM with-ghc-cabal AS tool-weeder ARG WEEDER_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "weeder-$WEEDER_VERSION" +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "weeder-$WEEDER_VERSION" && strip /tool-bin/* FROM with-ghc-cabal AS tool-fourmolu ARG FOURMOLU_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "fourmolu-$FOURMOLU_VERSION" +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "fourmolu-$FOURMOLU_VERSION" && strip /tool-bin/* FROM with-ghc-cabal AS tool-ghcid ARG GHCID_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "ghcid-$GHCID_VERSION" +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "ghcid-$GHCID_VERSION" && strip /tool-bin/* FROM with-ghc-cabal AS tool-hlint ARG HLINT_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "hlint-$HLINT_VERSION" +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "hlint-$HLINT_VERSION" && strip /tool-bin/* FROM with-ghc-cabal AS tool-shellcheck ARG SHELLCHECK_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "ShellCheck-$SHELLCHECK_VERSION" +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "ShellCheck-$SHELLCHECK_VERSION" && strip /tool-bin/* FROM with-ghc-cabal AS tool-stan ARG STAN_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "stan-$STAN_VERSION" +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "stan-$STAN_VERSION" && strip /tool-bin/* FROM with-hls AS final From a3924083b0f82e170d5419fb19091960ec0e73b7 Mon Sep 17 00:00:00 2001 From: Paul Burns Date: Fri, 7 Aug 2026 16:16:22 -0400 Subject: [PATCH 2/2] Let cabal strip the tool executables itself 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 --- Dockerfile | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index c674a17..c5701f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -113,9 +113,11 @@ RUN XDG_STATE_HOME=/usr/local/.ghcup/hls-cabal \ # tools are all binary executables copied into the final image they # don't need to share dependency versions with each other or the lts. # -# cabal does not strip the executables it installs, and the debug -# symbols account for roughly a third of each binary, so each stage -# strips what it built before the final image copies it in. +# cabal does not strip executables by default, and the debug symbols +# account for roughly a third of each binary, so every stage passes +# --enable-executable-stripping. The hls layer above strips explicitly +# instead because ghcup places its binaries and libraries outside +# cabal's install step, which is where this flag takes effect. FROM base AS tool-ghciwatch ARG GHCIWATCH_VERSION @@ -125,27 +127,27 @@ RUN curl --fail -Lo /ghciwatch \ FROM with-ghc-cabal AS tool-weeder ARG WEEDER_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "weeder-$WEEDER_VERSION" && strip /tool-bin/* +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin --enable-executable-stripping "weeder-$WEEDER_VERSION" FROM with-ghc-cabal AS tool-fourmolu ARG FOURMOLU_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "fourmolu-$FOURMOLU_VERSION" && strip /tool-bin/* +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin --enable-executable-stripping "fourmolu-$FOURMOLU_VERSION" FROM with-ghc-cabal AS tool-ghcid ARG GHCID_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "ghcid-$GHCID_VERSION" && strip /tool-bin/* +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin --enable-executable-stripping "ghcid-$GHCID_VERSION" FROM with-ghc-cabal AS tool-hlint ARG HLINT_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "hlint-$HLINT_VERSION" && strip /tool-bin/* +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin --enable-executable-stripping "hlint-$HLINT_VERSION" FROM with-ghc-cabal AS tool-shellcheck ARG SHELLCHECK_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "ShellCheck-$SHELLCHECK_VERSION" && strip /tool-bin/* +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin --enable-executable-stripping "ShellCheck-$SHELLCHECK_VERSION" FROM with-ghc-cabal AS tool-stan ARG STAN_VERSION -RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin "stan-$STAN_VERSION" && strip /tool-bin/* +RUN cabal update && cabal install --install-method=copy --installdir=/tool-bin --enable-executable-stripping "stan-$STAN_VERSION" FROM with-hls AS final