Distillery releases with Docker multi-stage builds

This post describes the procedure to create lightweight Docker images, using multi-stage builds, to deploy Elixir applications packaged using Distillery.

It is assumed that you’re familiar with Docker and Elixir.

Multi-stage builds

Since Docker version 17.05 you can have multi-stage builds. With such builds you can have a single Dockerfile contain multiple FROM instructions, separating multiple stages of a build, where artifacts from one stage can be used in the next and all resulting in a single image.

Example Use Case

You have a static site, like this blog, which is build using Hugo, and you want deploy it.

Your build dependencies are:

  • hugo
  • nodejs (because you also have some fancy JavaScript)
  • libsass (because you find vanilla css to be boring)

Your runtime dependencies are:

  • nginx (to serve the static html pages and assets)

Before multi-stage builds you’d either have a Dockerfile handling both building and serving the files, or 2 different ones commonly named Dockerfile.build and Dockerfile and run the build command twice like:

docker build -f Dockerfile.build .

and

docker build -f Dockerfile .

This process is now simplified with a Dockerfile like the following:

FROM node:latest as builder

RUN apt-get -qq update
RUN apt-get -qq install hugo libsass

# Compile assets
RUN npm run build

WORKDIR /app

# Generate static html pages
RUN hugo

FROM debian:jessie-slim

RUN apt-get -qq update
RUN apt-get -qq install nginx
EXPOSE 80

# Notice this instruction
# The generated files under public from the previous build step
# are copied to the path which is served by nginx
COPY --from=builder /app/public /var/www/html

# Start nginx to serve files
CMD ["nginx", "-g", "daemon off"]

To be able to use this new feature you have to make sure you have a version >= 17.05 installed:

A mininal Dockerfile which contains all the necessary steps to install Docker CE version 17.05 is the following:

FROM debian:jessie-slim
MAINTAINER you@areawesome.com

RUN apt-get -qq update

RUN apt-get -qq install software-properties-common python-software-properties \ 
                        apt-transport-https ca-certificates curl gnupg2

# Add docker repository, for docker-ce edge (supports multi-stage builds)
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add -
RUN apt-key fingerprint 0EBFCD88
RUN add-apt-repository \
  "deb [arch=amd64] https://download.docker.com/linux/debian \
  $(lsb_release -cs) \
  edge"

RUN apt-get -qq update

# Install the minimum required version of docker for multi-stage builds
RUN apt-get install -qq docker-ce=17.05.0~ce-0~debian-jessie

CMD ["/bin/sh"]

You may find the Dockerfile above handy, if you run a CI allowing you to supply your own Docker images, like GitLab CI.

Benefits

  • Packages required to build your release (eg. nodejs), increase the size of your image but aren’t required during runtime.

  • You can install packages for debugging / tracing only to the final container.

  • The final container can be based off a slim image.

  • Dockerfiles are easier to maintain since it’s clearer which package dependencies are required for the build phase and which are runtime ones.

Tutorial

For the example below, for the sake of simplicity, a Phoenix application without assets or database models is used. The application is named goo and you can generate it using the phoenix.new task below:

mix phoenix.new goo --no-brunch --no-ecto

About Distillery

Distillery is a deployment tool for Elixir applications, which reduces your Mix application to a single package, containing all dependencies and (optionally) the Erlang / Elixir runtime.

Configuring Distillery

You’re suggested to take a moment to have a look at the distillery documentation about deploying phoenix applications.

Adding the dependency

The first thing we do, is to declare the distillery package dependency in mix.exs.

defmodule Goo.Mixfile do
  use Mix.Project

  def project do
    [app: :goo,
     version: "0.0.1",
     elixir: "~> 1.2",
     elixirc_paths: elixirc_paths(Mix.env),
     compilers: [:phoenix, :gettext] ++ Mix.compilers,
     build_embedded: Mix.env == :prod,
     start_permanent: Mix.env == :prod,
     deps: deps()]
  end

  def application do
    [mod: {Goo, []},
     applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :gettext, :cowboy, :logger]]
  end

  defp elixirc_paths(:test), do: ["lib", "web", "test/support"]
  defp elixirc_paths(_),     do: ["lib", "web"]

  defp deps do
    [{:phoenix, "~> 1.2.4"},
     {:phoenix_pubsub, "~> 1.0"},
     {:phoenix_html, "~> 2.6"},
     {:phoenix_live_reload, "~> 1.0", only: :dev},
     {:gettext, "~> 0.11"},
     {:cowboy, "~> 1.0"},
     # Distillery is added here
     {:distillery, "~> 1.4.0"}]
  end
end

Adding Distillery Config

You can generate the default distillery configuration using:

mix release.init

The generated rel/config.exs will be like:

Path.join(["rel", "plugins", "*.exs"])
|> Path.wildcard()
|> Enum.map(&Code.eval_file(&1))

use Mix.Releases.Config,
    # This sets the default release built by `mix release`
    default_release: :default,
    # This sets the default environment used by `mix release`
    default_environment: Mix.env()

# For a full list of config options for both releases
# and environments, visit https://hexdocs.pm/distillery/configuration.html

environment :dev do
  set dev_mode: true
  set include_erts: false
  set cookie: :"kool-thing"
end

environment :prod do
  set include_erts: true
  set include_src: false
  set cookie: :"song-for-karen"
end

release :goo do
  set version: current_version(:goo)
  set applications: [
    :runtime_tools
  ]
end

Dockerfile

FROM elixir:1.3.4-slim as builder

RUN apt-get -qq update
RUN apt-get -qq install git build-essential

RUN mix local.hex --force && \
    mix local.rebar --force && \
    mix hex.info

WORKDIR /app
ENV MIX_ENV prod
ADD . .
RUN mix deps.get
RUN mix release --env=$MIX_ENV

FROM debian:jessie-slim
RUN apt-get -qq update
RUN apt-get -qq install libssl1.0.0 libssl-dev
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/goo .

CMD ["./bin/goo", "foreground"]

Build It

docker build -t goo:latest .

Run It

docker run -it goo:latest -name goo

Connect to the running node

docker exec -it goo /app/bin/goo remote_console

Why Debian Slim

You may have noticed that the base images for the final release image are based on Debian.

Debian is the distribution used for most official language base images:

Debian Slim applies some sane defaults to apt for Docker, like:

  • Keeping gzipped indexes
  • Not caching deb files under /var/cache/apt/archives

Which means that you don’t have to include the instruction below to reduce size:

RUN rm -rf /var/lib/apt/lists/* && apt-get clean

The final image for the above multi-stage Dockerfile is just 213MB, which is slim enough. If you’re in desperate need to further reduce image sizes, you can use Alpine Linux, but then you’re giving up the maturity and stability of:

glibc / GNU coreutils / Systemd

for..

musl / busybox / OpenRC

Further Steps

  • Adding distillery plugins to compile assets
  • Running migrations on deployment

Feel free to suggests edits and make comments on the reddit post.