この記事はElixir Advent Calendar 2019 14日目の記事です

昨日は @32hero による「Elixir Circuitsを使用してLEDを操作する」でした!

今日の記事はタイトルにあるように、Phoenixプロジェクトをmix releaseでパッケージ化し、コンテナ内で動作するまでの流れをおさらいしてみようと思います。

  • dockerが利用できること
  • elixir1.9を利用してphoenixプロジェクトを用意できること

という前提で書いております、ご容赦ください。

mix release?

Elixir1.9から導入された機能で、Elixirプロジェクトのコード、Erlang VMとそのランタイムをひとまとめにパッケージ化できます。

https://hexdocs.pm/mix/Mix.Tasks.Release.html

リリースを利用することで、

  • 事前にモジュールのロードが行われるため、起動後の処理が高速
  • ランタイムごとパッケージ化されるので、サーバーにErlang/Elixirが不要になる

    • Alpineのコンテナで動作させることができる(opensslなど一部パッケージは必要)

などのメリットがあります。

参考: https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-why-releases

ただ、パッケージ化されたコードがどこでも動くかというとそうではなく、mix release コマンドを実行しているマシンと同じOS で動作させる必要があるので注意です。

「Mac OSでmix releaseを実行して、出来上がったパッケージをUbuntu上で動かす!」ということはできません。

なので、実行環境と合わせたdockerコンテナを用意してビルドするというのが基本的な流れになります。

ローカルで試してみる

次のような構成をゴールに、ローカル環境を作っていきましょう。

container

執筆時点での環境は次の通りです。

$ elixir -v
Erlang/OTP 21 [erts-10.3.4] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.9.1 (compiled with Erlang/OTP 20)

忙しい人のために

以下の構築をすべて行ったものをリポジトリに置いていますので、なんとなくコードだけサッとみたい人はどうぞ。

https://github.com/koga1020/mix_release_example

プロジェクトの作成

早速、phoenixプロジェクトを作成します。

$ mix phx_new mix_release_example

* creating mix_release_example/config/config.exs
* creating mix_release_example/config/dev.exs
* creating mix_release_example/config/prod.exs
...

Fetch and install dependencies? [Yn] y # y でOK
* running mix deps.get
* running mix deps.compile
* running cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development

dbコンテナの作成

続いて、DBをdockerで用意しましょう。既存の別のDBを利用したい人は飛ばして適宜読み替えてください。

  • dockerfiles/db/Dockerfile
  • docker-compose.yml

の2ファイルを作成します。

# dockerfiles/db/Dockerfile
FROM postgres:alpine

CMD ["postgres"]

EXPOSE 5432
# docker-compose.yml
version: '3'

networks:
  backend:
    driver: bridge

services:
  db:
    build: ./dockerfiles/db
    volumes:
      - ./volumes/db/data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_DB=default
      - POSTGRES_USER=default
      - POSTGRES_PASSWORD=secret
    networks:
      - backend

マウント用のディレクトリも作成しておきます。

$ mkdir volumes

ファイルが用意できたら、コンテナを起動しておきます。

$ docker-compose up -d

ectoの設定

config/dev.exsのDBへの接続設定を修正します。

 # Configure your database
 config :mix_release_example, MixReleaseExample.Repo,
-  username: "postgres",
-  password: "postgres",
-  database: "mix_release_example_dev",
+  username: "default",
+  password: "secret",
+  database: "default",
   hostname: "localhost",
   pool_size: 10

mix ecto.setupがエラーなく動作すれば接続はOKです。

$ mix ecto.setup
The database for MixReleaseExample.Repo has already been created

15:00:47.980 [info]  Already up

Ectoの設定が終われば、一度サーバーを起動してみて問題なく起動するか確認しておきましょう。

$ mix phx.server

サンプル用のUserスキーマを追加

サンプルとして、nameカラムだけを持ったusersテーブルをサクッと作成しましょう。

$ mix phx.gen.context Accounts User users name:string
$ mix ecto.migrate

確認用のseedを合わせて作成します。priv/repo/seeds.exsを次のように修正しましょう。

alias MixReleaseExample.Accounts

1..10
|> Enum.map(fn itr ->
  # 適当に10ユーザー作成
  %{
    name: "test user #{itr}"
  }
end)
|> Enum.each(fn attrs ->
  Accounts.create_user(attrs)
end)

