Elixir PhoenixをDockerize:マルチステージビルド・OTP Releases・本番用Erlangクラスタリング

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

DockerでPhoenixを本番運用して6ヶ月が経った頃、PhoenixのデプロイはNode.jsやPythonとは根本的に異なることに気づきました。難しいからではなく、PhoenixがOTPエコシステム全体——GenServer、ETS、PubSub、そして何より重要なErlangクラスタリング——を連れてくるからです。Node.jsプロジェクトのDockerfileをコピーしてくるだけでもアプリは動きますが、Elixirが持つ力の80%を捨てていることになります。

通常の方法でPhoenixをDockerizeする際の問題

最もシンプルな方法——Elixirをインストールしてmix phx.serverを実行する——では、ビルドツール、Hexパッケージ、ソースコードを含む1GB超のイメージが出来上がります。デプロイのたびに最初からビルドし直しで5〜10分かかり、CI/CDパイプラインが毎日のボトルネックになります。

さらに大きな問題は、コンテナを2つ以上にスケールしたとき、Erlangノードがお互いの存在を認識しないためPhoenix PubSubがコンテナをまたいで機能しないことです。コンテナAからのメッセージブロードキャストは、コンテナBに接続しているユーザーに届きません。LiveViewセッションが途中で切れます。ここでOTP ReleasesとErlangクラスタリングを理解する必要があります——高度な機能ではなく、Phoenixが分散環境で正しく動くための必須条件です。

コードを書く前に押さえておくべきコアコンセプト

OTP Releasesとは?

ソースコードを直接実行する代わりに、mix releaseはアプリケーション全体をErlangランタイムも含めた自己完結型バイナリにコンパイルします。ランタイムイメージにはLinuxベースさえあれば十分で、ElixirもErlangもインストール不要です。実際の結果:イメージサイズが1.2GBから80〜90MBに削減、起動時間が3分の1に短縮、そしてbin/app evalによる組み込みヘルスチェックも利用できます。

DockerでErlangクラスタリングはどう動くか

Erlangノードはepmd(Erlang Port Mapper Daemon、ポート4369)を介してお互いを見つけ、その後ランダムなポートレンジで直接接続します。Dockerでは2つの問題が生じます:

  • コンテナ名解決:ノードはフルホスト名([email protected])で互いを呼び出す必要がある
  • ポートの可視性:epmdとdistributionポートが同一ネットワーク内のコンテナ間でアクセス可能でなければならない

最もシンプルな解決策は、DNSPollストラテジーを使ったlibclusterライブラリです——Docker内部ネットワークのDNSルックアップで自動的にノードを検出し、IPやホスト名のハードコードが不要です。

ステップごとの詳細な実践

ステップ1:Mix Releaseの設定

mix.exsでリリースを宣言する:

def project do
  [
    app: :my_app,
    releases: [
      my_app: [
        include_executables_for: [:unix],
        applications: [runtime_tools: :permanent]
      ]
    ]
  ]
end

rel/env.sh.eexファイルを作成して環境変数からクラスタリングをブートストラップする:

#!/bin/sh
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=<%= @release.name %>@${HOSTNAME}
export RELEASE_COOKIE=${RELEASE_COOKIE:-"change_me_in_production"}

ステップ2:最適化されたマルチステージDockerfile

これは実際に使っているDockerfileで、レイヤーキャッシュを最適化するために何度も調整したものです:

# Stage 1: Builder
FROM hexpm/elixir:1.16.3-erlang-26.2.5-debian-bookworm-20240701-slim AS builder

WORKDIR /app

RUN apt-get update -y && apt-get install -y build-essential git \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

RUN mix local.hex --force && mix local.rebar --force

ENV MIX_ENV=prod

# depsレイヤーを個別にキャッシュ
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

# Build assets
COPY priv priv
COPY assets assets
RUN mix assets.deploy

# Compile app + build release
COPY lib lib
RUN mix compile
COPY config/runtime.exs config/
COPY rel rel
RUN mix release

# Stage 2: Runtime — 必要なものだけ残す
FROM debian:bookworm-20240701-slim AS runner

WORKDIR /app

