Deploying Elixir to ECS - Part 2

Published

In Part 1 we used terraform to build all of the required ECS infrastructure in AWS. Next we’ll build an image, push it to the image repo and tell ECS to run it.

A simple project

Start by building a simple Phoenix app or feel free to use an existing app that you want to deploy to ECS.

$ mix phx.new ecs_app --no-ecto --live

Add a health controller that has a single endpoint that the ALB will use to determine the health of the service. Make a new file at lib/ecs_app_web/controllers/health_controller.ex and add the following content:

defmodule EcsAppWeb.HealthController do    use EcsAppWeb, :controller

  def index(conn, _params) do
    {:ok, vsn} = :application.get_key(:ecs_app, :vsn)

    conn
    |> put_status(200)
    |> json(%{healhy: true, version: List.to_string(vsn), node_name: node()})
  end
end

and in lib/ecs_app_web/router.ex

scope "/", EcsAppWeb do    get "/health", HealthController, :index
end

This is a pattern I add to a lot of my web services so I can verify the version that’s deployed and the node name.

Configuration

There’s a few things we’ll need to update in the default phoenix configuration.

First update the prod.exs by changing the host to your load balancer url. This was one of the terraform outputs when we built the infrastructure, or it can also be found in the AWS web console:

config :ecs_app, EcsAppWeb.Endpoint,    url: [host: "your-lb.us-east-1.elb.amazonaws.com", port: 4000],
  cache_static_manifest: "priv/static/cache_manifest.json"

This will ensure live view works correctly.

Secondly, make sure you uncomment the following line in config/prod.secret.exs

config :ecs_app, EcsAppWeb.Endpoint, server: true

This will ensure the endpoint starts up when running a release.

Dockerfile

The Dockerfile is rather simple and taken almost directly from the Phoenix docs.

Phoenix Dockerfile Documentation

Create the file Dockerfile and add the following:

FROM elixir:1.10.0-alpine AS build  
ARG MIX_ENV
ARG SECRET_KEY_BASE

RUN apk add --no-cache build-base git npm python

WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

ENV MIX_ENV=${MIX_ENV}
ENV SECRET_KEY_BASE=${SECRET_KEY_BASE}

COPY mix.exs mix.lock ./
COPY config config
RUN mix do deps.get, deps.compile

COPY assets/package.json assets/package-lock.json ./assets/
RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error

COPY priv priv
COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix phx.digest

COPY lib lib

RUN mix do compile, release

FROM alpine:3.9 AS app

ARG MIX_ENV

RUN apk add --no-cache openssl ncurses-libs

WORKDIR /app

RUN chown nobody:nobody /app

USER nobody:nobody

COPY --from=build --chown=nobody:nobody /app/_build/${MIX_ENV}/rel/ecs_app ./

ENV HOME=/app

CMD ["bin/ecs_app", "start"]

Build Configuraton

I like to create a Makefile for building my Docker images and pushing them to ECR. Note the your_ecr_url is the url of your ECR that was created in Part 1.

APP_NAME ?= `grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g'`  APP_VSN ?= `grep 'version:' mix.exs | cut -d '"' -f2`
BUILD ?= `git rev-parse --short HEAD`

build_local:
  docker build --build-arg APP_VSN=$(APP_VSN) \
    --build-arg MIX_ENV=prod \
    --build-arg SECRET_KEY_BASE=$(SECRET_KEY_BASE) \
    -t $(APP_NAME):$(APP_VSN) .

build:
  docker build --build-arg APP_VSN=$(APP_VSN) \
    --build-arg MIX_ENV=prod \
    --build-arg SECRET_KEY_BASE=$(SECRET_KEY_BASE) \
    -t your_ecr_url:$(APP_VSN)-$(BUILD) \
    -t your_ecr_url:latest .

