Multi-stage Docker Builds and Elixir 1.9 Releases

Posted by Alex Koutmos on Thursday, June 20, 2019

Contents

Intro

In this post we’ll talk about what a release is in the context of Elixir and why/when you should use it. We’ll also cover how this was performed historically and how this changes in Elixir 1.9. Finally we’ll go through creating an Elixir release inside of a Docker container using multi-stage builds. Without further ado, let’s dive right into things!

What is a release?

An Elixir release (and Erlang of course) is the process of taking your application and bundling it so that it is ready for distribution (generally called an OTP release). Part of the bundling process can also include packaging ERTS (Erlang Runtime System) so that you have a completely standalone build artifact that can be executed on a target machine regardless of whether Erlang/Elixir is installed. It is important to note that where you build a release should match the end target machine. In other words, I cannot build a release on a Linux machine and then run the release on a Windows machine. In order for an OTP release to be compatible between build and target machine, three things need to be consistent: architecture, OS, and application binary interface. It is also important to note that Elixir 1.9 brings the release creation process directly into Elixir core where as before you had to rely on tools like Distillery to generate releases. Tools like Distillery still serve a purpose (like streamlining hot code upgrades), but having this functionality in Elixir core makes it easier to get started and takes care of most deployment use cases. A huge thanks to Paul Schoenfelder (https://github.com/bitwalker) by the way for all his work on Distillery (and the many other libs he maintains)! I am a happy user of many of Paul’s tools and I am grateful for his contribution to the Elixir community.

Why use releases?

There are several benefits to producing an OTP release over copying you code over to the target machine and then running it directly there. Some of these benefits include:

  • Your application is self contained and makes distribution a lot simpler. You no longer need to provision a machine with a runtime installed.
  • Can have multiple applications deployed to a single machine with different runtime versions.
  • Makes it easy to connect a remote shell to a running release for introspection.
  • Your application starts faster given that all application modules are preloaded vs lazy loaded.
  • Makes it explicit what are run-time vs build-time application configurations.
  • Easy control over BEAM VM flags (http://erlang.org/doc/man/erl.html#emu_flags).

Show me the code!

In order to showcase the process and capabilities of an OTP release, we’ll put together an OTP release for a Phoenix app. Our release will make use of run-time configuration to demonstrate how your app can be configured via environment variables. We’ll be doing all of this via Docker and will make use of multi-stage builds in order to keep our image size slim. As a cherry on top, we’ll attach an IEx session to our running app so that we can introspect it. The full code can be found at https://github.com/akoutmos/docker_elixir_19_release where each commit lines up with the below steps. This tutorial assumes you have Elixir 1.9 running on your machine (if you need help getting to that stage I recommend following this tutorial https://elixircasts.io/installing-elixir-with-asdf). For this exercise I am currently running Elixir -> 1.9.0-rc.0 and Phoenix -> 1.4.8.

Step 1: Create a new Phoenix project - commit

Install the Phoenix project generator.

$ mix archive.install hex phx_new 1.4.8

Generate a new project. You can replace docker_elixir_19_release with what ever your project is called and can also re-enable ecto and webpack if your app requires it.

$ mix phx.new docker_elixir_19_release --no-ecto --no-webpack

In order to ensure that everything works, switch into the project directory and start the server.

$ cd docker_elixir_19_release
$ mix phx.server

If all goes well you should see the default Phoenix homepage when you navigate to http://localhost:4000

Step 2: Initialize Elixir 1.9 release files and create a release - commit

In order to create an OTP release, there need to be certain configuration files in place. To generate these config files run the following.

$ mix release.init
$ touch config/releases.exs

Inside of config/releases.exs add the following (replacing :docker_elixir_19_release with whatever the name of your project is). The configurations that you specify here will need to be provided at run-time. As you can see, System.fetch_env!/1 ends in an exclamation point and thus will raise an error if the environment variable is not available. This is useful because it will immediately be apparent in your logs why your application failed to start (we will try this later on).

import Config

config :docker_elixir_19_release,
  cool_text: System.fetch_env!("COOL_TEXT")

You can go ahead and delete config/prod.secret.exs as we’ll perform those configuration steps within config/releases.exs instead. This will allow us to configure the port and secret key base for our Phoenix app at run time vs build time. Your config/releases.exs should like like this after this step.

import Config

secret_key_base = System.fetch_env!("SECRET_KEY_BASE")
cool_text = System.fetch_env!("COOL_TEXT")
application_port = System.fetch_env!("APP_PORT")

config :docker_elixir_19_release, DockerElixir19ReleaseWeb.Endpoint,
  http: [:inet6, port: String.to_integer(application_port)],
  secret_key_base: secret_key_base

config :docker_elixir_19_release,
  cool_text: cool_text

Inside of config/prod.exs remove the following as we are no longer importing secrets for production use.

import_config "prod.secret.exs"

While you are in config/prod.exs uncomment the following line.

config :docker_elixir_19_release, DockerElixir19ReleaseWeb.Endpoint, server: true

In order to show that our app now has run time configuration, inside of templates/page/index.html.eex, add the following somewhere in the template.

<p><%= Application.get_env(:docker_elixir_19_release, :cool_text) %></p>

To generate our first release and serve it, run the following. If you go to localhost:4000 you should see the below image with your run time configured COOL_TEXT.

$ mix phx.digest
$ MIX_ENV=prod mix release
$ APP_PORT=4000 COOL_TEXT="Elixir Rocks" SECRET_KEY_BASE=$(mix phx.gen.secret) _build/prod/rel/docker_elixir_19_release/bin/docker_elixir_19_release start

Step 3: Create a multi-stage Dockerfile - commit

To begin, we need to create the first stage of the multi-part Dockerfile, the builder stage. The comments in the Dockerfile below should explain the various sections, but at a high level we are installing Elixir 1.9 release candidate 0 since it is not available on dockerhub yet, and then installing rebar and hex. From there we copy over the necessary project files from the host to build the application, and generate a release. Below are the contents of the Dockerfile that you should have at the root of the project. We’ll append to it in the next section.

# ---- Build Stage ----
FROM erlang:21 AS app_builder

# Set environment variables for building the application
ENV MIX_ENV=prod \
    TEST=1 \
    LANG=C.UTF-8

# Fetch the latest version of Elixir (once the 1.9 docker image is available you won't have to do this)
RUN set -xe \
  && ELIXIR_DOWNLOAD_URL="https://github.com/elixir-lang/elixir/archive/v1.9.0-rc.0.tar.gz" \
  && ELIXIR_DOWNLOAD_SHA256="fa019ba18556f53bfb77840b0970afd116517764251704b55e419becb0b384cf" \
  && curl -fSL -o elixir-src.tar.gz $ELIXIR_DOWNLOAD_URL \
  && echo "$ELIXIR_DOWNLOAD_SHA256  elixir-src.tar.gz" | sha256sum -c - \
  && mkdir -p /usr/local/src/elixir \
  && tar -xzC /usr/local/src/elixir --strip-components=1 -f elixir-src.tar.gz \
  && rm elixir-src.tar.gz \
  && cd /usr/local/src/elixir \
  && make install clean

# Install hex and rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# Create the application build directory
RUN mkdir /app
WORKDIR /app

# Copy over all the necessary application files and directories
COPY config ./config
COPY lib ./lib
COPY priv ./priv
COPY mix.exs .
COPY mix.lock .

# Fetch the application dependencies and build the application
RUN mix deps.get
RUN mix deps.compile
RUN mix phx.digest
RUN mix release

Now that the build stage is complete, we need to have put together the actual application container. Add the following to the Dockerfile you created in the previous step (once again comments in the file should explain the various parts of the Dockerfile). One important thing to notice is that our final application image is based on debian and not Erlang or Elixir. Given that the ERTS is bundled within the release, we can run a plain debian base image and produce a smaller image (you can also use alpine if you prefer but the build step will also require some changes if you chose to go that route).

# ---- Application Stage ----
FROM debian:stretch AS app

ENV LANG=C.UTF-8

# Install openssl
RUN apt-get update && apt-get install -y openssl

# Copy over the build artifact from the previous step and create a non root user
RUN useradd --create-home app
WORKDIR /home/app
COPY --from=app_builder /app/_build .
RUN chown -R app: ./prod
USER app

# Run the Phoenix app
CMD ["./prod/rel/docker_elixir_19_release/bin/docker_elixir_19_release", "start"]

Now that the Dockerfile is complete, go ahead and build the application image. Replace my-app with whatever you want to tag your application. You can also see the size different in the containers!

$ docker build -t my-app .
$ docker images
REPOSITORY                                                     TAG                 IMAGE ID            CREATED             SIZE
my-app                                                         latest              4d2adfff908e        33 minutes ago      187MB
elixir                                                         latest              5d7ef0eb3b2f        5 days ago          1.08GB
erlang                                                         21                  b8eb89e9bff3        11 days ago         1.07GB

Now that our application has been built and bundled into a container, you can run the app and provide it the necessary environment variables by doing the following (if you changed the tag of the image make sure you update it below). You should see the Phoenix homepage now with your environment configurable text.

$ docker run --publish 4000:4000 --env COOL_TEXT='ELIXIR ROCKS!!!!' --env SECRET_KEY_BASE=$(mix phx.gen.secret) --env APP_PORT=4000 my-app:latest
20:53:09.311 [info] Running DockerElixir19ReleaseWeb.Endpoint with cowboy 2.6.3 at :::4000 (http)
20:53:09.311 [info] Access DockerElixir19ReleaseWeb.Endpoint at http://example.com

Step 4: Introspect a running application - commit

For this step, we are going to add an additional dependency to our project and rebuild the docker image. After that we will run the container and attach to the running Phoenix app and introspect it with observer_cli. To begin, add {:observer_cli, "~> 1.5"} to your dependency list in mix.exs and rebuild the docker image.

$ docker build -t my-app .

This time when we run the image we’ll pass in the additional --name flag to make it easy to access the running image

$ docker run --name my_app --publish 4000:4000 --env COOL_TEXT='ELIXIR ROCKS!!!!' --env SECRET_KEY_BASE=$(mix phx.gen.secret) --env APP_PORT=4000 my-app:latest
23:59:56.027 [info] Running DockerElixir19ReleaseWeb.Endpoint with cowboy 2.6.3 at :::4000 (http)
23:59:56.027 [info] Access DockerElixir19ReleaseWeb.Endpoint at http://example.com

In a separate terminal run the following in order to get a shell into the running container and attach an IEx shell to the running application

$ docker exec -it my_app bash
$ ./prod/rel/docker_elixir_19_release/bin/docker_elixir_19_release remote

Now that we have a remote IEx shell attached to our running application, you can start up the CLI observer by running the following.

> :observer_cli.start()

To show that this is indeed the same container that we spun up earlier, with the CLI observer running, we’ll simulate some load on our server and see that the CLI observer reacts appropriately. Go to the Network tab in observer CLI by entering capital N and enter. As you can see in the image below there is not a lot going on given that nothing is interacting with our application.

In another terminal (with the observer still running), run the following (you may need to install wrk2 on your system if you don’t have it already installed https://github.com/giltene/wrk2). With a good amount of load hitting your system now, you should see something like the following in your Network tab in observer CLI.

$ wrk2 -c 100 -d 10s -R 10 http://localhost:4000/

Closing thoughts

Well done and thanks for sticking with me to the end! We covered quite a lot of ground and hopefully you picked up a couple of cool tips and tricks along the way. In this tutorial we covered how to leverage Elixir 1.9 releases and Docker multi-stage builds in order to create lightweight docker images where our application and the ERTS are all neatly bundled. We also learned how to go about configuring our application at run-time. In addition, we also learned how to attach an IEx session to a running instance of our application, and how we can introspect that application. All in all the Elixir 1.9 release has made deploying Elixir applications far easier and way more streamlined. A huge thanks to all the people who put their time and effort into such an awesome release :).

Feel free to leave comments or feedback or even what you would like to see in the next tutorial. Till next time!

Additional Resources

Below are some additional resources if you would like to deep dive into any of the topics covered in the post.


comments powered by Disqus