Nim: Deploying static binaries
— Kaushal ModiDeploying Nim applications as static binaries for GNU/Linux type operating systems, built using musl.
Problem statement #
I’d like to create Nim applications, and then be able to easily deploy the static built binaries so that anyone on GNU/Linux type OS can just download the binary and run them.
If and when I figure out how to do the same for macOS and Windows too, I’ll write a separate post for that. If you can help me out with that, please comment below, or open an issue/PR in the repo linked at the end.
Solution #
The solution is multi-step:
- Building static binaries (only for GNU/Linux type OS).
- Optimizing the binary size.
- Doing the above two easily.
- Creating and deploying the builds automatcally (on GitHub).
1 Building static binaries #
My primary OS for coding and development is RHEL 6.8.
By default, Nim builds dynamically linked binaries using the glibc version present on the OS where those binaries are built.
Living on a stable but old OS like RHEL 6.8 (that has glibc 2.12 by default), I have ended up with the issue many a times1 where the deployed binary would be compiled with a newer glibc and so it wouldn’t run on my system. I would need to eventually compile that binary myself.
I don’t want to inflict other people with the same problem.
Inspired by how statically compiled binaries are distributed for tools built in other languages (hugo [Go], ripgrep [Rust], pandoc [Haskell], etc), I wanted the same for whatever I build with Nim.
I kept looking around for a “single press button” flow to do this in Nim for a while, and after finding nothing, I came up a flow myself, that I present in this post.
I picked musl to statically compile my binaries.
What is musl? #
Here’s a one-liner description from Wikipedia:
musl is a C standard library intended for operating systems based on the Linux kernel, released under the MIT License.
Check out its introduction on its official webpage for more details.
Installing musl #
Installing musl was as simple as downloading its .tar.gz and running
make && make install
with the --prefix
configured
appropriately. For the examples that follow, let’s say you set the
prefix to /usr/local/musl/
.
Do not install musl at the default prefix location, else it will override a lot of default header files – an outcome that you probably don’t want.
- With the prefix set as above, the
musl-gcc
binary would get installed in the/usr/local/musl/bin/
directory. - Add
/usr/local/musl/bin/
to yourPATH
environment variable.
Static linking #
By default, Nim will use the gcc
executable that’s found in PATH
for C compilation. But using the --gcc.exe
switch, we override to
use the musl-gcc
executable. Similarly, we use the --gcc.linkerexe
switch to use musl-gcc
too, and then the -static
option is passed
to the linker using the --passL
switch.
With a hello.nim
containing:
echo "Hello, World!"
running the below:
# Assuming that musl-gcc binary is found in PATH.
nim --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static c hello.nim
I got a 98kB statically linked hello
binary.
2 Optimizing the binary size #
The binary size can be optimized by Nim itself by doing a Release
build (-d:release
) which disables runtime checks used for debugging,
and enables certain optimizations. Adding --opt:size
does further
binary-size optimization.
So, with the same hello.nim
, running the below:
# Assuming that musl-gcc binary is found in PATH.
nim --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static -d:release --opt:size c hello.nim
shrunk down the binary size to 43kB.
Upon running external utilities like strip
(from binutils
)2
and upx
3, the binary size shrunk even further! – 16kB.
# Assuming that musl-gcc binary is found in PATH.
nim --gcc.exe:musl-gcc --gcc.linkerexe:musl-gcc --passL:-static -d:release --opt:size c hello.nim
strip -s ./hello
upx --best ./hello
3 Doing the above two easily #
All of that is great, but it wouldn’t be fun or elegant to do all the steps in Code Snippet 1 manually (or even in a shell script).
Wouldn’t it be awesome if I could just run a nim musl hello.nim
command‽
— And that’s what I do! 😎
NimScript #
The “nim musl” command is not available out-of-box, but it is easy to
create one using a NimScript config file, which is in the form of a
project-specific config.nims
.
Think of NimScripts as much superior “bash scripts” as they are written in Nim 😄
From the NimScript docs:
Strictly speaking, NimScript is the subset of Nim that can be evaluated by Nim’s builtin virtual machine (VM). This VM is used for Nim’s compiletime function evaluation features, but also replaces Nim’s existing configuration system.
The VM cannot deal with
importc
, the FFI is not available, so there are not many stdlib modules that you can use with Nim’s VM. However, at least the following modules are available:
That list of modules is not comprehensive. I believe that the macros
module should be added to that list too (we also happen to need it for
the error
macros in the config.nims
below 😄).
So, instead of having to resort to the hacky bash scripts, NimScript allows one to write scripts in the awesome Nim syntax, and it’s platform-agnostic too!
Also, as you see below, the tasks in NimScripts basically become user-defined Nim-subcommands. For example, “nim musl” command doesn’t exist, but I could make one just like that by defining a “musl” NimScript task.
config.nims
#
Below config.nims
is generic and would work for most Nim
projects. You need to just put it in the directory from where you
compile the Nim code.
Nim devel (v0.18.1) is needed for the below NimScript to work,
because the findExe
I use in there was not present in Nim v0.18.0.
# config.nims
from macros import error
from ospaths import splitFile, `/`
# -d:musl
when defined(musl):
var muslGccPath: string
echo " [-d:musl] Building a static binary using musl .."
muslGccPath = findExe("musl-gcc")
# echo "debug: " & muslGccPath
if muslGccPath == "":
error("'musl-gcc' binary was not found in PATH.")
switch("gcc.exe", muslGccPath)
switch("gcc.linkerexe", muslGccPath)
switch("passL", "-static")
proc binOptimize(binFile: string) =
## Optimize size of the ``binFile`` binary.
echo ""
if findExe("strip") != "":
echo "Running 'strip -s' .."
exec "strip -s " & binFile
if findExe("upx") != "":
# https://github.com/upx/upx/releases/
echo "Running 'upx --best' .."
exec "upx --best " & binFile
# nim musl foo.nim
task musl, "Builds an optimized static binary using musl":
## Usage: nim musl <.nim file path>
let
numParams = paramCount()
if numParams != 2:
error("The 'musl' sub-command needs exactly 1 argument, the Nim file (but " &
$(numParams-1) & " were detected)." &
"\n Usage Example: nim musl FILE.nim.")
let
nimFile = paramStr(numParams) ## The nim file name *must* be the last.
(dirName, baseName, _) = splitFile(nimFile)
binFile = dirName / baseName # Save the binary in the same dir as the nim file
nimArgs = "c -d:musl -d:release --opt:size " & nimFile
# echo "[debug] nimFile = " & nimFile & ", binFile = " & binFile
# Build binary
echo "\nRunning 'nim " & nimArgs & "' .."
selfExec nimArgs
# Optimize binary
binOptimize(binFile)
echo "\nCreated binary: " & binFile
Here’s a quick breakdown of that script:
- We first import the modules we would need for the sugar-syntax
error
macro, and for basic file path operations. when defined(musl)
block is used to do something extra to the nim command if user has passed a-d:musl
switch during compilation (this custom compile-time define switch is used in the “musl” task).- The
binOptimize
is just a Nim proc to contain the outside-Nim binary size optimization commands. - Finally, the NimScript task “musl” allows me to define my custom
“musl” sub-command for
nim
so that I can runnim musl FOO.nim
.
With a directory containing this config.nims
and a FOO.nim
,
running nim musl FOO.nim
will be analogous to manually running the
commands in Code Snippet 1.
4 Creating and deploying the builds automatically #
All of that is still great, but ..
Wouldn’t it be awesome if I could do all of that automatically to create static binary releases for my Nim projects‽
And of course, I can do that too! – At least on GitHub using Travis CI.
I would also like to know how to do the same on non-GitHub repos too. If you know, please comment below.
Below is a generic .travis.yml
that works for a repo name “FOO” and
containing the Nim code in its “src/FOO.nim” file. Notes on what this
config file does follow after that snippet.
language: c
sudo: required
cache:
directories:
- nim
- upx
env:
global:
- PROGNAME="$(basename ${TRAVIS_BUILD_DIR})" # /travis/build/kaushalmodi/hello_musl -> hello_musl
- NIMFILE="src/${PROGNAME}.nim"
- BINFILE="src/${PROGNAME}"
- ASSETFILE="${PROGNAME}-${TRAVIS_TAG}.Linux_64bit_musl.tar.xz"
- NIMREPO="https://github.com/nim-lang/Nim"
- NIMVER="$(git ls-remote ${NIMREPO} devel | cut -f 1)"
- NIMDIR="${TRAVIS_BUILD_DIR}/nim/${NIMVER}"
- UPXVER="3.95" # Change this value when upgrading upx
addons:
apt:
packages:
# For building MUSL static builds on Linux.
- musl-tools
install:
- echo "NIMDIR = ${NIMDIR}"
# After building nim, wipe csources to save on cache space.
- "{ [ -f ${NIMDIR}/bin/nim ]; } ||
( rm -rf nim;
mkdir -p nim;
git clone --single-branch --branch devel --depth=1 ${NIMREPO} ${NIMDIR};
cd ${NIMDIR};
[ -d csources ] || git clone --depth 1 https://github.com/nim-lang/csources.git;
cd csources;
sh build.sh;
cd ..;
./bin/nim c koch;
./koch boot -d:release;
rm -rf csources;
)"
- export PATH="${NIMDIR}/bin:${PATH}"
- nim -v
- echo "Installing upx .."
- "{ [ -f upx/${UPXVER}/upx ]; } ||
{ curl -OL https://github.com/upx/upx/releases/download/v${UPXVER}/upx-${UPXVER}-amd64_linux.tar.xz;
tar xvf upx-${UPXVER}-amd64_linux.tar.xz;
mkdir -p upx;
mv upx-${UPXVER}-amd64_linux upx/${UPXVER};
}"
- export PATH="${TRAVIS_BUILD_DIR}/upx/${UPXVER}/:${PATH}"
- upx --version | grep -E '^upx'
script:
# Ensure that you are in repo/build root now.
- cd "${TRAVIS_BUILD_DIR}"
- echo "NIMFILE = ${NIMFILE}"
- echo "BINFILE = ${BINFILE}"
# Compile the static binary using musl.
- nim musl "${NIMFILE}"
# See that the binary is not dynamic.
- ldd "${BINFILE}" || true
# Run the binary.
- "${BINFILE}"
before_deploy:
- cd "${TRAVIS_BUILD_DIR}"
- cp "${BINFILE}" "${PROGNAME}"
- tar caf "${ASSETFILE}" "${PROGNAME}"
deploy:
provider: releases
api_key: "${GITHUB_OAUTH_TOKEN}"
file: "${ASSETFILE}"
skip_cleanup: true
on:
tags: true
- I like environment variables to do all the configuration. So I do that in the env / global block.
- Caching is enabled for “nim” and “upx” directories, which are
created in the install block.
- So if the Nim devel branch hasn’t been updated since the last Travis build, the cached Nim build is used.
- And similar applies to the
upx
binary, which relies on the value of theUPXVER
env variable.
- In the install block,
nim
is built from the Nim devel branch HEAD, andupx
binary is downloaded from the Releases section of its repo. - The key command is
nim musl "${NIMFILE}"
in the script block, which uses the musl NimScript task defined in theconfig.nims
in the same repo. - In before_deploy,
tar
is used to create an archive of the generated binary. - The deploy section is mainly copy-paste of what’s suggested in the
Travis CI documentation.
- About the
${GITHUB_OAUTH_TOKEN}
, the first step is to get your GitHub account’s repo level token from GitHub Tokens settings. - The second step is to create a custom environment variable named GITHUB_OAUTH_TOKEN in that repo’s Travis CI settings (ensure that the variable’s value is not made public in the Travis CI’s logs, though they are private by default), and assigning that token string to that variable.
- About the
How it works in practice #
Let’s assume that the one-time setup for a new repo is already
done. The config.nims
and .travis.yml
are committed and pushed to
the GitHub repo, and the GITHUB_OAUTH_TOKEN
environment variable is
set in the repo’s Travis settings.
Now, to auto-generate the assets for each release, all I need to do is:
- Commit my changes to the Nim code.
- Tag the commit.
- Push the commit and tag to GitHub.
Repo #
You can find the latest versions of the above code in my Nim+musl template repo hello_musl.
You can also try downloading and running (on a GNU/Linux 64-bit OS) the deployed static binary from its Releases section and see this printed in full glory! 😄
Hello, World!
References #
- r/nim – Static Linking with Nim
- hookrace.net – Statically linking against musl libc
- Using NimScript for configuration
- NimScript “API”
The binutils strip utility is used to remove complete symbol tables and other debug info that are not needed for running the executable. ↩︎
From its docs, UPX is an advanced executable file compressor. UPX will typically reduce the file size of programs and DLLs by around 50%-70%, thus reducing disk space, network load times, download times and other distribution and storage costs. ↩︎