Elixir, Releases, and Docker

mpevec9

Milan Pevec

Posted on September 27, 2020

Elixir, Releases, and Docker

The goal of today's article is to show how to release Elixir application, written in my previous blog Elixir Plug and JWT and then run it inside of the docker container.

I would like to clear some things already in the beginning. There are a lot of options/solutions in this field and I'm not stating what's best or what's worse, nor am I an expert, but I would like to present what I've encountered in my research. And with that maybe help someone.

You can find all the code on my Github.

Why Docker with Elixir?

Ok, lets first create a production release. Where ie. on which environment could we do it?

We need to be aware, that the result of the Elixir release is a single unit that contains the app and the runtime, but is limited in self-containment - it can only run on systems sufficiently similar to the build system.

Hmm, so If I release the Elixir app on macOS it will (might) not run on Linux. Or even if it's released on a version of Linux with a different Glibc library than targeted Linux, the application might crash or not run either.

How to tackle this issue? The answer lies in the dockerization (containers). That sets our first goal, to release Elixir application inside of a docker container that is based on the same docker image as the target container where the app will be run in production.

I have chosen the Alpine Elixir to be the base image. It contains the full installation of Erlang/Elixir environment and its built on Alpine Linux.

At this point lets start writing our Dockerfile by adding the first line:

# Dockerfile
FROM bitwalker/alpine-elixir:latest AS release_stage

where we define that our image will be based on the alpine-elixir. Because we will use multi-stage build, we name this stage as release_stage. In this way, we keep one Dockerfile for releasing and for running the app.

Release me

Releasing an Elixir app is as simple as running mix release command, which will precompile and package all the code together with runtime (Erlang VM), which can be then used for running the app in the production or similar environments. There is a lot, a lot more behind the story of Elixir releases, I would just like to emphasize two points for its usage:

  • code preloading, when all Elixir modules are preloaded hence spikes on first requests are removed;
  • runtime configuration, where we can define different configuration every time we (re)start the app, without recompiling (releasing again) the app. We are planning to use that later!

Ok, lets first try to release our app outside of the docker container. We need to go to the root of the project and run the following line:

MIX_ENV=prod mix release

First, we set the MIX_ENV variable to prod, which puts production configuration into action (file config/prod.exs). Specifically, we want our production tokens to have a shorter expiration time than development tokens, so we do the following:

import Config

config :jwt_example,
  jwt_expiration_time_minutes: 15

Good. Now a release process will be called, all artifacts are built and saved in the folder _build/prod/rel/jwt_example, so we can easily run the application by calling:

_build/prod/rel/jwt_example/bin/jwt_example start

In order to do that also inside of the container, we need to prepare an appropriate docker image. What do we need for that?

Release stage

For running Elixir release we need all the source code, dependencies, and configuration. Lets first get project dependencies to the docker image and build them by adding the following lines to the Dockerfile:

COPY mix.exs .
COPY mix.lock .
RUN mix deps.get
RUN mix deps.compile

Each instruction in the Dockerfile adds a layer to the image and layers are cached, so in case of rebuilding the image and there are no changes in a specific layer, that layer will not be recalled, e.g.:

...
Step 5/10 : COPY mix.lock .
---> Using cache
---> 9d7f3925326c
Step 6/10 : RUN mix deps.get
---> Using cache
---> 79bae808d41c
Step 7/10 : RUN mix deps.compile
---> Using cache
---> 96c1e1d63927
Step 8/10 : COPY config ./config
---> efba111358c3
Step 9/10 : COPY lib ./lib
---> 04b6c23bdefb
Step 10/10 : COPY test ./test
---> b4f648c97967

You can see in the output (while building an image) which layers have been cached.

This gives us a reason to put things that have a lower probability to change as first, and things, like copying source code, as last to the Dockerfile.

Lets now copy the configuration and sources and then define the execution of the release:

COPY config ./config
COPY lib ./lib
COPY test ./test

ENV MIX_ENV=prod
RUN mix release

JWS secrets, ver1

What about the secret used for the h256 signature? As we wrote in the previous article Elixir Plug and JWT, we were using a solution where the secret is stored in the file, named hs256-signature-key. A secret file is read at the compile-time and secret is then stored in the application environment:

#config/config.exs:

config :jwt_example,
  jwt_secret_hs256_signature: Secret.get("hs256-signature-key", "default secret string")

If we want to keep the same approach, then we need to get the production secret to the docker image itself - also at the compile time, while running the release.

So imagine that you're a release manager and you have access rights to the production secret file stored in some secure location (e.g. /etc/elixir/production/hs256-signature-key) and you're preparing a docker image:

docker build --build-arg SECRET="$(cat /etc/elixir/hs256-signature-key)" —build-arg SECRET_FILE="hs256-signature-key" -t t3 .

