Jon Lunsford
Posted on June 18, 2020
This post is the first in a series of three on deploying elixir:
- Building Releases with Docker & Mix
- Terraforming an AWS EC2 Instance
- 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:
- Build a Docker build image.
- 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
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
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:
- Create a Dockerfile that matches your target environment.
- Build the release with Mix inside the Docker container.
- 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]
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
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
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
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
Here, we have a run
function that matches the pattern {env}
, you can now build your application by running:
$ mix docker.build prod
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
To recap, we've:
- Created a Dockerfile to match our
Debian
target environment. - Created a build script that will run mix.
- 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.
Posted on June 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.