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
opamto Alpine Linux using theapkpackage manager - create the
ocamldevuser in Alpine Linux - run
opam init - create a fresh OCaml 5.2.0
opamswitch - install all dependencies for the project, based on what’s listed in
pure-ocaml-demo.opam - run a build with
dune(note the--releaseoption) - sock the executable away at
/usr/local/bininside 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-demoimage calledpure-ocaml-demo-extract - copy the binary out of the image into the
muslsubdirectory of my project - remove the
pure-ocaml-demo-extractcontainer (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 updateapk search minimal-name-of-library- look for anything that optionally begins with
liband ends in-staticor-dev apk addevery package with those characteristics in myDockerfile
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
dunebuild profile that passes-cclib statictoocamloptas a linking option - if you want to statically link your pure OCaml program that calls into C, there are more steps
- first, use
pkg-configto find out what linking options to give the OCaml compiler - then, drop into a root shell in your Docker environment and use
apkto find out which Alpine Linux packages you need to install to your Docker environment - keep your eye on package names that end in
-staticand-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
-
Thanks to Skye Soss for inspiring me to investigate these matters in a GitHub issue. ↩︎