A few days ago, I read Fred Hebert’s tweet:
~15 years ago, if you wanted a dynamic website with PHP, you installed a LAMP stack, wrote a few toy pages, uploaded the files to a $5 host, fiddled 30mins with htaccess (optional), and you were done.
I don’t recall seeing anything close to this easy since then.
I feel the same. Deployments are really tedious today. A lot of abstractions around there. They all have their reasons to stand. But there are so many that we can’t easily see a single simple and beautiful way of it.
On the other hand, this is the best time. I believe with the deep knowledge of the problems and infrastructures, we can make deployment simple and delightful again. We’re just on the way of it.
In this article, I will show how to write a Dockerfile of 15 lines of code, for an Elixir project, to make a Docker image of it.
Docker is a good match for Elixir
Speaking of deployment, there are quite a lot of solutions out there for a typical Elixir project. Most of them rely on mix release
.
The mix task mix release
was introduced by @bitwalker’s Distillery, and later (Elixir 1.9) integrated into the language built-in libraries. It produces executable files that are ready to deploy. You can distribute these files through networks and run them everywhere you want.
Well, not everywhere indeed.
Since cross-compiling is not supported at this moment, you have to make sure the computer where you run mix release
(aka builder) has the same architecture and fundamental libraries installed (Kernel, standard C libraries, etc.) as the production servers. In another word, you can only deploy your project into the servers (aka runners) with the same architectures as the builder.
Docker can be used to keep a consistent environment for your application. It provides a way to make your files on the system always the same, as well as environment variables, IP addresses, and software dependencies.
With docker, we don’t worry about the builder/runner match problem anymore. Thanks to docker for mac, we can even use Macbooks as builders.
In this post, I’ll provide an example of a small Dockerfile
which can be used to generate a Docker image from an Elixir project. It’s really simple and easy, opposite to many’s impression.
Create a docker image from source code in two simple steps
I’m going to show you how to write a Dockerfile for an Elixir project and try to describe the ideas behind it.
I also created a hex package to make this a single command line to let you create your Dockerfile without copying & pasting. You can jump to that section if you like.
Alright, let’s start from an empty Dockerfile at the root of your project. Run this command in terminal to create it:
$ touch Dockerfile
Open Dockerfile
with your favorite editor and we are going to code.
Our Docker image will be built within two steps:
Step 1: Generating release files
Put the following into the Dockerfile
:
FROM qhwa/elixir-builder:latest AS builderARG mix_env=prod
ENV MIX_ENV=${mix_env}WORKDIR /srcCOPY mix.exs mix.lock /src/
RUN mix deps.get --only $MIX_ENVADD . .
RUN mix release --path /app --quiet
The first line of it ( FROM qhwa/elixir-builder:latest AS builder
) defines the base image, and give a name builder
to this building step (or called stage in Docker’s terminology). We’ll refer to this name in step 2.
I use qhwa/elixir-builder instead of the official elixir image. You can replace it with your favorite image. Just make sure:
- It should be the same OS as step 2.
- It should have Elixir, hex, and rebar3 installed.
I prefer and recommend qhwa/elixir-builder because it has been working well for me in the past years, and has other important tools such as Node.js installed.
Let’s move on to the next two lines:
ARG mix_env=prod
ENV MIX_ENV=${mix_env}
They provide a point for users to specify which mix environment (MIX_ENV) to build. For example, if we want to build with MIX_ENV
as staging
, we can run docker build command with: --build-arg mix_env=staging
.
A piece of background info: There was a choice I made, to use the same docker image for different mix environments, or a different docker image for each different mix environment. I chose the first approach because many things, such as dependencies, supervisor trees, etc., may be different in each mix environment.
Then, we add mix.exs
and mix.lock
into the builder, and fetch the dependencies:
WORKDIR /srcCOPY mix.exs mix.lock /src/
RUN mix deps.get --only $MIX_ENV
It’s tricky to only add mix.exs
and mix.lock
instead of the full project. This will give us better performance because we need to re-fetch dependencies only when these two files change.
At last, we build the release by running mix release
and put the built files into /app
, rather than the current working directory of /src
:
ADD . .
RUN mix release --path /app --quiet
At this point, the Dockerfile is valid. And we can get a docker image from it by running docker build .
Let us make it better, a smaller Docker image!
Step 2: Put release files onto an operating system
Append the following to our dockerfile:
FROM qhwa/elixir-runner:latestARG mix_env=prod
ENV MIX_ENV=${mix_env}COPY --from=builder /app /app
WORKDIR /appENTRYPOINT ["/app/bin/YOUR_APP_NAME"]
CMD ["start"]
Notice: please replace
YOUR_APP_NAME
with your real project name.
Let’s go through it.
The first line defines the base image we use to host our application:
FROM qhwa/elixir-runner:latest
This is the second time we use FROM instruction in the Dockerfile. It’s called multi-stage build and fits our scenario perfectly. After generating release files, we can start the second build step from a clean base system, and copy the release files generated from the last step.
The base image here is for the runner and is different from the builder.
Why don’t we just reuse the builder’s base image, like official Elixir image or qhwa/elixir-builder in the last step?
Well, the answer is that we have lots of tools, for example, Elixir, mix, and Node.js installed in the builder, but they are only required for development and would not be used in the production. By removing these tools, we can make our final Docker image smaller.
I prefer qhwa/elixir-runner image, it’s small (based on Alpine Linux) and ships with lots of useful utilities. It feels like a Swiss Knife.
Once again, you can replace it with your favorite base image for a runner. Just to remember that keep the same architecture as the builder.
Then we copy release files from our last stage, the building stage, in step 1:
COPY --from=builder /app /app
At last, we define the working directory and entry point for the image:
WORKDIR /appENTRYPOINT ["/app/bin/YOUR_APP_NAME"]
CMD ["start"]
Done!
We can run docker build -t my_app .
in the terminal to build the image. After the image is built, we can run it by:
$ docker run my_app
or
$ docker run --rm --tty --interactive my_app start_iex
to run and attach to the console.
For Phoenix projects
If your project has static assets, and you want to build them too, which is very typical for a Phoenix project, you can just append following codes to the Dockerfile, right before RUN mix release …
:
...RUN npm install --prefix assets
RUN npm run deploy --prefix assets
RUN mix phx.digest
...
You don’t have to write for every project
I’ve put the template into a hex package, called Dockerize. You can install it with:
$ hex archive.install hex dockerize
and generate a Dockerfile with:
$ mix dockerize.init
And there it is!
Further topics
Runtime configurations
We don’t want our secret credentials to spread into the builder. How to organize these runtime configurations?
Fortunately mix release
already provides a way for us, you can check the documentation to set it up. In most cases, we only need to add a config/releasee.exs
.
Create a CI/CD process based on the Docker image
So far we have got our Docker image. It can be used in many different ways:
- a local docker run (for other developers to quick start)
- docker-compose (very simple and delightful)
- Kubernetes (very powerful)
- …
This is an interesting topic but too long for this article. Hopefully, we may discuss it in another post. I strongly encourage you to give Rio a try if you are using Kubernetes.
So, at last, happy deploying your Elixir projects!