RUN apt-get update -y && \
  apt-get install -y libstdc++6 openssl libncurses5 locales curl \
  && apt-get clean && rm -rf /var/lib/apt/lists/*

RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8

# 非rootユーザーで実行
RUN useradd --create-home app
USER app

COPY --from=builder --chown=app:app /app/_build/prod/rel/my_app ./

EXPOSE 4000 4369 9000-9010

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
  CMD bin/my_app eval "MyApp.HealthCheck.check()" || exit 1

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

mix deps.getmix deps.compileのレイヤーを分離することで、mix.lockが変更された場合のみ再ビルドされます。初回ビルドは約4分、以降は約45秒です。

ステップ3:自動検出のためのlibclusterの追加

mix.exsに依存関係を追加する:

defp deps do
  [
    {:libcluster, "~> 3.3"},
    # ... 他のdeps
  ]
end

config/runtime.exsでの設定:

config :libcluster,
  topologies: [
    dns_poll: [
      strategy: Cluster.Strategy.DNSPoll,
      config: [
        query: System.get_env("CLUSTER_DNS", "my_app"),
        node_basename: System.get_env("RELEASE_NAME", "my_app"),
        polling_interval: 5_000
      ]
    ]
  ]

他のプロセスより先にApplication supervisorでlibclusterを起動する:

def start(_type, _args) do
  topologies = Application.get_env(:libcluster, :topologies, [])

  children = [
    {Cluster.Supervisor, [topologies, [name: MyApp.ClusterSupervisor]]},
    MyApp.Repo,
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

ステップ4:クラスタリング対応の本番用Docker Compose

スタック全体をdocker-compose v1からv2に移行しましたが、かなりスムーズでした——シンタックスがシンプルになり、version:の宣言が不要になり、Docker内部DNSがlibclusterのサービス検出でより正確に動作します。

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: my_app_prod
      POSTGRES_USER: my_app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U my_app"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: my_app:latest
    deploy:
      replicas: 2
    environment:
      DATABASE_URL: ecto://my_app:${DB_PASSWORD}@db/my_app_prod
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      PHX_HOST: myapp.example.com
      PORT: 4000
      RELEASE_COOKIE: ${RELEASE_COOKIE}
      CLUSTER_DNS: app          # Docker DNSがこのサービス名をルックアップ
      RELEASE_NAME: my_app
    ports:
      - "4000"
    networks:
      - internal
      - proxy
    depends_on:
      db:
        condition: service_healthy

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    networks:
      - proxy
    depends_on:
      - app

volumes:
  db_data:

networks:
  internal:
    internal: true
  proxy:

secrets:
  db_password:
    file: ./secrets/db_password.txt

ポイントはCLUSTER_DNS: appです——libclusterがサービス名appをDNSルックアップし、Docker内部DNSが実行中のすべてのレプリカのIPを返します。2つのノードが追加設定なしでお互いを検出します。3つ目のレプリカにスケールすると、5秒以内に自動でクラスタに参加します。

ステップ5:マイグレーションとデプロイスクリプト

新しいアプリへのトラフィックを受ける前に安全にデータベースマイグレーションを実行する:

#!/bin/bash
set -e

docker compose pull
docker compose run --rm app bin/my_app eval "MyApp.Release.migrate()"
docker compose up -d --no-deps app

# クラスタが形成されたことを確認
docker compose exec app bin/my_app rpc "IO.inspect(Node.list())"

マイグレーションモジュールをlib/my_app/release.exに追加する:

defmodule MyApp.Release do
  @app :my_app

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

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

デプロイスクリプトの最後のコマンドがクラスタ内のノードリストを出力します——[:[email protected]]が表示されれば、2つのコンテナが正常に接続されています。

まとめ

このセットアップはmix phx.serverをコンテナで直接実行する方法より数時間余分にかかりますが、その代わりに得られるのは:イメージサイズが10分の1、自動クラスタリング、ゼロダウンタイムデプロイ、そして真の本番対応スタックです。Elixirでは、OTP Releasesとクラスタリングを省略することは、多くのチームが最初からこの言語を選んだ主な理由を捨てることと同じです。

2つのレプリカを常時稼働させて6ヶ月運用した結果、このスタックはピークトラフィックも手動介入なしに問題なく処理しています。1つのコンテナが障害でリスタートしても、クラスタが自動でリバランスし、PubSubブロードキャストはコンテナをまたいで動作を続け、ユーザーはほとんど障害に気づきません。

Share: