Elixir Releases and Docker Multi-Stage Builds on Kubernetes
At Revelry, we’ve recently changed how we deploy Phoenix / Elixir apps onto Kubernetes. We’re using a new feature of Elixir called “releases” alongside a Docker feature called “multi-stage builds.” The result is that we are making smaller and more secure Docker images than with our previous approach.
Deploying applications with single-stage images without releases leaves behind an entire toolchain that is only needed to initially build the app. This toolchain far exceeds what is needed to actually run the final compiled application in terms of size and complexity. As a result, leaving behind this toolchain in the final image results in more resource-intensive deployed applications and a larger attack surface. It seemed to us that our images were unnecessarily large and complex. With Elixir releases and Docker multi-stage builds, we can only include precisely what is needed to run the app within the final image and eliminate any intermediate tools.
To explain how this works, we must start by talking about what releases and multi-stage builds are.
What is an Elixir release? The official documentation for Elixir 1.9 describes it like this:
The main feature in Elixir v1.9 is the addition of releases. A release is a self-contained directory that consists of your application code, all of its dependencies, plus the whole Erlang Virtual Machine (VM) and runtime. Once a release is assembled, it can be packaged and deployed to a target as long as the target runs on the same operating system (OS) distribution and version as the machine running the mix release command.https://github.com/elixir-lang/elixir/releases/tag/v1.9.0
And here’s how the Docker documentation describes multi-stage builds:
Multi-stage builds are a new feature requiring Docker 17.05 or higher on the daemon and client. Multistage builds are useful to anyone who has struggled to optimize Dockerfiles while keeping them easy to read and maintain.
… The end result is the same tiny production image as before, with a significant reduction in complexity. You don’t need to create any intermediate images and you don’t need to extract any artifacts to your local system at all.https://docs.docker.com/develop/develop-images/multistage-build/
Multi-stage Docker images and Elixir Releases can work together to build images for Elixir applications that contain only exactly what you need to run the application. Here’s our method. We use a two-stage Docker file. The first stage is the “builder” and the second stage is the “runner.” The first uses a larger base image with a complete build toolchain. We use
elixir:1.9 as our base builder image. This image compiles the app and its assets. Then, once the application is compiled, it uses the
mix release command to build a release. The second stage of the Dockerfile is the “runner.” The runner includes the release built by the prior stage and runs it. We have been using
debian:stretch-slim as the base of our runner image. You can see our multi-stage Dockerfile in our Phoenix App Template.
Since the final release image contains only the bare minimum runtime dependencies of the application, the resulting image is much smaller. In our sample application, the Docker image size decreased from roughly 900mb to 60mb. The final images from the multi-stage process are also more secure since they include fewer packages, which lowers the probability that the built artifact contains a vulnerable package. Without any other work, the number of known vulnerabilities in our test image dropped by roughly 65%. The resulting test image also dropped from 18 critical issues to 0. We’ve been extremely happy with the results so far and will be using it as our method for building Docker images for Elixir applications going forward.
Looking for more on Elixir, Phoenix, Kubernetes, and Docker? Try our article on Managing Application Environment Configuration with Kubernetes or Monitoring Phoenix Applications and Recording Metrics.