exsファイルが修正できたら、seedを実行しておきます。insert文が発行されていればOKです。

$ mix run priv/repo/seeds.exs 
[debug] QUERY OK db=9.9ms decode=1.1ms queue=22.6ms idle=0.0ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["test user 1", ~N[2019-12-14 06:06:57], ~N[2019-12-14 06:06:57]]
[debug] QUERY OK db=6.9ms queue=1.1ms idle=22.5ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["test user 2", ~N[2019-12-14 06:06:57], ~N[2019-12-14 06:06:57]]
[debug] QUERY OK db=4.0ms queue=1.2ms idle=30.8ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["test user 3", ~N[2019-12-14 06:06:57], ~N[2019-12-14 06:06:57]]
...

画面に表示する

画面確認用に、usersテーブルの値を画面に出しておきましょう。lib/mix_release_example_web/controllers/page_controller.exを修正し、ユーザーのlistをviewに追加します。

 defmodule MixReleaseExampleWeb.PageController do
   use MixReleaseExampleWeb, :controller
+  alias MixReleaseExample.Accounts
 
   def index(conn, _params) do
-    render(conn, "index.html")
+    users = Accounts.list_users()
+    render(conn, "index.html", users: users)
   end
 end

さらにlib/mix_release_example_web/templates/page/index.html.eexを修正し、渡されたusersの情報をtable形式で表示するようにします。

-<section class="phx-hero">
-  <h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1>
-  <p>A productive web framework that<br/>does not compromise speed and maintainability.</p>
-</section>
-
-<section class="row">
-  <article class="column">
-    <h2>Resources</h2>
-    <ul>
-      <li>
-        <a href="https://hexdocs.pm/phoenix/overview.html">Guides &amp; Docs</a>
-      </li>
-      <li>
-        <a href="https://github.com/phoenixframework/phoenix">Source</a>
-      </li>
-      <li>
-      </li>
-    </ul>
-  </article>
-  <article class="column">
-    <h2>Help</h2>
-    <ul>
-      <li>
-        <a href="https://elixirforum.com/c/phoenix-forum">Forum</a>
-      </li>
-      <li>
-        <a href="https://webchat.freenode.net/?channels=elixir-lang">#elixir-lang on Freenode IRC</a>
-      </li>
-      <li>
-      </li>
-    </ul>
-  </article>
-</section>
+<div>
+  <table>
+    <thead>
+      <th>id</th>
+      <th>name</th>
+    </thead>
+    <tbody>
+      <tr>
+        <td><%= user.id %></td>
+        <td><%= user.name %></td>
+      </tr>        
+      <% end %>
+    </tbody>
+</div>

localhost:4000を確認して、テーブルが表示されていればOKです。

users_table

これでDBを利用したPhoenixプロジェクトの雛形ができました。

動作確認ができたら、mix releaseでの動作確認のためにphoenixサーバーは一度落としておきましょう。

releaseコマンドを叩いてみる

Phoenixプロジェクトをひとまずホストマシンでパッケージ化してみましょう。

$ MIX_ENV=prod mix release

* assembling mix_release_example-0.1.0 on MIX_ENV=prod
* skipping runtime configuration (config/releases.exs not found)

Release created at _build/prod/rel/mix_release_example!

    # To start your system
    _build/prod/rel/mix_release_example/bin/mix_release_example start

Once the release is running:

    # To connect to it remotely
    _build/prod/rel/mix_release_example/bin/mix_release_example remote

    # To stop it gracefully (you may also send SIGINT/SIGTERM)
    _build/prod/rel/mix_release_example/bin/mix_release_example stop

To list all commands:

    _build/prod/rel/mix_release_example/bin/mix_release_example

コンパイルが走り、_build以下にリリースが作成されていればOKです。

試しにstartしてみると、

  • マニフェストファイルがないよ
  • DBに接続できないよ

というエラーがつらつらと表示されるはずです。prod環境のDB情報はまだ何もいじっていないので、そりゃそうですね。

