What’s kicking, internauts?

This is a blog post explaining how to build fully statically-linked OCaml executables in a Docker environment, then copy them out of your Docker environment into your filesystem.1

Prerequisites

This post assumes an “I run Linux on my computer” and “I write sofware in a programming language that compiles to machine code” level of tech-savviness. Although I develop in OCaml on Arch Linux, this post is nonetheless likely to be of interest if you are a developer who isn’t on Arch Linux, and it may very well even be of interest if you don’t use OCaml. If you have no idea what any of these computer words mean, please feel free to get in touch with me and I’ll be happy to write a follow-up post explaining whatever you’d like to know about.

Background

I like static linking, because I like being able to install a single-file executable on whatever machine I please, knowing that it won’t be prevented from running because some shared library isn’t installed on that machine. Having that assurance brings us one step closer to the glorious dream of being able to ship executables that are as portable as they can be, and provision production environments that are as reproducible as they can be.

It will perhaps surprise you to hear that not every single OS packager on planet Earth shares my dream. For example, glibc is a C library that every single Linux executable needs to link with, in order to as so much as execute. But glibc strongly recommends dynamic linking, and if you try to statically link with it, you’ll get a lot of warnings. It’s warnings rather than errors, so you will be able to forge ahead and do it, but you will be expected to keep your fingers crossed that the exact version of glibc you have installed to your system won’t accidentally do something glitchy at runtime. Wouldn’t it be nice not to have to cross your fingers?

In walks musl, a lightweight alternative to glibc, which (among other things) is designed not to get in your way if your goal is to produce a fully statically-linked executable. Linking with musl and nothing else suffices, if your program does not need to call into any C libraries other than glibc and cousins. However, many third-party OCaml libraries that one might want to use call into C libraries. For example, the ocurl library presents the OCaml developer with bindings into libcurl. So if you want to use that library in your OCaml program, the requirements for full static linking become a little more complicated, because now you have to figure out how to link not only with libcurl but also with all of its transitive dependencies.

In this blog post, I opt for the solution of building a binary inside of a Docker environment running Alpine Linux, then copying my build artefacts outside of the Docker image back onto my real filesystem. I will first demonstrate how to build a pure OCaml executable that links only with musl, and then give a few toy examples showing how to build a statically linked executable that calls into a third-party C library. I have yet to figure out way of automating this process with any generality, but I will share some advice for hopefully/mostly getting it to work.

Why Alpine Linux under Docker? Because the Alpine Linux folks have gone to the trouble of providing .a files for their C packages, wherever possible, giving you the means with which to statically link them. If you can’t easily get the .a files, you can’t really do this.

The Simple Case: Mere OCaml

Let’s set up a toy project demonstrating how to make this stuff work in the easy scenario. First, I need an executable module. I’m going to call mine pure-ocaml and put it in /tmp:

$ cd /tmp
$ mkdir pure-ocaml
$ cd pure-ocaml

I’ll call the executable /tmp/pure-ocaml/pure_ocaml_demo.ml, and because I literally can’t even with hello world, I’m going to have it print a classic movie quote:

let () = print_endline "I have fought my way here to the \
                        castle beyond the goblin city."

Next, I’ll set things up at the opam package level via /tmp/pure-ocaml/dune-project:

(lang dune 3.13)
(name pure-ocaml-demo)
(generate_opam_files true)
(package
 (name pure-ocaml-demo)
  (depends
    ocaml
    dune))

The last thing I need to build and run this thing is a dune file for the executable, at /tmp/pure-ocaml/dune:

(executable 
 (public_name pure-ocaml-demo) 
 (name pure_ocaml_demo)
 (promote (until-clean))) 

Here, I follow the cringe but standard OCaml practice of naming the project with hyphens, then replacing the hyphens with underscores in everything that needs to follow the naming conventions of an OCaml module. Where applicable, please feel free to apply a less cringe naming convention of your own devising.

In addition, note the (promote (until-clean)) option; that tells dune to place the executable in the project root, and not just in _build/default. Now, for a quick sanity check, I’ll build the project and run it:

$ dune build
$ dune exec pure-ocaml-demo
I have fought my way here to the castle beyond the goblin city.

