Haskell and Compilation and Static Linking and Nix

Posted on 29 July 2023 by vkraven

I absolutely adore Haskell. I wouldn’t consider myself an excellent Haskell programmer, but it brings me joy to conceive and re-conceive of solutions within Haskell’s paradigm.

What doesn’t bring me joy about Haskell is the build system - i.e., the deprecated Haskell Platform, Cabal, and all the wrappers around Cabal. Packages that depend on each other, but also on base, with some latest packages updated and others not. Argh!

This is a common enough occurrence. A nice person says:

“Oh! You’re writing a shell script. Why don’t you use ShellCheck to help you?”

With the response:

“Uhh yeah but I’m building X from source, and pulling in ShellCheck means I have to compile Haskell…”

Since switching over to Nix, I’ve managed to come up with a decent Nix shell that I’m comfortable with, eliminating all those frustrating build complications that I ran into during my early Haskell days. However, something I had never quite figured out to my satisfaction was building a statically-linked binary for using on another system.

That changes today with Nix!

Haskell’s “Fake” -static Flag

One of my earlier unpleasant surprises went like so. Observe these lines in cabal build --help:

$ cabal build --help
Compile targets within the project.

Usage: cabal build [TARGETS] [FLAGS]

...

Flags for build:
 ...
  
 --enable-static                Enable Static library
 --disable-static               Disable Static library
 --enable-executable-dynamic    Enable Executable dynamic linking
 --disable-executable-dynamic   Disable Executable dynamic linking
 --enable-executable-static     Enable Executable fully static linking
 --disable-executable-static    Disable Executable fully static linking
 
 ...

Look look, static linking flags!

Switch them on, however, and you still end up with a binary that ldd reports as relying on:

  • libz
  • libgmp
  • libc
  • libm
  • librt
  • libffi

How frustrating. Turns out a few things contribute to this sad and very un-static state of affairs.

  • glibc makes it hard to statically link, and Haskell links to libc.
  • The Haskell runtime almost always requires libgmp, zlib, libffi, etc., and these libraries are usually built dynamically on a desktop distro.
  • The Static flags lead Cabal to statically link to Haskell libraries as far as possible, but not dynamic system libraries.

The Old Containers Solution

Since we have a rough idea of the root causes of the problem, it seems like the a solution would work like so:

  • glibc: Build Haskell programs against a different libc, like Musl
  • Dynamic system libs: Ensure that the .a static libs are available in the environment, and link against them with the -static flags.

The simplest way to achieve this would be to have a VM or container with a musl-based distro, cross your fingers and hope that your desired Cabal wrapper (like Stack) runs in that environment, and build all your distributable binaries there.

I used to keep a lovely Alpine container around just for this purpose. Frustratingly, Stack (which used to be my Cabal-wrapper of choice), does not work reliably in that environment, which led to a lot of pinning to ancient Stackage LTS versions, manually replacing Stack-pulled dependencies with Cabal ones, and manually running ghc -o Main Main.hs.

Others have also released Dockerfiles following this approach: see haskell-static-alpine

The Nix Flakes Solution

Since switching over to Nix, however, a whole world of opportunities has revealed itself!

Nix already has good support for “outside-the-norm” compilation processes, such as arch cross-compilation (through pkgsCross), cross-platform compilation (linux vs darwin), etc. Turns out Nix also has a pkgsStatic and a Musl-libc-linked pkgsMusl set! Nix commands are also akin to esoteric incantations, so I’ll attempt here to explain how I made it work in my own words:

  1. Invoke the pkgsMusl set of derivations instead of just <nixpkgs>’s pkgs (this solves libc)
    • I.e., where pkgs used to be <nixpkgs>, set it to <nixpkgs>.pkgsMusl
  2. Tell Cabal not to build shared executables or shared libraries
  3. Pass the -static flag to Cabal, so it passes it to ghc
  4. Expose the static versions of the classic GHC system libraries (e.g. zlib, libgmp) to Cabal

Here’s an example that goes through each step in a recent Nix flake I made. My original dynamically linked flake.nix:

{
  description = ""; # Add description

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let pkgs = nixpkgs.legacyPackages.${system};
            haskellPackages = pkgs.haskell.packages.ghc928;
            packageName = ""; # Add package name
            jailbreakUnbreak = pkg: pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; }));
        in
        {
          packages.${packageName} = haskellPackages.callCabal2nix packageName self rec {};

          defaultPackage = self.packages.${system}.${packageName};
          devShell = pkgs.mkShell {
            buildInputs = with haskellPackages; [
              haskell-language-server
              cabal-install
              cabal2nix
            ] ++ [ pkgs.zlib ];
            inputsFrom = builtins.attrValues self.packages.${system};
          };
        }
      );
}

Invoke pkgsMusl instead of pkgs

Replace