$ _build/prod/rel/mix_release_example/bin/mix_release_example start
# 一部パスを伏せています
15:18:56.188 [error] Could not find static manifest at "xxxxxxx/mix_release_example/_build/prod/rel/mix_release_example/lib/mix_release_example-0.1.0/priv/static/cache_manifest.json". Run "mix phx.digest" after building your static files or remove the configuration from "config/prod.exs".
15:18:56.216 [error] Postgrex.Protocol (#PID<0.1376.0>) failed to connect: ** (Postgrex.Error) FATAL 28P01 (invalid_password) password authentication failed for user "postgres"
15:18:56.220 [error] Postgrex.Protocol (#PID<0.1384.0>) failed to connect: ** (Postgrex.Error) FATAL 28P01 (invalid_password) password authentication failed for user "postgres"

まずはマニフェストファイルを作成しましょう。次のコマンドを実行します。

$ mix phx.digest

続いて、DBの接続情報を整えていきましょう。

config/prod.secret.exsを修正し、正しい接続情報を記載します。

# Configure your database
config :mix_release_example, MixReleaseExample.Repo,
  username: "default",
  password: "secret",
  database: "default",
  pool_size: 15

接続情報を修正したら、再度releaseコマンドを実行します。

$ MIX_ENV=prod mix release
Generated mix_release_example app
Release mix_release_example-0.1.0 already exists. Overwrite? [Yn] y # yで上書き
* assembling mix_release_example-0.1.0 on MIX_ENV=prod
* skipping runtime configuration (config/releases.exs not found)

Release created at _build/prod/rel/mix_release_example!

    # To start your system
    _build/prod/rel/mix_release_example/bin/mix_release_example start

Once the release is running:

    # To connect to it remotely
    _build/prod/rel/mix_release_example/bin/mix_release_example remote

    # To stop it gracefully (you may also send SIGINT/SIGTERM)
    _build/prod/rel/mix_release_example/bin/mix_release_example stop

To list all commands:

    _build/prod/rel/mix_release_example/bin/mix_release_example

もう一度起動してみると、とくにエラーは出ないものの、localhost:4000にアクセスしても何も表示されないはずです。

$ _build/prod/rel/mix_release_example/bin/mix_release_example start

これはprod.exsでデフォルトだとstart時にサーバーが起動しないような設定になっているからです。config/prod.exsを開き、Endpointのオプションをアンコメントして有効にします。

 # Alternatively, you can configure exactly which server to
 # start per endpoint:
 #
-#     config :mix_release_example, MixReleaseExampleWeb.Endpoint, server: true
+config :mix_release_example, MixReleaseExampleWeb.Endpoint, server: true
 #
 # Note you can't rely on `System.get_env/1` when using releases.
 # See the releases documentation accordingly.

再度、releaseしてstartしてみましょう。

$ MIX_ENV=prod mix release
$ _build/prod/rel/mix_release_example/bin/mix_release_example start

15:35:59.792 [info] Running MixReleaseExampleWeb.Endpoint with cowboy 2.7.0 at :::4000 (http)
15:35:59.793 [info] Access MixReleaseExampleWeb.Endpoint at http://example.com

Phoenixのログが表示され、localhost:4000にアクセスするとPhoenixのページが見れるはずです。

これでmix releaseを使ってパッケージしたプロジェクトを動作させることができました。

ただ、これはホストマシン上でreleaseして同じくホストマシン上で実行するパターンで、コンテナ上で動作するアプリケーションではありません。

次にコンテナを利用して起動させるので、今起動しているPhoenixサーバーは落としておきましょう。

コンテナ化する

このphoenixプロジェクトをコンテナ化してみましょう。PhoenixのリポジトリにDockerfileの例が載っているのでありがたく利用します。

https://github.com/phoenixframework/phoenix/blob/master/guides/deployment/releases.md#containers

※ hexdocsとmasterブランチで記載例が異なっていたので、masterを参考しましょう。hexdocsの例だとnpmが入りません。

dockerfiles/app/Dockerfileを作成し、リポジトリ例のまま記述します。

FROM elixir:1.9.0-alpine AS build

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

# 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
RUN 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

# build release (uncomment COPY if rel/ exists)
# COPY rel rel
RUN mix release

# prepare release image
FROM alpine:3.9 AS app
RUN apk add --update bash openssl

RUN mkdir /app
WORKDIR /app

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

ENV HOME=/app

dockerのマルチステージビルドを利用して、「elixir:1.9.0-alpineイメージでreleaseを実行→alpine:3.9へ成果物をコピー」という処理を行なっています。これにより、最終的にはリリースパッケージだけ入った軽量なイメージができ上がります。

docker-compose.ymlを修正して、このdockerfileを指定してappコンテナを起動させます。

       - POSTGRES_DB=default
       - POSTGRES_USER=default
       - POSTGRES_PASSWORD=secret
    networks:
      - backend
+  app:
+    build: 
+      dockerfile: ./dockerfiles/app/Dockerfile
+      context: .
+    command: "bin/mix_release_example start"
+    ports:
+      - "4000:4000"
+     networks:
+       - backend

commandを指定し、コンテナ起動時にstartコマンドを実行するようにしています。

修正したら、コンテナを起動します。

$ docker-compose up -d

イメージのbuildが終わると、ローカルの4000番とコンテナの4000番がbindされ、appコンテナが起動します。

$ docker-compose ps
          Name                         Command              State           Ports         
------------------------------------------------------------------------------------------
mix_release_example_app_1   bin/mix_release_example start   Up      0.0.0.0:4000->4000/tcp
mix_release_example_db_1    docker-entrypoint.sh postgres   Up      0.0.0.0:5432->5432/tcp

localhost:4000にアクセスすると、Internal Server Errorになリます。

docker-compose logs appでログを見ると、DB接続のエラーが出ているはずです。ローカルで繋がっていた接続情報のままコンテナでは動作しません(当たり前)。

configの修正

ではどう設定するかというと、config/releases.exsファイルを作成します。releases.exsはパッケージされたアプリケーションの実行時に評価されるため、実行環境の環境変数を読み込むことができます。

一方、prod.exs(厳密にはreleases.exs以外のconfig)はmix releaseを実行したタイミングで評価されるため、パッケージを作ったタイミングで値が決定してしまいます。mix releaseを実行する環境と、パッケージされたアプリケーションが動作する環境が異なる場合には環境変数の読み込みができません。

なので、

  • DBの接続情報などの秘匿情報 → releases.exsに記述して、実行時に環境変数を読み込む
  • ログレベルなど環境変数から読み込む必要のない設定 → prod.exsに記述する

という方針になります。

方針が決まったら実際に修正してみます。config/releases.exsを作成して次のように記述します。

import Config

config :mix_release_example, MixReleaseExampleWeb.Endpoint,
  secret_key_base: System.fetch_env!("SECRET_KEY_BASE")

# Configure your database
config :mix_release_example, MixReleaseExample.Repo,
  username: System.fetch_env!("DB_USER"),
  password: System.fetch_env!("DB_PASSWORD"),
  database: System.fetch_env!("DB_NAME"),
  port: System.fetch_env!("DB_PORT"),
  hostname: System.fetch_env!("DB_HOST"),
  pool_size: 15

prod.secret.exsの値をコピーして、hostnameを追加した上で環境変数を読み込む形に変更しています。また、1.9から導入されたimport Configを利用する必要があるので注意です。

一方、prod.secret.exsは不要になったので、config/prod.exsから読み込み部分を消しておきます。

-
-# Finally import the config/prod.secret.exs which should be versioned
-# separately.
-import_config "prod.secret.exs"

あとはdocker-compose.ymlを修正して、コンテナに環境変数を流し込みます。(SECRET_KEY_BASEは読み替えてください)

       dockerfile: ./dockerfiles/app/Dockerfile
       context: .
     command: "bin/mix_release_example start"
+    environment:
+      - DB_USER=default
+      - DB_PASSWORD=secret
+      - DB_NAME=default
+      - DB_PORT=5432
+      - DB_HOST=db
+      - SECRET_KEY_BASE=qEb5cvzNQYgNogxW8pxIexlp2cff9i5E2dWqTyHKsCYxSsEDwJz6GCzcc2l2Oy/0
     ports:
       - "4000:4000"
     networks:

DB_HOSTにはdbというサービス名を指定してコンテナ間通信でDBコンテナへアクセスさせます。

修正が完了したら、再度buildし、コンテナを立ち上げなおします。

$ docker-compose build app
$ docker-compose down
$ docker-compose up -d

localhost:4000にアクセスして、appコンテナで動いているPhoenixサーバーからレスポンスが確認できれば完了です!

まとめ

mix releaseを覚えることで、「パッケージを格納したDockerイメージをCIで作成→ECRヘpush→コンテナ起動」といったデプロイパイプラインを組むことができます。configが若干ハマりどころですので、このチュートリアルでなんとなく掴んでもらえればと思います。


明日の Elixir Advent Calendar 2019 は @MzRyuKa さんの「簡単な名前診断プログラムをElixirで書いてみる話」です。お楽しみに!