The above docker build command will take two so-called build arguments:

  • SECRET_FILE: the name of the file inside of the container that will hold the secret;
  • SECRET: a secret itself. We need to send its content trough argument, because of the limitation of the docker build command - it's not allowed to copy files outside of the Dockerfile scope, so we can not copy directly /etc/elixir/hs256-signature-key. We must read the file on the fly and store its content in the build argument.

Accordingly to that, we will add the following lines to Dockerfile:

ARG SECRET
ARG SECRET_FILE

RUN mkdir $HOME/secrets
RUN echo "$SECRET" > $HOME/secrets/$SECRET_FILE
RUN chown -R default $HOME/secrets

The above lines will create a folder for secrets and store our secret coming from the build argument and then we set the permission that mix release could read the file while compiling.

Disclaimer:

It's not recommended to use build-time arguments to pass secrets. Even if we delete a secret file at the end, a secret can still be visible when running the docker history command. There are a couple of options to avoid this (check Access Private Repositories from Your Dockerfile Without Leaving Behind Your SSH Keys), but we will switch later to a different approach.

Almost there

Till now we have prepared the build stage of our multi-stage image. What's left is the run stage. It will be based on the same alpine-elixir image, although we could have taken a "thinner" image with only Erlang runtime. Let's add lines to Dockerfile:

FROM bitwalker/alpine-elixir:latest AS run_stage

COPY --from=release_stage $HOME/_build .
RUN chown -R default: ./prod
USER default
CMD ["./prod/rel/jwt_example/bin/jwt_example", "start"]

We copied the released application from the previous stage and run the release under the default user. That's it! Now that the Dockerfile is finished, we can build the image:

docker build --build-arg SECRET="$(cat /etc/elixir/hs256-signature-key)" --build-arg SECRET_FILE="hs256-signature-key" -t jwt_example .

and run the container, while exposing its port 4001, on which our cowboy server is listening:

docker run --name c_jwt_example -d --publish 4001:4001 jwt_example:latest

Now if we call

docker ps -a

we will see our container up and running.

Let's run our REST calls and at the same time check the container logs (stdout, stderr of the running app) with:

docker logs c_jwt_example —follow

we can see the following output:

##### 16:13:20.485 application=jwt_example domain=elixir file=lib/jwt_example/application.ex function=start/2 line=6 mfa=JwtExample.Application.start/2 module=JwtExample.Application pid=<0.2056.0> [info] Starting application..
##### 12:34:18.531 application=plug domain=elixir file=lib/plug/logger.ex function=call/2 line=27 mfa=Plug.Logger.call/2 module=Plug.Logger pid=<0.2163.0> [debug] POST /login
##### 12:34:18.535 application=plug domain=elixir file=lib/plug/logger.ex function=call/2 line=34 mfa=Plug.Logger.call/2 module=Plug.Logger pid=<0.2163.0> [debug] Sent 200 in 4ms

Great, as you can see our application is up and serving requests.

For the meaning of the docker parameters, you can check the official docker documentation.

Just before you go away

As we mentioned before, Elixir releases are enabling us to use the runtime configuration, that could we use for setting JWS secret at runtime and not at compile time!

Let's create a config/releases.exs file and add the following lines inside:

import Config

config :jwt_example,
  cowboy_port: String.to_integer(System.fetch_env!("COWBOY_PORT")),
  jwt_secret_hs256_signature: System.fetch_env!("HS256_SIGNATURE_KEY")

As you can see we set up the cowboy port at runtime also.

Please note one little detail, the System.fetch_env/1 function always returns a string, so in case of the integer values, we need to do the conversion.

That means we don't need to recompile the application (rebuild the release) to apply changes in this config file, we just need to restart our container and the new configuration will be taken into account!

For the above case, we will set port and secret as environment variables while running the container. At the same time, we can get rid of the build arguments about JWS secret, so our final Dockerfile looks like this:

FROM bitwalker/alpine-elixir:latest AS release_stage

COPY mix.exs .
COPY mix.lock .
RUN mix deps.get
RUN mix deps.compile

COPY config ./config
COPY lib ./lib
COPY test ./test

ENV MIX_ENV=prod
RUN mix release

FROM bitwalker/alpine-elixir:latest AS run_stage

COPY --from=release_stage $HOME/_build .
RUN chown -R default: ./prod
USER default
CMD ["./prod/rel/jwt_example/bin/jwt_example", "start"]

We then call the image build like this (no secret arguments needed):

docker build -t jwt_example .

and run the container with needed environment variabbles:

docker run --name c_jwt_example -d --publish is 8002:8002 -e COWBOY_PORT="8002" -e HS256_SIGNATURE_KEY="$(cat /etc/elixir/hs256-signature-key)" jwt_example:latest

Very straight forward isn't it! Happy coding!

💖 💪 🙅 🚩
mpevec9
Milan Pevec

Posted on September 27, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related