Nothing arouses the senses like the smell of a fresh executable running. If I run ldd on it, I can see that it is dynamically linked:

$ ldd pure_ocaml_demo.exe 
	linux-vdso.so.1 (0x00007fc052d10000)
	libm.so.6 => /usr/lib/libm.so.6 (0x00007fc052b55000)
	libc.so.6 => /usr/lib/libc.so.6 (0x00007fc052800000)
	/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fc052d12000)

To get a fully statically-linked version of this binary, I’m going to create a Dockerfile that provisions an Alpline Linux environment in which to do this same build. Due to the wonders of Alpine Linux packaging, you don’t need to do anything special to do a build that links with musl rather than glibc. You just give ocamlopt the static linking options that it wants, and Alpine Linux takes care of the rest.

The first change on the way to making all that happen is to add an env stanza to /tmp/pure-ocaml/dune, adding an option to give ocamlopt the green light to statically link:

(executable 
 (public_name pure-ocaml-demo) 
 (name pure_ocaml_demo)
 (promote (until-clean))) 

(env
 (release
  (ocamlopt_flags (:standard))
  (link_flags (:standard -cclib -static))))

This creates a dune build profile called release, which will use the compiler options for static linking when you type dune build --release. Typing dune build without the --release option will still build a dynamically linked binary, if you’re in a situation where you need to build with the default options.

Next, I will create a Dockerfile at /tmp/pure-ocaml/Dockerfile to provision the Alpine Linux environment in which to build with this release profile:

FROM alpine:3.20 AS builder

RUN apk update && \
    apk upgrade && \
    apk add --no-cache build-base opam

RUN addgroup ocamldev && adduser -G ocamldev -D ocamldev

USER ocamldev

RUN opam init --bare -a -y --disable-sandboxing \
    && opam update

RUN opam switch create docker-switch 5.2.0

WORKDIR /app

COPY dune dune-project pure-ocaml-demo.opam pure_ocaml_demo.ml ./

RUN opam install . --deps-only --yes

RUN opam exec -- dune build --release

FROM alpine:3.2 AS runtime

COPY --from=builder /app/pure_ocaml_demo.exe /usr/local/bin/pure-ocaml-demo

CMD  [ "/usr/local/bin/pure-ocaml-demo" ]

Here’s a summary of what the Dockerfile does:

  • provision a new Alpine Linux 3.20 image
  • install opam to Alpine Linux using the apk package manager
  • create the ocamldev user in Alpine Linux
  • run opam init
  • create a fresh OCaml 5.2.0 opam switch
  • install all dependencies for the project, based on what’s listed in pure-ocaml-demo.opam
  • run a build with dune (note the --release option)
  • sock the executable away at /usr/local/bin inside of the Docker image

Let’s try it! (You will need to be connected to the internet, and to have Docker installed with the daemon running, for this command to work.) I’m going to pass the -t pure-ocaml-demo option to tell Docker to create an image called pure-ocaml-demo, and for every command that is required to pull data down from Docker registries, I’ll add --network=host, to make sure the data get pulled down via my machine’s actual network connection. I will then watch a bunch of stuff in approximately this vein get printed to the screen:

$ docker build --network=host --progress=plain -t pure-ocaml-demo .
#0 building with "default" instance using docker driver

#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 641B done
#1 DONE 0.0s

#2 [internal] load metadata for docker.io/library/alpine:3.2
#2 DONE 0.3s

... etc ...

Inquisitive eyes will notice that I also passed the --progress-plain option to docker build, in order to enjoy a meditative reprieve from cutesy ANSI animations. Please feel free to take it out, if cutesy ANSI animations are your thing.

The executable has been built, but to be able to use it, I need to be able to copy it out of my Docker image and onto my filesystem. I’ll use these commands for that:

$ docker create --name pure-ocaml-demo-extract pure-ocaml-demo
$ docker cp pure-ocaml-demo-extract:/usr/local/bin/pure-ocaml-demo ./musl/pure-ocaml-demo
$ docker rm pure-ocaml-demo-extract