pkgs = nixpkgs.legacyPackages.${system};

with

pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;

Tell Cabal not to build Shared Executables or Libraries

First, move the callCabal2nix function invocation into the let block:

...

flake-utils.lib.eachDefaultSystem
      (system:
        let pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;
            haskellPackages = pkgs.haskell.packages.ghc928;
            packageName = "swankybar";
            jailbreakUnbreak = pkg: pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; }));
            inherit (pkgs.haskell.lib) appendConfigureFlags justStaticExecutables; # <- Need this for the next step
            mypackage = haskellPackages.callCabal2nix packageName self rec {}; # <- Moved here
            
            ...

Next, in the in block, use pkgs.haskell.lib.overrideCabal to override attributes in callCabal2nix. Disable shared executables and libraries.

let pkgs = ... 

...

in
{
  packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
    enableSharedExecutables = false; # <- Added
    enableSharedLibraries = false;   # <- Added
  });

    ...
}

Pass the -static flag to Cabal

In the overrideCabal call, add some configure flags:

let pkgs = ... 

...

in
{
  packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
    enableSharedExecutables = false;
    enableSharedLibraries = false;
    configureFlags = [
      "--ghc-option=-optl=-static"  # <- Added
    ];
  });

    ...
}

Expose the Static system libraries to Cabal

In the overrideCabal call, add some more configure flags:

let pkgs = ... 

...

in
{
  packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
    enableSharedExecutables = false;
    enableSharedLibraries = false;
    configureFlags = [
      "--ghc-option=-optl=-static"
      "--extra-lib-dirs=${pkgs.gmp6.override { withStatic = true; }}/lib"  # <- Added for libgmp
      "--extra-lib-dirs=${pkgs.zlib.static}/lib" # <- Added for zlib
      "--extra-lib-dirs=${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib" # <- Added for libffi
      "--extra-lib-dirs=${pkgs.ncurses.override { enableStatic = true; }}/lib" # <- Added for libcurses
    ];
  });

    ...
}

Summary

If you’re comfortable reading diffs, here’s all I changed.

diff flake.nix flake-static.nix
12c12
<         let pkgs = nixpkgs.legacyPackages.${system};
---
>         let pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;
15a16,18
>             inherit (pkgs.haskell.lib) appendConfigureFlags justStaticExecutables;
>             mypackage = haskellPackages.callCabal2nix packageName self rec {
>             };
18c21,31
<           packages.${packageName} = haskellPackages.callCabal2nix packageName self rec {};
---
>           packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
>             enableSharedExecutables = false;
>             enableSharedLibraries = false;
>             configureFlags =     [
>                   "--ghc-option=-optl=-static"
>                   "--extra-lib-dirs=${pkgs.gmp6.override { withStatic = true; }}/lib"
>                   "--extra-lib-dirs=${pkgs.zlib.static}/lib"
>                   "--extra-lib-dirs=${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib"
>                   "--extra-lib-dirs=${pkgs.ncurses.override { enableStatic = true; }}/lib"
>             ];
>           });

The final flake.nix file looks like this:

{
  description = ""; # Add description

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let pkgs = nixpkgs.legacyPackages.${system}.pkgsMusl;
            haskellPackages = pkgs.haskell.packages.ghc928;
            packageName = ""; # Add package name
            jailbreakUnbreak = pkg: pkgs.haskell.lib.doJailbreak (pkg.overrideAttrs (_: { meta = { }; }));
            inherit (pkgs.haskell.lib) appendConfigureFlags justStaticExecutables;
            mypackage = haskellPackages.callCabal2nix packageName self rec {
            };
        in
        {
          packages.${packageName} = pkgs.haskell.lib.overrideCabal mypackage (old: {
            enableSharedExecutables = false;
            enableSharedLibraries = false;
            configureFlags =     [
                  "--ghc-option=-optl=-static"
                  "--extra-lib-dirs=${pkgs.gmp6.override { withStatic = true; }}/lib"
                  "--extra-lib-dirs=${pkgs.zlib.static}/lib"
                  "--extra-lib-dirs=${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib"
                  "--extra-lib-dirs=${pkgs.ncurses.override { enableStatic = true; }}/lib"
            ];
          });

          defaultPackage = self.packages.${system}.${packageName};
          devShell = pkgs.mkShell {
            buildInputs = with haskellPackages; [
              haskell-language-server
              cabal-install
              cabal2nix
            ] ++ [ pkgs.zlib ];
            inputsFrom = builtins.attrValues self.packages.${system};
          };
        }
      );
}

Results

And after a quick nix build (no, it wasn’t quick. It bootstrapped a whole bunch of Musl executables and libraries, as one might expect from a cross-compile), I finally saw that wondrous declaration from ldd:

$ ldd result/bin/output
        not a dynamic executable

Simply beautiful.