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.getとmix 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ブロードキャストはコンテナをまたいで動作を続け、ユーザーはほとんど障害に気づきません。