push:
  eval `aws ecr get-login --no-include-email --region us-east-1`
  docker push your_ecr_url:$(APP_VSN)-$(BUILD)
  docker push your_ecr_url:latest

deploy:
  ./bin/ecs-deploy -c your_cluster_name -n your_service_name -i your_ecr_url:$(APP_VSN)-$(BUILD) -r us-east-1 -t 300

For this to work, you’ll need to set an environment variable SECRET_KEY_BASE which you can generate with mix phx.gen.secret.

Assuming you have docker on your computer, you can now run make build_local and it should build and package a production release docker image. And it’s always a good idea to try it out locally before deploying:

$ docker run -p 4000:4000 -it ecs_app:0.1.0

You should be able to hit http://localhost:4000 now.

The push task will require that you have the AWS Cli installed on your computer and your AWS access_key and secret setup correctly. See the docs to set it up locally.

AWS CLI Userguide

For the deploy step, I reference a script at ./bin/ecs-deploy. You can get this script on github. Create a folder at the root of your project called bin and place the ecs-deploy script in it. This will require the same AWS authentication as the push task. It also requires that you have jq installed on your system and may require you to set the execution bit on the file chmod +X ./bin/ecs-deploy.

silinternational/ecs-deploy

Deploy!

Now that we have a simple project, lets get it deployed to ECS. Assuming you have your AWS credentials setup correctly, you should be able to run the following commands in order:

  1. make build - builds and tags a docker image
  2. make push - pushs that image to your private docker repository
  3. make deploy - instructs ECS to create a new task definition with your latest image and start running it

The deploy task can take some time. It trys to verify that the task is running and that the previous task is stopped. You can now browse to the ECS web console and watch the progress of your task starting.

If everything worked correctly, you should be able to browse to the Load Balancer URL and see the default Phoenix welcome screen!

Github Actions

It’s great that we can build and deploy the app locally, now lets automate the deployment process with Github Actions.

We’re going to create one workflow that does three jobs:

  1. Run Tests
  2. Build and push the docker image
  3. Deploy to ECS

Steps 1 and 2 will run in parallel and step 3 will run only if 1 and 2 are both successful.

Create a new file at .github/workflows/ci.yml with the following content:

name: ECS DEPLOYMENT  
on:
  push:
    branches: [ main ] #i renamed my master branch to main

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          ref: main
      - uses: actions/cache@v2
        with:
          path: deps
          key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
          restore-keys: |
            ${{ runner.os }}-mix-
      - name: Set up Elixir
        uses: actions/setup-elixir@v1
        with:
          elixir-version: '1.10.3'
          otp-version: '22.3'
      - name: Install dependencies
        run: mix deps.get
      - name: Run tests
        run: MIX_ENV=test mix do compile, test

  build:
    name: Build And Push Container
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
      with:
        ref: main
    - uses: actions/cache@v2
      with:
        path: deps
        key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
        restore-keys: |
          ${{ runner.os }}-mix-
    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: us-east-1

    - name: Build Docker Image
      run: make build
      env:
        SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}

    - name: Push Docker Image
      run: make push

  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: [test, build]
    steps:
    - uses: actions/checkout@v2
      with:
        ref: main
    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: us-east-1

    - name: Deploy
      run: make deploy

You’ll notice that there are references to three different ${{secrets}}. You can set these in your Github repos Settings page. There is section there called secrets, just add the three secrets and this build will have access.

Now push your code to the repo and your ci action should test, build and deploy your code to ECS. You can watch the progress in the Actions tab of your Github repo.

Verify this by going to your-lb-url.com/health to see the version and node name of your app.

Wrap Up

Now there is a reproducible infrastructure definition, and its being deployed on a push to repository. Most projects would probably be done at this point.

In Part 3, I’ll show you how to use ECS Service Discovery to build a distributed cluster on ECS.

Part 1 - using Terraform to describe and build the infrastructure Part 3 - using ECS Service Discovery to build a distributed Elixir cluster