These commands:

  • create a Docker container based on the pure-ocaml-demo image called pure-ocaml-demo-extract
  • copy the binary out of the image into the musl subdirectory of my project
  • remove the pure-ocaml-demo-extract container (since its job is now done)

Congratulations to me, proud new owner of a fully statically-linked binary. I can verify that it is statically linked with ldd:

$ ldd musl/pure-ocaml-demo 
	not a dynamic executable
$ echo $?
1

The magic phrase I’m looking for in the output of ldd, which tells me that this is a fully statically-linked binary, is not a dynamic executable. I should also expect ldd to return an exit status of 1. Now, I can run this executable the same way I’d run anything:

$ musl/pure-ocaml-demo
I have fought my way here to the castle beyond the goblin city.

Now I’ll be able to copy that binary wherever I please, knowing that if the machine I copied it onto has the Linux kernel, it’ll run. BOOYAH.

C Library Example #1: pcre

Things get a bit more annoying if one of your third-party libraries has a dependency on a C library. In that situation, you’ll need to link with the C library itself, and also with all of its transitive dependencies.

To get this party started, I’m going to create a new project with the same structure as the old project:

$ cd /tmp
$ mkdir pcre
$ cd pcre

Next, I’ll write an executable module called /tmp/pcre/pcre_demo.ml, which makes trivial use of OCaml’s pcre bindings:

let rex = Pcre2.regexp {|\d{3}-\d{4}|}

let string = "phone: 555-1234"

let print_output input =
  if Pcre2.pmatch ~rex input
  then Printf.printf
         "the string '%s' contains a phone number\n%!"
         input
  else Printf.printf
         "the string '%s' does not contain a phone number\n%!"
         input

let () = print_output string

/tmp/pcre/dune-project is the same as my previous dune-project, except that now I add the OCaml library that provides bindings to pcre2 to my dependencies:

(lang dune 3.13)
(name pcre-demo)
(generate_opam_files true)
(package
 (name pcre-demo)
  (depends
    ocaml 
    dune
    pcre2))

As before, I’ll create a simple /tmp/pcre/dune file to build a dynamically-linked executable first, then add the necessary options for static linking. It’s mostly the same as before, except that I now declare pcre2 as a library, so that the compiler knows where to find it when I reference the module Pcre2 in the above executable code:

(executable 
 (public_name pcre-demo) 
 (name pcre_demo)
 (promote (until-clean))
 (libraries pcre2))

I’ll need to first install pcre2 to my opam switch before running the build as a bootstrapping maneuver, due to the rather silly way .opam files are generated from dune files, but dune errors out due to a build error if ocamlopt can’t find your libraries. Given that I need the .opam file, I need to do it this way. C’est la vie.

$ opam install pcre

Now I’ll do a build and run the thing as a quick sanity check:

$ dune build
$ dune exec pcre-demo
the string 'phone: 555-1234' contains a phone number

As before, no static linking yet:

$ ldd ./pcre_demo.exe
        linux-vdso.so.1 (0x00007f4487514000)
        libpcre2-8.so.0 => /usr/lib/libpcre2-8.so.0 (0x00007f4487336000)
        libm.so.6 => /usr/lib/libm.so.6 (0x00007f4487203000)
        libc.so.6 => /usr/lib/libc.so.6 (0x00007f4486e00000)
        /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f4487516000)

Now it’s time to figure out how to statically link with pcre2. The annoying question here is: what do C programs that want to link with that library call it?

Fortunately for us, some conscientious computer hackers in the early 2000s came up with a tool called pkg-config, which looks up the .pc files that are expected to ship with popular C libraries, and uses the information in them to provide the kind of dependency information that today’s package managers give us. So the first thing I’ll do is ask pkg-config what it calls pcre2:

> pkg-config --list-all | grep pcre2
libpcre2-8                     libpcre2-8 - PCRE2 - Perl compatible regular expressions C library (2nd API) with 8 bit character support
libpcre2-16                    libpcre2-16 - PCRE2 - Perl compatible regular expressions C library (2nd API) with 16 bit character support
libpcre2-32                    libpcre2-32 - PCRE2 - Perl compatible regular expressions C library (2nd API) with 32 bit character support
libpcre2-posix                 libpcre2-posix - Posix compatible interface to libpcre2-8

