A Quick Recap on Docker & Elixir Releases
This article is best read when you already know what Docker and Elixir Releases are. If you need to refresh your memory, check out this little introduction that (curiously) explains both concepts at the same time:
Why use Docker with Elixir?
I’ve previously written about how to create Elixir Releases with Distillery. Releases are great and one of the many features to love about the Elixir/Erlang ecosystem. But the extent to which they are truly self-contained is limited.
A Release can only run on systems sufficiently similar to the build system. What does that mean? Unfortunately, that question doesn’t have a trivial answer. Most importantly, operating system and processor architecture need to match and system libraries need to be compatible. The former is why you can’t run a Release that was built on a macOS machine on Linux. The latter is why a Release that you build with Ubuntu 17.10 doesn’t work on Ubuntu 14.04: Their C standard libraries (specifically glibc) are not ABI-compatible. This means that your application might work perfectly fine on one system, but crash with an obscure error message on another.
Fortunately, there is a solution to this problem and it’s containers.
Step-by-Step Guide
Installing Docker
Before we get started, you need to have Docker installed on your development machine. Even though most of the time you’ll be running Docker in Continuous Integration (CI) and other specialized server environments, in order to get familiar with it and to follow this guide, I recommend setting it up locally.
There are multiple versions of Docker available but for our purposes Docker Community Edition (Docker CE) is more than sufficient. The Docker website has installation instructions for Windows, macOS and Linux.
If you are using Linux, you might already have Docker installed but many distributions ship with outdated versions. So I recommend installing the latest version from the Docker website to make sure there are no compatibility issues.
What’s in a Dockerfile?
Before we can create a Docker image, we need to tell Docker what this image should look like. This is what the aptly named Dockerfile
is for:
A
Dockerfile
is a text document that contains all the commands a user could call on the command line to assemble an image.
- Docker reference documentation
Here are the most important commands that we’ll be using to create our image:
FROM
initializes a new image based on an existing image. Alternatively,FROM scratch
can be used.COPY
copies files from the build context (which is usually the directory in which theDockerfile
resides) into the image.ENV
sets persistent environment variables.RUN
executes one or more shell commands.
Each command in a Dockerfile creates a new layer of the final image. I don’t want to go into the internals of Docker too much but for our purpose, it is desirable to keep the number of total layers as low as possible. If you are interested in how exactly layers work, I find that this short article gives a better explanation than the official Docker documentation
Dockerfile Stages
In some cases the files you deploy on a production machine will be pretty much identical to your source code repository. Languages such as PHP and Ruby are interpreted at runtime; they don’t need to be compiled before they can be executed.
Elixir, on the other hand, is a compiled language and always requires a dedicated build phase. During the build phase you need files and programs that are not necessary in order to run the application: Mix, Git and your source code all don’t need to be available on your production system.
This is where Docker’s multi-stage builds come in handy: You can set up environments for building your application that are not going to be part of your final deployment container.
How do multi-stage builds work? Easy: Every FROM
command in your Dockerfile
initializes a new stage. Later stages can copy files from previous stages. The last stage becomes your final image while all other stages are discarded.
Ready, Set …
I have created a little Elixir application that does nothing but print out the current time. Let’s put this amazing work of software engineering into a Docker container!
git clone https://github.com/wmnnd/elixir-docker-guide
cd elixir-docker-guide
The Build Stage
Start by creating a file called Dockerfile
in the project’s root folder.
The first step is to pick an existing image from Docker Hub for the build phase: Let’s go with Paul Schoenfelders’s awesome alpine-elixir
image which ships with everything we need to compile an Elixir application.
FROM bitwalker/alpine-elixir:1.5 as build
Next, let’s copy our source code into the Docker container:
COPY . .
If you want to be more selective about what you copy from your source folder, you could also do something like this instead:
COPY rel ./rel
COPY config ./config
COPY lib ./lib
COPY priv ./priv
COPY mix.exs .
COPY mix.lock .
Being more explicit with the folders you want to copy, can be useful if you want to avoid issues with artifacts in the _build
folder or previously downloaded dependencies.
Next, we want to fetch the application’s dependencies, compile it and build a Release with Distillery:
RUN export MIX_ENV=prod && \
rm -Rf _build && \
mix deps.get && \
mix release
The rm -Rf _build
bit is not necessary if you are only building your image in an isolated CI environment or if you have chosen the more explicit way of copying your files mentioned above.
At this point, you can already try building an image from your Dockerfile
. Call the following command and you should see the application being built by Mix and Distillery — inside a Docker container:
docker build -t elixir-docker-guide .
The -t
parameter for docker build
gives a name or tag to the newly created image. Like this, we can access it at a later point. We also need to specify the build context, i. e. the local folder from which to build the image. Since this is the current folder, we simply put .
there.
Finishing the Build Stage
Distillery buries the .tar.gz
archive of a Release deep in the _build
folder. Since it is our goal to use the Release we’ve just built in our deployment image, let’s copy it to a more easily accessible location:
RUN APP_NAME="clock" && \
RELEASE_DIR=`ls -d _build/prod/rel/$APP_NAME/releases/*/` && \
mkdir /export && \
tar -xf "$RELEASE_DIR/$APP_NAME.tar.gz" -C /export
While this snippet might seem a bit obscure at first, it’s actually pretty simple: It extracts the .tar.gz
archive created by Distillery into a folder called /export
.
The Deployment Stage
Now we initialize a second stage in our Dockerfile
:
FROM pentacent/alpine-erlang-base:latest
The next step is to copy the compiled Release from the previous stage:
COPY --from=build /export/ .
It’s good practice to use a non-root user, even inside of a container. This is why we make Docker switch to the default
user (which has already been created in alpine-erlang-base
).
USER default
Finally, all we need to do is specify an ENTRYPOINT
and a CMD
for our image. ENTRYPOINT
defines the application that is started when creating a container from our image; CMD
specifies the default arguments for that application:
ENTRYPOINT ["/opt/app/bin/clock"]
CMD ["foreground"]
Let’s give it a try! Build and run the application:
docker build -t elixir-docker-guide .
docker run -t elixir-docker-guide
That wasn’t too hard, was it?
The Full Recipe
At this point you might want to “dockerize” your own application. For this purpose, I have created this annotated Dockerfile that you can use as a boilerplate. Just make sure to replace both instances of MY_APP_NAME
with your actual application name:
From Here On Out
Phoenix
You can also use this method for creating Docker images of Phoenix applications. Just make sure to include the steps to build and digest your assets in the build phase. With the default Brunch asset pipeline, this would look something like this:
ENV MIX_ENV=prod
RUN apk update && \
apk add -u musl musl-dev musl-utils nodejs-npm build-base
RUN mix deps.get
RUN mix compile
RUN cd assets && \
npm install && \
node ./node_modules/brunch/bin/brunch b -p && \
cd .. && \
mix phx.digest
RUN mix release
If you are using a more elaborate asset pipeline or if you want to take better advantage of Docker’s caching capabilities, you can even make this a dedicated Docker stage.
Umbrella Applications
There isn’t really anything special you need to do to build Docker images from umbrella applications. Distillery already takes care of bundling everything up nicely. If you’re using Phoenix as one of your child apps, just remember that you need to adjust the paths in the above example for building your assets.
What About the Base Image?
You might have noticed that I suggested using my very own pentacent/alpine-erlang-base
image as the base of the production image. You could also use bitwalker/alpine-erlang
but alpine-erlang-base
gives you the smallest image size possible. I will write about choosing and creating a base image for Elixir deployment in a future article.
I hope you enjoyed this guide! If you have any questions or suggestions, please let me know!
This article is part of an ongoing series about developing and deploying Elixir applications to production. Make sure to sign up for my email list if you enjoyed this article.
Credit Where Credit is Due
I’d like to thank those who helped me create this guide:
- Paul Schoenfelder for creating alpine-erlang on which inspired my alpine-erlang-base image and for his helpful feedback.
- Cees de Groot and Thomas Athanas for their feedback and suggestions.