Create a distributable Elixir executable using Bakeware

2023/11/24

What follows is a step-by-step guide to set up an Elixir project so that it produces an executable that can be distributed. The executables will run on other Linux machines with the same processor architecture.

We will be using Bakeware.

The Project

I've created an example project

mix help bakeware_example

It's here on GitHub.

Add the Dependency

Add the following to mix.exs

defp deps do
  [
    ...
    {:bakeware, runtime: false}
  ]
end

We set `runtime: false` as the dependency is not part of the runtime application (See the Mix doc for a full explanation).

Update dependencies

mix deps.get

Configure the Release

Add release details to mix.exs:

  @app :bakeware_example

  def project do
    [
      app: @app,
      releases: [{@app, release()}],
      preferred_cli_env: [
        release: :prod
      ]
      ...
    ]
  end

...

  defp release do
    [
      overwrite: true,
      quiet: true,
      steps: [:assemble, &Bakeware.assemble/1],
      strip_beams: Mix.env() == :prod
    ]
  end

Create the First Release

We can now create a first executable to see where we're at

$ MIX_ENV=prod mix release
...
Bakeware successfully assembled executable at:

    _build/prod/rel/bakeware/bakeware_example

Running the executable works, but doesn't produce any output

$ _build/dev/rel/bakeware/bakeware_example

Implement main

We need to implement a function that will get called when the application starts.

Create lib/bakeware_example/cli.ex

defmodule BakewareExample.CLI do
  use Bakeware.Script

  @impl Bakeware.Script
  def main(_args) do
    IO.puts "bakeware_example works!"
    0
  end
end

Configure that as the startup module in mix.exs

--- mix.exs
+++ mix.exs
@@ -16,7 +16,8 @@ def project do
 
   def application do
     [
-      extra_applications: [:logger]
+      extra_applications: [:logger],
+      mod: {BakewareExample.CLI, []}
     ]
   end

Now when be rebuild and run, we get some output

$ MIX_ENV=prod mix release
...
$ _build/dev/rel/bakeware/bakeware_example
bakeware_example works!

We're not Finished!

Now, if I copy that executable onto a computer with a recent Linux distribution, it will work.

But, if the distribution is a bit older, it fails:

$ ./bakeware_example 
./bakeware_example: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./bakeware_example)

The problem is that our executable relies on a more recent version of glibc.

The solution, as explained in the Bakeware README is to build with an older version of glibc.

A Containerized Build

So, let's use a slightly older Linux version in a container to build the executable.

What follows uses Podman but should work exactly the same with Docker.

# Build releases with a recent Elixir, but using an older Linux
# so the executable depends on an older
# (and more widely available) GLibC version.

FROM debian:buster-slim

# Installing the dependency packages takes 90% of the time
# so we do it first to allow quick builds of images
# with varied options.

RUN \
  apt-get update && \
  apt-get install -y curl libncurses5 procps build-essential zstd unzip locales

# Set up workspace
WORKDIR /app

# Environment for Erlang
ENV ERLANG_URL=https://packages.erlang-solutions.com/erlang/debian/pool
ENV ERTS_RELEASE=24.3.3-1
ENV ERTS_PLATFORM=debian~buster_amd64
ENV PACKAGE=$ERTS_RELEASE~$ERTS_PLATFORM.deb

# Environment for Elixir
ARG ELIXIR_VERSION
ENV ELIXIR_VERSION=$ELIXIR_VERSION
ENV ELIXIR_ZIP=v$ELIXIR_VERSION-otp-24.zip
ENV PATH="${PATH}:/app/elixir/bin"
ENV LC_ALL=en_US.UTF-8

# Set up Erlang and Elixir
RUN \
  curl -O $ERLANG_URL/erlang-base_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-ssl_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-crypto_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-public-key_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-asn1_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-syntax-tools_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-inets_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-mnesia_$PACKAGE && \
  curl -O $ERLANG_URL/erlang-runtime-tools_$PACKAGE && \
  dpkg -i *.deb && \
  echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \
  locale-gen && \
  mkdir elixir && \
  curl --output elixir/$ELIXIR_ZIP https://repo.hex.pm/builds/elixir/$ELIXIR_ZIP && \
  (cd elixir; unzip $ELIXIR_ZIP) && \
  mix local.hex --force && \
  mix local.rebar --force

# Install Elixir Dependencies
COPY ../mix.* /app/
RUN mix deps.get

COPY ../lib /app/lib/

We've kept the ELIXIR_VERSION as a build argument, so we can make it match the project more easily.

$ podman build --file Containerfile --env ELIXIR_VERSION=1.15.5 --tag bakeware_example:latest .
...
Successfully tagged localhost/bakeware_example:latest
$ podman run -v `pwd`:/app/_build/prod/rel/bakeware -e MIX_ENV=prod bakeware_example:latest mix release
...
Bakeware successfully assembled executable at:

    _build/prod/rel/bakeware/bakeware_example

Because we set up a volume pwd:/app/_build/prod/rel/bakeware the executable is actually build in the project root.

It Works!

Running it locally works


> uname -a
Linux framework 5.19.0-43-generic #44~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon May 22 13:39:36 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
> ./bakeware_example 
bakeware_example works!

As does running it on an older machine

> uname -a
Linux foobar 5.4.0-107-generic #121-Ubuntu SMP Thu Mar 24 16:04:27 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
> ./bakeware_example 
bakeware_example works!

🎉