/eb-elixir

A brief guide to preparing an Elixir Phoenix web application to be deployed to an AWS Elastic Beanstalk

Primary LanguageElixir

Elastic Beanstalk Elixir

Prereq: Have and Elastic Beanstalk Multicontainer Environment (see future blog posts)

This is kind of one of those backwards trilogy where the first episode is actually the 2nd episode.

We will be adding or modifying these files:

  1. Change config/prod.secret.exs to config/releases.exs
  2. Modify config/prod.exs
  3. Add lib/my_app_web/release.ex
  4. Add Dockerfile
  5. Add .dockerignore
  6. Add entrypoint.sh
  7. Add buildspec.yml

Part 1:

Prepare app for release (comments removed for brevity)

  1. Change config/prod.secret.exs to config/releases.exs with these contents adjusted to your app:

config/releases.exs

# In this file, we load production configuration and secrets
# from environment variables. You can also hardcode secrets,
# although such is generally not recommended and you have to
# remember to add this file to your .gitignore.
import Config

config :my_app, MyApp.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: System.get_env("RDS_USERNAME"),
  password: System.get_env("RDS_PASSWORD"),
  database: System.get_env("RDS_DB_NAME"),
  hostname: System.get_env("RDS_HOSTNAME"),
  port: System.get_env("RDS_PORT") || 5432,
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

secret_key_base =
  System.get_env("SECRET_KEY_BASE") ||
    raise """
    environment variable SECRET_KEY_BASE is missing.
    You can generate one by calling: mix phx.gen.secret
    """

config :my_app, MyAppWeb.Endpoint,
  http: [:inet6, port: String.to_integer(System.get_env("PORT") || "4000")],
  url: [scheme: "https", host: System.get_env("HOST"), port: 443],
  secret_key_base: secret_key_base

# ## Using releases (Elixir v1.9+)
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start each relevant endpoint:
#
config :my_app, MyAppWeb.Endpoint, server: true
#
# Then you can assemble a release by calling `mix release`.
# See `mix help release` for more information.
  1. Change config/prod.exs to look like this:

my_app/config/prod.exs

use Mix.Config

# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
# when generating URLs.
#
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix phx.digest` task,
# which you should run after static files are built and
# before starting your production server.
config :my_app, MyAppWeb.Endpoint,
  http: [port: {:system, "PORT"}, compress: true],
  url: [scheme: "http", host: System.get_env("HOST"), port: 80],
  code_reloader: false,
  cache_static_manifest: "priv/static/manifest.json",
  server: true

# Do not print debug messages in production
# config :logger, level: :info

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to the previous section and set your `:url` port to 443:
#
#     config :my_app, MyAppWeb.Endpoint,
#       ...
#       url: [host: "example.com", port: 443],
#       https: [
#         :inet6,
#         port: 443,
#         cipher_suite: :strong,
#         keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
#         certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
#       ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
#     config :my_app, MyAppWeb.Endpoint,
#       force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.

# Finally import the config/prod.secret.exs which loads secrets
# and configuration from environment variables.

  1. Create a release.ex file inside lib/my_app_web directory:

lib/my_app_web/release.ex

defmodule MyApp.Release do
  @app :my_app

  def migrate do
    Application.ensure_all_started(@app)
    
    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.load(@app)
    Application.fetch_env!(@app, :ecto_repos)
  end
end

Now to add the Dockefile

Dockerfile

FROM elixir:1.10-alpine as build

# install build dependencies
RUN apk add --update git build-base nodejs npm yarn

# prepare build dir
RUN mkdir /app
WORKDIR /app

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

# set build ENV
ENV MIX_ENV=prod


# install mix dependencies
COPY mix.exs mix.lock ./
COPY config config
RUN mix deps.get --only prod
RUN MIX_ENV=prod mix deps.compile

# build assets
COPY assets assets
COPY priv priv
RUN cd assets && npm install && npm run deploy
RUN mix phx.digest

# build project
COPY lib lib
RUN mix compile

# copy entry point file over
COPY entrypoint.sh entrypoint.sh

RUN mix release

# prepare release image
FROM alpine:3.9 AS app

RUN apk add --update bash openssl postgresql-client

WORKDIR /app

COPY --from=build /app/_build/prod/rel/my_app ./
COPY --from=build /app/entrypoint.sh entrypoint.sh
RUN chown -R nobody: /app
USER nobody

ENV HOME=/app

CMD ["./bin/my_app eval", "MyApp.Release.migrate"]
CMD ["./bin/my_app", "start"]

# ENTRYPOINT ["sh", "./entrypoint.sh"]

Also don't forget to put in a .dockerignore file to cut down on bloat.

.dockerignore

/deps
/_build
ecl_Crash.dump
/node_modules
/assets/node_modules
/priv/static/*
/uploads/files/*
.git
.gitignore

# Elastic Beanstalk Files
.elasticbeanstalk/*
.git
.gitignore

Add an entrypoint.sh script.

entrypoint.sh

#!/bin/bash
# Docker entrypoint script.

# Wait until Postgres is ready
while ! pg_isready -q -h "aa2tzxdwb7y3qa5.xxxxxxxxxxxx.us-west-2.rds.amazonaws.com" -p 5432 -U "ebroot"
                          
do
  echo "$(date) - waiting for database to start"
  sleep 2
done

./bin/my_app eval "MyApp.Release.migrate" && \
./bin/my_app start

Our final step will be to add the buildspec.yml file.

buildspec.yml

version: 0.2

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws --version
      - $(aws ecr get-login --region $AWS_DEFAULT_REGION --no-include-email)
      - REPOSITORY_URI=xxxxxxxxxxxx.dkr.ecr.us-west-2.amazonaws.com/my-app
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - IMAGE_TAG=build-$(echo $CODEBUILD_BUILD_ID | awk -F":" '{print $2}')
  build:
    commands:
      - echo Build started on `date`
      - echo Building the Docker image...
      - docker build -t $REPOSITORY_URI:latest .
      - docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG
  post_build:
    commands:
      - echo Build completed on `date`
      - echo Pushing the Docker images...
      - docker push $REPOSITORY_URI:latest
      - docker push $REPOSITORY_URI:$IMAGE_TAG 
      - aws s3 sync s3://my-app-artifacts .
artifacts:
  files: 
    - Dockerrun.aws.json
    - proxy/conf.d/*

Your app is now ready to be deployed to an Elastic Beanstalk!