Deploying Elixir (1 of 3): Building Releases With Mix

jonlunsford

Jon Lunsford

Posted on June 18, 2020

Deploying Elixir (1 of 3): Building Releases With Mix

This post is the first in a series of three on deploying elixir:

  1. Building Releases with Docker & Mix
  2. Terraforming an AWS EC2 Instance
  3. Deploying Releases with Ansible

Last time, we built a small JSON endpoint with Plug, Cowboy and Poison, let's retrofit that same app to build releases with Docker & Mix. The first step in deploying Elixir apps is to build them on the same target they will run. There are many ways to achieve this, we will walk through using Docker, locally, to build releases that can then be deployed.

If you did not follow along with that last post, you can grab the complete code here: https://github.com/jonlunsford/webhook_processor


Mix is:

A build tool that provides tasks for creating, compiling, and testing Elixir projects, managing its dependencies, and more.

Mix >= 1.9 introduced a task to manage releases, providing a built in alternative to tools like Distillery. We will be using this task to manage our releases: Mix.Tasks.Release


This is how we will package our app for release. We will also write a Mix task to:

  1. Build a Docker build image.
  2. Build releases with Mix.

Preparing our App for Release

Before we build our release with Docker, let's make a few changes to our app to get it ready for building releases. Update the project function in mix.exs for release:

# ./mix.exs
defmodule WebhookProcessor.MixProject do
 ...
  def project do
    [
      app: :webhook_processor,
      version: "0.1.0",
      elixir: "~> 1.10",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      releases: [ # add releases configuration
        prod: [ # we can name releases anything, this will be prod's config
          include_executables_for: [:unix], # we'll be deploying to Linux only
          steps: [:assemble, :tar] # have Mix automatically create a tarball after assembly
        ]
      ]
    ]
  end
 ...
end
Enter fullscreen mode Exit fullscreen mode

Now we can begin building our release for our target environment.


Add a /version Route

Let's make one more change to our endpoint so we can easily see which version of our app is ultimately running, this will come into play once we've actually deployed, add this to ./lib/webhook_processor/endpoint.ex:

defmodule WebhookProcessor.Endpoint do
  ...
  get "/version" do
    resp =
      case :application.get_key(:webhook_processor, :vsn) do
        {:ok, vsn} -> vsn
        _ -> "version not found :("
      end

    send_resp(conn, 200, resp)
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Building the Docker Image

Given that Erlang, and thus Elixir, are compiled, we must target the environment our app will run in at build time. Fortunately, Docker makes this trivial. The general idea is this:

  1. Create a Dockerfile that matches your target environment.
  2. Build the release with Mix inside the Docker container.
  3. Deploy the produced artifacts, the app tarball.

So let's do that, we will be targeting a Debian environment. The minimal Dockerfile will look like this:

# ./Dockerfile
# ENV matching production target host
# We'll be using a Debian linux EC2 instance to run this app
# See all official elixir docker images here: https://hub.docker.com/_/elixir
FROM elixir:1.10

# By default, if we're cutting a release it'll likely be prod
ARG ENV=prod

# We'll pass in ENV as a build arg to docker
ENV MIX_ENV=$ENV

# Our working directory within the container
WORKDIR /opt/build

# Add our release script to the container, named `release` and
# placed into the ./bin/ directory in our project root
ADD ./bin/release ./bin/release

# This is our entry point, make sure to run
# `chmod +x bin/release` to make this script executable
CMD ["bin/release", $ENV]
Enter fullscreen mode Exit fullscreen mode

With this, we have what we need to build the Elixir image, next let's write our release bash script that will facilitate the Elixir build inside our image. Be sure to make this file executable by running chmod +x ./bin/release:

#!/usr/bin/env bash

set -e

# The one arg that we can accept will be the ENV we are building
export MIX_ENV=$1

echo "Starting release process..."
cd /opt/build

echo "Installing rebar and hex..."
mix local.rebar --force
mix local.hex --if-missing --force

echo "Fetching project deps..."
mix deps.get --only prod

echo "Cleaning any leftover artifacts..."
mix do clean, compile --force

echo "Building $1 release..."
mix release $1 --overwrite

echo "Build completed!"
exit 0
Enter fullscreen mode Exit fullscreen mode

Building the Release

Given that building releases will be so common, let's write a Mix task to handle the redundancy. Our goal will be to have a task that reads like:

$ mix docker.build prod
Enter fullscreen mode Exit fullscreen mode

It's common to create namespaces for mix tasks by defining them in their own file, here's how we get the docker.build task:

# ./lib/mix/tasks/docker.build.ex
defmodule Mix.Tasks.Docker.Build do
  use Mix.Task

  @shortdoc "Docker utilities for building releases"
  def run(args) do
    Mix.Task.run("docker", args)
  end
end
Enter fullscreen mode Exit fullscreen mode

The run function simply runs the docker task, passing along the args. The upper Docker module contains the build code:

# ./lib/mix/tasks/docker.ex
defmodule Mix.Tasks.Docker do
  use Mix.Task
  use Mix.Tasks.Utils

  @shortdoc "Docker utilities for building releases"
  def run([env]) do
    # Build a fresh Elixir image, in case Dockerfile has changed
    build_image(env)

    # Get the current working directory
    {dir, _resp} = System.cmd("pwd", [])

    # Mount the working directory at /opt/build within the new elixir image
    # Execute the /opt/build/bin/release script
    docker(
      "run -v #{String.trim(dir)}:/opt/build --rm -i #{app_name()}:latest /opt/build/bin/release #{env}"
    )
  end

  defp build_image(env) do
    docker("build --build-arg ENV=#{env} -t #{app_name()}:latest .")
  end

  defp docker(cmd) do
    System.cmd("docker", String.split(cmd, " "), into: IO.stream(:stdio, :line))
  end
end

Enter fullscreen mode Exit fullscreen mode

Here, we have a run function that matches the pattern {env}, you can now build your application by running:

$ mix docker.build prod
Enter fullscreen mode Exit fullscreen mode

After the script runs, you will have a tarball containing everything it needs to run on our debian target at:

_build/prod/prod-0.1.0.tar.gz
Enter fullscreen mode Exit fullscreen mode

To recap, we've:

  1. Created a Dockerfile to match our Debian target environment.
  2. Created a build script that will run mix.
  3. Created a mix task to facilitate everything.

With this approach you should now be able to build releases to match your target environment. As usual, the full code can be found on github: https://github.com/jonlunsford/webhook_processor

Up next, Terraforming an EC2 instance to deploy our app to.

💖 💪 🙅 🚩
jonlunsford
Jon Lunsford

Posted on June 18, 2020

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

Sign up to receive the latest update from our blog.

Related