読者です 読者をやめる 読者になる 読者になる

phoenixアプリケーションをDockerでデプロイする

最近phoenix(elixir)を使っている. まぁelixir自体の話は,いろんな記事でおすすめされているのでそちらを参照してもらうとして,これをDockerに載せたいなーと思ったので.

なんでDockerに載せたいのか

erlangとelixirの環境構築を毎回やりたくない っていうのがすべて.

そういう意味では,別にchefでもよかった.

ただ,最近は開発環境はみんなDockerにしていて,家のLinuxでもほとんどDockerしか使っていないので,できればみんなDockerにしておきたい.

開発環境をDockerに

これは割と楽で,ふつうのことしかしない.

elixirは公式でもdocker imageを配布しているので,そちらを使わせてもらう.

FROM elixir:1.3.4-slim

ENV APP_DIR /var/opt/app

RUN set -x && \
  apt-get update && \
  apt-get install -y --no-install-recommends \
  nodejs \
  npm \
  mysql-client \
  inotify-tools \
  git \
  imagemagick \
  curl && \
  rm -rf /var/lib/apt/lists/* && \
  npm cache clean && \
  npm install n -g && \
  n stable && \
  ln -sf /usr/local/bin/node /usr/bin/node && \
  apt-get purge -y nodejs npm

RUN useradd -m -s /bin/bash elixir
RUN echo 'elixir:password' | chpasswd
RUN mkdir -p ${APP_DIR}

USER elixir

WORKDIR ${APP_DIR}

imagemagickとかcurlは,使うのであれば……というくらい. mysql-clientあたりは,DB接続に必要なので入れておく.mysql以外を使う場合には,相応のクライアントを.

npm関連はphoenixで必要となるので,入れておいた.

www.phoenixframework.org

あとは,

version: '2'
services:
  storage:
    image: busybox
    volumes:
      - /var/lib/mysql
  mysql:
    image: mysql:5.6
    volumes_from:
      - storage
    ports:
      - "3306:3306"
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "true"
  elixir:
    image: h3poteto/phoenix
    env_file: .docker-env
    volumes:
      - .:/var/opt/app
    links:
      - mysql:mysql
    ports:
      - "4000:4000"
    command: /bin/bash

こんな感じのdocker-compose.ymlを作っておけばよい. 開発環境はこれでだいたい行けた.

本番環境をDockerにする

先に言っておくと,現段階で本番環境をDockerにするのは,そこまでサイコーとは言えない. 開発環境に関しては,かなり楽なので是非おすすめしたいが,本番環境については,もう少し考えたほうがいいかもしれない.

Phoenixのビルド

準備

リリース用ビルドはexrmを使う.

www.phoenixframework.org

まず, mix.exsexrmを足す.

  defp deps do
    [{:phoenix, "~> 1.2.0"},
     {:phoenix_ecto, "~> 3.0"},
     {:postgrex, ">= 0.0.0"},
     {:phoenix_html, "~> 2.3"},
     {:phoenix_live_reload, "~> 1.0", only: :dev},
     {:cowboy, "~> 1.0"},
     {:exrm, "~> 1.0"}]
  end

次に, config/prod.exsserver: true を追加する. prodに独自の設定を追加したい場合はここに書く.

config :sample_app, SampleApp.Endpoint,
  http: [port: 4000],
  cache_static_manifest: "priv/static/manifest.json",
  server: true

cache_static_manifest は,ビルド時にdigestつきのassetsを生成するため,それを参照できるように manifest.json を指定しておく. portは好きに書き換えていいと思う.

これでビルドができる.

ビルド

ビルドをしてみる. 実際にリリースする際には,このビルド処理はdocker image生成時に行いたいので,ここでは試しにビルドするだけ.

適当にelixirの環境構築が済んでいるlinux上で,

$ export MIX_ENV=prod
$ mix local.hex --force
$ mix local.rebar --force
$ mix deps.get --only prod
$ mix deps.compile
$ mix compile
$ npm install
$ npm run compile # ここは自分で設定したpackage.jsonのコマンドによるもの
$ mix phoenix.digest # これにより,npmでcompileしたassetsにdigestをつけて,manifest.jsonを更新する
$ mix release # リリース用のビルドを生成する

というような一連のコマンドを実行すると,リリース用ビルドが rel/<app>/bin/ の下にできている. ちなみに新しいバージョンのリリース用ビルドを生成すると, rel/<app>/reelases/<version>/ にいろいろ増えていくので,この辺はやってみるとすぐにわかる.

で,やりたいこととしては,この成果物を詰め込んだdocker imageをデプロイしたい

Dockerfile

Dockerfileについては,先ほどと同じものを使ってもいいし,インストールするものを減らして軽量化しても良い. ただ,exrmのビルドには,erlangのランタイム環境も含まれているため,ビルドと実行は同じプラットフォームで行う必要がある. Macでビルドしたものをlinuxで実行はできない.

そのためdocker build時にphoenixのビルドしておきたい.

先ほどの開発環境用のDockerをベースに,以下のようなDockerfileを作った.

FROM h3poteto/phoenix:latest

USER root

COPY . ${APP_DIR}

RUN chown -R elixir:elixir ${APP_DIR}

USER elixir

ENV MIX_ENV=prod

RUN mix local.hex --force
RUN mix local.rebar --force
RUN mix deps.get --only prod
RUN mix deps.compile
RUN mix compile

RUN npm install \
  && npm run compile \
  && rm -rf node_modules

RUN mix phoenix.digest
RUN mix release

CMD rel/sample_app/bin/sample_app foreground

rel/sample_app 内のbinを普通に実行すると,デーモンが起動するが,Dockerなのでプロセスはフォアグラウンドで動いてほしい. そのため,foreground指定をして起動している.

migration

デプロイ時にはmigrationをかけたいと思うだろう. 上記のdocker imageには,ビルド結果だけでなくソースも含まれているので,docker run コマンド等で,mixコマンドを打っても別に動くのだが…….

ビルドされた成果物に対するコマンド実行が,exrm側で用意されているため,migrationもできる.

http://blog.plataformatec.com.br/2016/04/running-migration-in-an-exrm-release/

$ rel/sample_app/bin/sample_app command Elixir.Release.Tasks migrate

これをdocker run に渡してやれば良い.

困りどころ

configで使う値を環境変数で渡せない

実際にprod用のimageを作って実行してみて,困ったことがあった. pheonixをnewしたときに,config/prod.secret.exs というものが作られていると思う. ここにはDB接続情報や,SECRET_KEY_BASEなどの機密情報を含む設定を書くらしい. もちろんgitignoreされているので,本番にだけ置いてほしいということだろう.

DB接続情報

Dockerで本番をデプロイするにあたり,できればDB接続情報等の機密情報はコンテナ内に含めたくないと考えた.

なので,外から環境変数で渡すようにしようと思い,prod.secret.exs を廃止し,prod.exs

config :sample_app, SampleApp.Endpoint,
  secret_key_base: System.get_env("SECRET_KEY_BASE")

# Configure your database
config :sample_app, SampleApp.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: System.get_env("DB_USER"),
  password: System.get_env("DB_PASSWORD"),
  database: "sample_app",
  hostname: System.get_env("DB_HOST"),
  port: 3306,
  pool_size: 5

このように環境変数から読み込むようにした.

しかし,これではphoenix起動と同時に database connection errorが大量に出てきて,アプリケーションが起動できなかった.

どうやら,この設定,ビルド時に埋め込まれるらしく,ビルド時に $DB_USER等の環境変数がセットされていないと,nil がわたってしまう. まさか実行時の展開じゃないとは…….

これにはみんな困るらしく,

engineering.avvo.com

先人がいた!!

ポイントとしては,ビルド時に埋め込まれてしまう環境変数については,"${DB_USER}" というような文字列として埋め込みをしておく.

config :sample_app, SampleApp.Endpoint,
  secret_key_base: "${SECRET_KEY_BASE}"

config :sample_app, SampleApp.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "${DB_USER}",
  password: "${DB_PASSWORD}",
  database: "sample_app",
  hostname: "${DB_HOST}",
  port: 3306,
  pool_size: 5

で,Docker起動時に $DB_USER を渡すんだけど,同時にあわせて $RELX_REPLACE_OS_VARS=trueを渡せよってことらしい.

$ docker run --name sample_app --env SECRET_KEY_BASE=hogehoge \
  DB_USER=hoge \
  DB_PASSWORD=fuga \
  DB_HOST=localhost \
  RELX_REPLACE_OS_VARS=true \
  sample_app:latest

見事これで環境変数を展開できた.

まぁ文字列しか渡せないので,例えば,portみたいなのはビルド時の型チェックで,Intじゃないと怒られて終わる.

ex_aws

もうひとつ秘密にしたいものがあって,ex_aws というhexを使っているのだけど,これに渡すAWSの鍵だ.

こいつに関しては,ex_awsのREADMEに書いてあるとおりに従った.

GitHub - CargoSense/ex_aws: A flexible, easy to use set of clients AWS APIs for Elixir

config :ex_aws,
  access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
  secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role],
  region: "ap-northeast-1"

ここでも,System.get_env() を使ったら全滅した.

OTPの切り替え問題

もうひとつ,こちらは規模が小さければ些細な問題である. が,elixirのいいところを潰してしまう問題でもある.

ネイティブのelixir環境

非Docker環境で,普通にexrmでサービスをデプロイする場合,新しいビルドを生成する際には以下のような手順を踏むと思う.

  1. リリース用にアプリケーションのバージョンを上げる
  2. git pullして最新のソースを落としてくる
  3. mix release他,コマンドを打って,rel/sample_app/releases 以下に最新のprodビルドを作る
  4. $ rel/sample_app/bin/sample_app upgrade 0.0.2 のようにバージョンアップコマンドを打つ

この最後のバージョンアップコマンドが曲者で,本来,elixirのリリースはこのようにバージョン番号をちゃんとアプリケーションに設定し,バージョン番号に従ってupgradeを行うように設計されている.

このupgradeの際,OTPでは古いバージョンと新しいバージョンをちゃんと区別して処理することができる. そのため,まだ古いアプリケーションからのリクエストがOTPに飛んでいても,最後まで処理してくれる.

非同期処理を含むアプリケーションのデプロイの泣きどこをうまいこと解消してくれている.

このOTP周りについては,

プログラミングElixir

プログラミングElixir

この本にもう少し詳しく書いてある.

Docker上

Dockerの上でこのようなデプロイは,ほとんど不可能である. コンセプト的に,Dockerは新しいバージョンには,新しいコンテナを生成する. 今回紹介した方法でも,rel ディレクトリの下に成果物が溜まっていくことはなく,毎回最新のアプリケーションをビルドしたimageを生成するだけだ.

そして,それをswarmを使うなり,kurbernetesを使うなり,ECSを使うなりして切り替えるしかない.

この切り替えの時,確かにhttpのリクエストは前段のLoadBalancerとデプロイの組み合わせ方により,途切れさせることなく新しいコンテナ側に移行することができる.

しかし,リクエストにより発生した各種非同期処理は,コンテナ内で生き続けている可能性は否定できない. そしてそのコンテナを殺した場合には,非同期処理も途中で殺されてしまう.

まぁこの問題自体は,実はelixir固有の問題ではなくて,たとえばgoでgoroutineをいっぱい使って裏側で処理をしていた場合などにも,問題になってくるだろう.

まとめ

並列処理は人類には早いのかもしれない.

というのはあるのだが,どうにもphoenixはdockerとのかみ合わせがいまいちだった. 特に環境変数に関しては,configで設定する必要があるhexを入れるたびに,どうやって渡すかを検討する必要があるため,注意が必要だ.

そして,デプロイ時のOTPに関しては,Dockerでは解決方法がない. 今の段階では,まだchefで環境構築して,prod.secret.exs をちゃんと用意した上で,upgradeを真面目にやったほうがいいのかなぁ.