Building static Haskell binary with Nix on Linux
In this post, I’ll try to explain what are dynamic libraries and static executable, how they work what are there strengths/weaknesses.
I’ll also show how to create the latter with Nix on Linux.
Introduction
PatchGirl is a rest client that works directly in your browser. But because browsers save users from security issues (i.e: CORS, same origin policy), some features couldn’t be implemented in a web app.
So I created the patchgirl-runner app which is an executable that runs on the user computer and overcome those limitations.
The complete project looks like this:
I wanted Patchgirl-runner to be easy to use. Ideally, you would just have to download it and run it. But because it is written in Haskell, it was natively compiled to a dynamic executable.
Dynamic libraries
By default, when you compile your Haskell program to an executable it will require dynamic libraries to work. This means that your executable cannot work alone.
Visualizing dynamic libraries
Let’s take a simple example to explain how it works. Let’s create a basic project:stack new HelloWorld
This project has 2 files:
-- src/lib.hs
module Lib
( someFunc
) where
someFunc :: IO ()
someFunc = putStrLn "someFunc"
-- app/Main.hs
module Main where
import Lib
main :: IO ()
main = someFunc
Now let’s build our project:
stack build
stack install # copy the generated executable to a folder in your $PATH (e.g: ~/.local/bin/HelloWorld-exe)
This executable is not a standalone binary, meaning that you can’t just copy it to another computer and expect it to work. Indeed, it requires dynamic libraries.
If we want to show this executable’s dependencies we can run the command ldd
which given its manual “print shared object dependencies” (you can replace shared object by dynamic library).
So let’s run it:
% ldd HelloWorld-exe
linux-vdso.so.1 (0x00007ffc3fdba000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f419e709000)
libgmp.so.10 => /lib/x86_64-linux-gnu/libgmp.so.10 (0x00007f419e688000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f419e67d000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f419e677000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f419e654000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f419e463000)
/lib64/ld-linux-x86-64.so.2 (0x00007f419e871000)
This means that this executable requires libm.so.6
, libgmp.so.10
, libc.so.6
,… Those are dynamic libraries.
One way to tell whether a library is dynamic is the extension .so
(i.e shared object).
When you run your executable, these libraries will also be loaded and accessible to your program. I’m not going to describe them all but to in a nutshell, libm
provides mathematic functions like abs
, div
or cos
…libgmp
provides arbitrary precision arithmetic, operating on signed integers, rational numbers, and floating-point numbers… This libraries are part of a more global library glibc
that was splitted.
Dynamic libraries pros and cons
Dynamic libraries have some advantages. One of them is the executable size.
These libraries are shared by all executables which need them. That means you can have lightweight executables because they doesn’t include libraries.
An other nice advantage is maintainability. If many programs depends on a library with security issues or bugs, you will only need to upgrade the culprit library to fix them all.
On the other hand, you cannot distribute your executable easily to your customer. If you copy the executable on another computer, it will most likely fail to run because the dynamic libraries it requires are not present.
Which brings us to static binary.
Building a standalone executable
Dynamic libraries are not great when it comes to make application usage/installation easy. This is even more true on Linux distributions where each distribution has it own way of packaging a software (e.g: dpgk, rpm, yum, snap, flatpak…)
If we want to provide a standalone executable to simplify the developer and the customers’ life, we should generate a static executable instead.
Static executable
nb: When I refer to a static executable, I mean an executable which doesnt require dynamic libraries.
If we want to provide an executable without dependency, we’d rather make it completely static (i.e: running ldd
on it should return nothing). One way of doing this is to tweak Cabal/Stack/whatever building tool you are using and set it up to build static binary. But we unfortunately can’t just stop here. Indeed, even if you build a static executable with this solution, you might not be able to ship your binary to another platform.
The reason is that your static binary will have been compiled against a specific version of glibc which might not be the same on your the targeted computer. That means that the API your executable is going to use could be incompatible with the kernel.
So can we overcome this issue ? On GNU/linux operating systems, we can thanks to musl.
Musl
musl is an implementation of the C standard library built on top of the Linux system call API, including interfaces defined in the base language standard, POSIX, and widely agreed-upon extensions. musl is lightweight, fast, simple, free, and strives to be correct in the sense of standards-conformance and safety.
In a nutshell, musl is another implementation of the libc. It has the nice advantage of providing a single API so whatever program compiled statically against musl should theorically work on any GNU/Linux platforms.
Cool, so musl looks like a great solution! How do we use it in our project. Well GHC is traditionally compiled against glibc so every time you compile with GHC, it will make it glibc dependent… The solution is to compile GHC with musl!
This looks like a difficult job, Fortunately @nh2 has already done the job with static-haskell-nix
static-haskell-nix
Static-haskell-nix’s purpose is to build fully static haskell executables for linux. It uses a lot of Nix machinery so it might not be super easy for beginners.
It provides multiple solutions to generate your executable. The easiest one is to use stack but I won’t describe it. I tried it and failed because of some incompatibility with recent version of stack.
Instead, we are going to write some Nix code to use with static-haskell-nix.
Building a fully static haskell executable
Requirements
Alright, here is the requirements:
- simple project that uses a recent version of stack
- our project should be split in 2 packages, the library and the executable
- the executable package should depend on the library
- our project should use postgresql-simple (meaning we will have to generate an executable that embed the libpq library)
Project architecture
Ok, so just like before let’s generate a simple stack project by running:stack new HelloWorld
Let’s modify our stack.yaml
so we have 2 packages and a recent resolver version:
# stack.yaml
resolver: lts-15.13
packages:
- hello-world-lib/
- hello-world-app/
Let’s create both packages’s package.yaml
file:
# hello-world-lib/package.yml
name: hello-world-lib
library:
source-dirs:
- src
dependencies:
- base
- postgresql-simple
# hello-world-app/package.yml
name: hello-world-app
executables:
hello-world-app-exe:
main: app/Main.hs
dependencies:
- hello-world-lib
dependencies:
- base
So that was the easy part. We can check that everything works by running stack build
. We can also check that the executable generated isn’t static by running:
stack install
ldd ~/.local/bin/hello-world-app-exe
linux-vdso.so.1 (0x00007ffed50fb000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fd92c9ce000)
libpq.so.5 => /lib/x86_64-linux-gnu/libpq.so.5 (0x00007fd92c982000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007fd92c977000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd92c6d5000)
libssl.so.1.1 => /lib/x86_64-linux-gnu/libssl.so.1.1 (0x00007fd92c643000)
libcrypto.so.1.1 => /lib/x86_64-linux-gnu/libcrypto.so.1.1 (0x00007fd92c36e000)
libkrb5support.so.0 => /lib/x86_64-linux-gnu/libkrb5support.so.0 (0x00007fd92c1a5000)
... truncated for brevity
The output shows that adding postgresql-simple
as a dependency added other dynamic libraries like libpq
. This executable works fine but we want it to be fully static. It’s time to play with Nix and static-haskell-nix
!
Static build script with Nix
As we said before, we are not going to use the stack
part of static-haskell-nix. Instead we are relying on the generated Cabal files (i.e: hello-world-lib.cabal
and hello-world-app.cabal
) from our Nix Script.
Our build script was inspired a lot by postgrest build script.
Our program will have two main scripts.
default.nix will pin nixpkgs and define where are our packages:
# default.nix
let
# We are using lts-15.13 stack resolver which uses ghc883 (cf: https://www.stackage.org/lts-15.13)
compiler = "ghc883";
# pin nixpkgs for reproducible build
nixpkgsVersion = import nix/nixpkgs-version.nix;
nixpkgs =
builtins.fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/${nixpkgsVersion.rev}.tar.gz";
sha256 = nixpkgsVersion.tarballHash;
};
# overlays define packages we need to build our project
allOverlays = import nix/overlays;
overlays = [
allOverlays.gitignore # helper to use gitignoreSource
(allOverlays.haskell-packages { inherit compiler; })
];
pkgs = import nixpkgs { inherit overlays; };
# We define our packages by giving them names and a list of source files
hello-world-lib = {
name = "hello-world-lib";
src = pkgs.lib.sourceFilesBySuffices (pkgs.gitignoreSource ./hello-world-lib)[ ".cabal" ".hs" ".lhs" "LICENSE" ];
};
hello-world-app = {
name = "hello-world-app";
src = pkgs.lib.sourceFilesBySuffices (pkgs.gitignoreSource ./hello-world-app)[ ".cabal" ".hs" ".lhs" "LICENSE" ];
};
# Some patches are unfortunately necessary to work with libpq
patches = pkgs.callPackage nix/patches {};
lib = pkgs.haskell.lib;
# call our script which add our packages to nh2/static-haskell-nix project
staticHaskellPackage = import nix/static-haskell-package.nix { inherit nixpkgs compiler patches allOverlays; } hello-world-lib hello-world-app;
in
rec {
inherit nixpkgs pkgs;
hello-world-app-static = lib.justStaticExecutables (lib.dontCheck staticHaskellPackage.hello-world-app);
}
static-haskell-package.nix will define how to add our haskell packages to the static-haskell-nix build script.
# nix/static-haskell-package.nix
# Derive a fully static Haskell package based on musl instead of glibc.
{ nixpkgs, compiler, patches, allOverlays }:
# this file returns a function that takes in parameter the 2 package sources we want to build
hello-world-lib: hello-world-app:
let
# pin nh2/static-haskell-nix project with a commit revision
# this make sure we will always use the same version of the nh2/static-haskell-nix
static-haskell-nix =
let
rev = "749707fc90b781c3e653e67917a7d571fe82ae7b";
in
builtins.fetchTarball {
url = "https://github.com/nh2/static-haskell-nix/archive/${rev}.tar.gz";
sha256 = "155spda2lww378bhx68w6dxwqd5y6s9kin3qbgl2m23r3vmk3m3w";
};
# package that deals with posgresql needs a little patch from
# within nh2/patched-static-haskell-nix script
patched-static-haskell-nix = patches.applyPatches
"patched-static-haskell-nix"
static-haskell-nix
[
patches.static-haskell-nix-patchgirl-openssl-linking-fix
];
# Fix taken from https://github.com/PostgREST/postgrest/blob/43d71e95ac091aa77ac104de7fc881226d1a17f6/nix/static-haskell-package.nix
# I'm not too sure if there are really needed for this simple project
patchedNixpkgs = patches.applyPatches
"patched-nixpkgs"
nixpkgs
[
patches.nixpkgs-revert-ghc-bootstrap
patches.nixpkgs-openssl-split-runtime-dependencies-of-static-builds
];
lib = (import nixpkgs {}).haskell.lib;
# We are defining our package by calling callCabal2nix on the `package.yml` generated by stack
extraOverrides = final: prev:
rec {
"${hello-world-lib.name}" = lib.dontCheck (prev.callCabal2nix "${hello-world-lib.name}" hello-world-lib.src {});
"${hello-world-app.name}" = prev.callCabal2nix "${hello-world-app.name}" hello-world-app.src {};
};
# We make sure our package will be integrated in the nh2/patched-static-haskell-nix project
overlays = [
(allOverlays.haskell-packages { inherit compiler extraOverrides; })
];
normalPkgs = import patchedNixpkgs { inherit overlays; };
# each version of GHC needs a specific version of Cabal.
defaultCabalPackageVersionComingWithGhc = { ghc883 = "Cabal_3_2_0_0"; }."${compiler}";
# The static-haskell-nix 'survey' derives a full static set of Haskell
# packages, applying fixes where necessary.
survey = import "${patched-static-haskell-nix}/survey" { inherit normalPkgs compiler defaultCabalPackageVersionComingWithGhc; };
in
{
"${hello-world-lib.name}" = survey.haskellPackages."${hello-world-lib.name}";
"${hello-world-app.name}" = survey.haskellPackages."${hello-world-app.name}";
}
For most case these should suffice.
If you deal with libpq, you are likely to run into another issue. Because we use postgresql-simple we will need libpq.
So In order to fix it, we want to apply a patch. The patch is easy and solely consists in adding:
hello-world-lib =
addStaticLinkerFlagsWithPkgconfig
super.hello-world-lib
[ final.openssl final.postgresql ]
"--libs libpq";
hello-world-app =
addStaticLinkerFlagsWithPkgconfig
super.hello-world-app
[ final.openssl final.postgresql ]
"--libs libpq";
But this fix needs to be done to the nh2/static-haskell-nix build script.
So how can we edit it without polluting it with our dumb app? One solution is to fork it locally, edit it and save our edition to a patch file. This is what we are going to do.
git clone https://github.com/nh2/static-haskell-nix
cd static-haskell-nix/survey/
let’s edit survey/default.nix
and add to our packages the static library openssl and postgresql like above. Finally, let’s save the diff in a patch file:git diff survey/default.nix > /home/$USER/HelloWorld/nix/patches/hello-world-openssl-linking-fix.patch
Alright, we are done. We can now just build our static executable:nix-build -A hello-world-app-static
Please note that the first time you run this command, static-haskell-nix will build GHC against libmusl. It took roughly 8h to complete this task on my computer. Once it’s been compiled, it will be stored to your /nix/store and you won’t have to go through this computation again!
Let’s check it actually work:
ldd /nix/store/d191d3fwzhxi10qz0qbxlqisk86fqvlg-hello-world-app-0.0.0/bin/hello-world-app-exe
not a dynamic executable
Yay, it worked!! This executable is fully static and can be run to any Linux platforms
This complete sample project is available on this repo.
Conclusion
This solution was used to build the patchgirl-runner. You can check the full build script on this repo. patchgirl-runner can be downloaded here and used on any X86_64 linux OS
Other platforms
Nix isn’t supported by Windows platform so this solution can’t be used. For MacOS, it seems this field is quite recent and even if simple examples works, real world application won’t probably be trivial to generate.
I want patchgirl-runner to be available on MacOS and Windows in a near future so I hope I’ll find a solution and write a blog post about it
Last words
static-haskell-nix is a huge work that simplifies greatly the haskell static binary generation. It’s a bit hard to grasp if you’re not well versed into nix and haskell but I hope this article helped you understanding how to integrate your project with it.
ps: some resources were quite helpful to understand haskell static executable with nix
- vaibhavsagar blog
- postgrest repo which was the inspiration for our building script.