Now we play the game of intersecting that list with what came up in the output of ldd, when run on the dynamic executable. Looks like libpcre2-8 is the library that got dynamically linked. So that’s what we give to pkg-config when we ask it to calculate pcre2’s transitive dependencies:

$ pkg-config -static -libs libpcre2-8
-lpcre2-8 -pthread -lpthread

These options must be in the correct order for the linking to work, but pkg-config—bless its beautiful little soul—is designed to give them to us in the correct order (assuming the packagers made no mistakes in the .pc files for any of those libraries).

With that unpleasantness out of the way, the next thing to do is whip up a release build profile for my dune file, the way I did before, copy/paste these options in right after -cclib -static, and then precede each one with a -cclib of its own. Ready? The new file, at /tmp/pcre/dune, will be:

(executable 
 (public_name pcre-demo) 
 (name pcre_demo)
 (promote (until-clean))
 (libraries pcre2)) 

(env
 (release
  (ocamlopt_flags (:standard))
  (link_flags (:standard -cclib -static
                -cclib -lpcre2-8
                -cclib -pthread
                -cclib -lpthread))))

As it stands, we are almost but not quite ready for The Legend of Zelda: A Static Link to the Past. That dune file will make ocamlopt happy, but libpcre2-8 still needs to be installed to my OS. So now how do I install the dependencies that pkg-config told me about to my OS? Not my real OS, of course, the fake Alpine one I’m building the statically linked executable in.

Unfortunately, the gods are not with us on this one. The ideal would be to automate this part of the process, but as near as I can make out, it is downright un-automatable, because there are no consistent naming conventions between different Alpine Linux packages for C libraries. So we are stuck figuring out what to apk add on a case by case basis, using trial and error.

Here is the general method I’ve landed on for figuring out what to install to Alpine Linux. Probably best to treat it as a rule of thumb, but it seems to work most of the time:

  • get into an Alpine Linux Docker shell
  • apk update
  • apk search minimal-name-of-library
  • look for anything that optionally begins with lib and ends in -static or -dev
  • apk add every package with those characteristics in my Dockerfile

Let’s demonstrate this method for pcre2. I’ll get into a Docker shell:

$ docker run --rm -it --network=host alpine:3.20 /bin/sh

Note that the prompt changes to / #, to indicate that I am logged into the Docker environment as root. I’ll run an apk update, to pull down the latest package index:

/ # apk update
fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main/aarch64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/community/aarch64/APKINDEX.tar.gz
v3.20.10-114-ga410d2306e5 [https://dl-cdn.alpinelinux.org/alpine/v3.20/main]
v3.20.10-106-gcfb1a4769c3 [https://dl-cdn.alpinelinux.org/alpine/v3.20/community]

Then I’ll search for pcre2:

/ # apk search pcre2
libpcre2-16-10.43-r0
libpcre2-32-10.43-r0
lua-rex-pcre2-2.9.1-r3
lua5.1-rex-pcre2-2.9.1-r3
lua5.2-rex-pcre2-2.9.1-r3
lua5.3-rex-pcre2-2.9.1-r3
lua5.4-rex-pcre2-2.9.1-r3
pcre2-10.43-r0
pcre2-dev-10.43-r0
pcre2-doc-10.43-r0
pcre2-tools-10.43-r0

In this case, pcre2-static does not come back, but pcre2-dev does, so that is what I will install to my Docker environment, apk add-ing the package right after the installation of build-base and opam:

RUN apk update && \
    apk upgrade && \
    apk add --no-cache build-base opam pcre2-dev

Full Dockerfile:

FROM alpine:3.20 AS builder

RUN apk update && \
    apk upgrade && \
    apk add --no-cache build-base opam pcre2-dev

RUN addgroup ocamldev && adduser -G ocamldev -D ocamldev

USER ocamldev

RUN opam init --bare -a -y --disable-sandboxing \
    && opam update

RUN opam switch create docker-switch 5.2.0

WORKDIR /app

COPY dune dune-project pcre-demo.opam pcre_demo.ml ./

RUN opam install . --deps-only --yes

RUN opam exec -- dune build --release

FROM alpine:3.20 AS runtime

COPY --from=builder /app/pcre_demo.exe /usr/local/bin/pcre-demo

CMD  [ "/usr/local/bin/pcre-demo" ]

Now to create a Docker image for the pcre-demo project, I’ll create a subdirectory called musl:

$ mkdir musl

Then I’ll build the statically linked executable and copy it out:

$ docker build --network=host --progress=plain -t pcre-demo .
$ docker create --name pcre-demo-extract pcre-demo
$ docker cp pcre-demo-extract:/usr/local/bin/pcre-demo ./musl/pcre-demo

Is it statically-linked?

$ ldd musl/pcre-demo
        not a dynamic executable

Yup! Does it run?

$ musl/pcre-demo
the string 'phone: 555-1234' contains a phone number

Yup!

C Library Example #2: readline

Let’s walk through another example, to give a feel for how the details can vary slightly from case to case. First, I’ll zip into /tmp and make a new project that is destined to contain a module using OCaml’s bindings into GNU Readline:

$ cd /tmp
$ mkdir readline
$ cd readline

Here’s a toy program that makes a trivial call into readline, called /tmp/readline/readline_demo.ml:

let () =
  Readline.init () ;
  match Readline.readline ~prompt:"Type a string: " () with
  | None -> ()
  | Some "" -> print_endline "You didn't type anything!"
  | Some line -> Printf.printf "You typed '%s'!\n%!" line

I’ll leave the creation of /tmp/readline/dune-project and /tmp/readline/dune as an exercise for the reader. I just re-used my dune and dune-project files from the pcre project, except that I changed pcre2 to readline in both places, and I omitted the env stanza from dune, to give myself some time to ponder it.

Next, I install the readline OCaml package:

$ opam install readline --yes

Sanity check build:

$ dune build

Here it is in action:

$ dune exec readline-demo
Type a string: goodbye, sarah
You typed 'goodbye, sarah'!
$ dune exec readline-demo
Type a string:
You didn't type anything!

ldd output:

$ ldd ./readline_demo.exe 
	linux-vdso.so.1 (0x00007f72d563b000)
	libreadline.so.8 => /usr/lib/libreadline.so.8 (0x00007f72d54d1000)
	libm.so.6 => /usr/lib/libm.so.6 (0x00007f72d539a000)
	libc.so.6 => /usr/lib/libc.so.6 (0x00007f72d5000000)
	libncursesw.so.6 => /usr/lib/libncursesw.so.6 (0x00007f72d5329000)
	/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f72d563d000)

Once again, I’ll use pkg-config to figure out what it wants me to call readline:

$ pkg-config --list-all | grep readline
readline                       Readline - Gnu Readline library for command line editing

Let’s take a peek at those transitive dependencies:

$ pkg-config -static -libs readline
-lreadline -lncursesw

Noice. That tells me to create an the env stanza in /tmp/readline/dune that links with -lreadline and -lncursesw:

(executable 
 (public_name readline-demo) 
 (name readline_demo)
 (promote (until-clean))
 (libraries readline)) 

(env
 (release
  (ocamlopt_flags (:standard))
  (link_flags (:standard -cclib -static
                -cclib -lreadline
                -cclib -lncursesw))))

As before, I’ll drop into a Alpine Linux root shell to find out what apk calls ncurses and readline:

$ docker run --rm -it --network=host alpine:3.20 /bin/sh
/ # apk update
fetch http://dl-cdn.alpinelinux.org/alpine/v3.2/main/x86_64/APKINDEX.tar.gz
v3.2.3-474-g10ee65f [http://dl-cdn.alpinelinux.org/alpine/v3.2/main]
OK: 5294 distinct packages available

Searching for readline, I get:

/ # apk search readline
growlight-1.2.38-r2
guile-readline-3.0.9-r0
jimtcl-readline-0.82-r1
jruby-readline-9.3.13.0-r0
perl-anyevent-readline-gnu-1.1-r0
perl-anyevent-readline-gnu-doc-1.1-r0
perl-term-readline-gnu-1.46-r1
perl-term-readline-gnu-doc-1.46-r1
py3-urwid_readline-0.14-r1
py3-urwid_readline-pyc-0.14-r1
readline-8.2.10-r0
readline-dev-8.2.10-r0
readline-doc-8.2.10-r0
readline-static-8.2.10-r0
tcl-readline-2.3.8-r0
tcl-readline-dev-2.3.8-r0
tcl-readline-doc-2.3.8-r0

Looks like readline-static and readline-dev are both available, so I’ll use them. Searching for ncurses, I get:

/ # apk search ncurses
libncurses++-6.4_p20240420-r2
libncursesw-6.4_p20240420-r2
ncurses-6.4_p20240420-r2
ncurses-dev-6.4_p20240420-r2
ncurses-doc-6.4_p20240420-r2
ncurses-libs-6.4_p20240420-r2
ncurses-static-6.4_p20240420-r2
ncurses-terminfo-6.4_p20240420-r2
ncurses-terminfo-base-6.4_p20240420-r2
vdr-skincurses-2.6.1-r6

ncurses-static and ncurses-dev are also both available. So I apk add all four of those packages in my Dockerfile:

RUN apk update && \
    apk upgrade && \
    apk add build-base --no-cache opam git && \
    apk add --no-cache readline-static readline-dev ncurses-static ncurses-dev

Full Dockerfile:

FROM alpine:3.20 AS builder

RUN apk update && \
    apk upgrade && \
    apk add build-base --no-cache opam git && \
    apk add --no-cache readline-static readline-dev ncurses-static ncurses-dev

RUN addgroup ocamldev && adduser -G ocamldev -D ocamldev

USER ocamldev

RUN opam init --bare -a -y --disable-sandboxing \
    && opam update

RUN opam switch create docker-switch 5.2.0

WORKDIR /app

COPY dune dune-project readline-demo.opam readline_demo.ml ./

RUN opam install . --deps-only --yes

RUN opam exec -- dune build --release

FROM alpine:3.20 AS runtime

COPY --from=builder /app/readline_demo.exe /usr/local/bin/readline-demo

CMD  [ "/usr/local/bin/readline-demo" ]

Make a directory for the statically-linked binary:

$ mkdir musl

Build the project and extract the executable:

$ docker build --network=host --progress=plain -t readline-demo .
$ docker create --name readline-demo-extract readline-demo
$ docker cp readline-demo-extract:/usr/local/bin/readline-demo ./musl/readline-demo
$ docker rm readline-demo-extract

Confirm it’s statically linked:

$ ldd musl/readline-demo 
	not a dynamic executable

And check that it runs:

$ musl/readline-demo 
Type a string: you have no power over me
You typed 'you have no power over me'!

Rinse and repeat for other C libraries. I wish you the best of luck.

Cleaning up the campsite

It’s up to you whether you want to do similarly, but I generally like to clean all my residual Docker images and containers when I’m done. The only output I really needed at the end of this process was the statically-linked executable, and now that I have it, I can blow everything else away:

$ docker rmi pcre-demo
$ docker rmi readline-demo
$ docker rmi pure-ocaml-demo
$ docker rmi alpine:3.20

Conclusion

Let me try to distill everything into some takeaways:

  • I’ve found Alpine Linux via Docker to be a good build environment for full static linking
  • if you want to statically link your pure OCaml program, create a dune build profile that passes -cclib static to ocamlopt as a linking option
  • if you want to statically link your pure OCaml program that calls into C, there are more steps
  • first, use pkg-config to find out what linking options to give the OCaml compiler
  • then, drop into a root shell in your Docker environment and use apk to find out which Alpine Linux packages you need to install to your Docker environment
  • keep your eye on package names that end in -static and -dev
  • copy the binaries out of your Docker images when you’re done

Hopefully, assuming you share my interest in static linking, this is enough to get you started with it. Or maybe you’re only interested in knowing how to do it from the armchair, even if you won’t necessarily ever get around to actually doing it. Either way.

Matt Teichman


  1. Thanks to Skye Soss for inspiring me to investigate these matters in a GitHub issue↩︎