Sau 6 tháng chạy Phoenix trên production trong Docker, mình nhận ra deploy Phoenix khác hoàn toàn so với Node.js hay Python. Không phải vì khó hơn, mà vì Phoenix mang theo cả một hệ sinh thái OTP — GenServer, ETS, PubSub, và quan trọng hơn là clustering Erlang. Nếu bạn chỉ copy Dockerfile từ một project Node.js sang, ứng dụng vẫn chạy được, nhưng bạn đang bỏ mất 80% sức mạnh mà Elixir mang lại.
Vấn đề khi Dockerize Phoenix theo kiểu thông thường
Container hóa Phoenix theo cách đơn giản nhất — install Elixir, chạy mix phx.server — cho ra một image nặng hơn 1GB với đầy đủ build tools, Hex packages, source code. Mỗi lần deploy phải build lại từ đầu mất 5–10 phút, và CI/CD pipeline trở thành điểm nghẽn mỗi ngày.
Vấn đề lớn hơn: khi scale lên 2 container trở lên, Phoenix PubSub không hoạt động xuyên container vì các node Erlang không biết nhau tồn tại. Message broadcast từ container A không tới được user đang connect vào container B. LiveView sessions bị break giữa chừng. Đây là lúc bạn cần hiểu OTP Releases và Erlang clustering — không phải là tính năng nâng cao, mà là điều kiện cần để Phoenix chạy đúng trong môi trường distributed.
Khái niệm cốt lõi cần nắm trước khi code
OTP Releases là gì?
Thay vì chạy source code trực tiếp, mix release compile toàn bộ ứng dụng thành một self-contained binary — bao gồm cả Erlang runtime. Runtime image chỉ cần Linux base, không cần Elixir hay Erlang được cài sẵn. Kết quả thực tế: image giảm từ 1.2GB xuống còn 80–90MB, startup time giảm 3x, và bạn có thêm health check tích hợp qua bin/app eval.
Erlang Clustering trong Docker hoạt động thế nào?
Erlang nodes tìm nhau qua epmd (Erlang Port Mapper Daemon, port 4369) rồi kết nối trực tiếp qua một port range ngẫu nhiên. Trong Docker, hai vấn đề nảy sinh:
- Container name resolution: nodes cần gọi nhau bằng hostname đầy đủ (
[email protected]) - Port visibility: epmd và distribution ports phải accessible giữa các container trong cùng network
Giải pháp gọn nhất là dùng thư viện libcluster với strategy DNSPoll — tự động discover nodes qua DNS lookup trong Docker internal network, không cần hardcode IP hay hostname.
Thực hành chi tiết từng bước
Bước 1: Cấu hình Mix Release
Khai báo release trong mix.exs:
def project do
[
app: :my_app,
releases: [
my_app: [
include_executables_for: [:unix],
applications: [runtime_tools: :permanent]
]
]
]
end
Tạo file rel/env.sh.eex để bootstrap clustering từ environment:
#!/bin/sh
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=<%= @release.name %>@${HOSTNAME}
export RELEASE_COOKIE=${RELEASE_COOKIE:-"change_me_in_production"}
Bước 2: Multi-stage Dockerfile tối ưu
Đây là Dockerfile mình đang dùng thực tế, đã tinh chỉnh qua nhiều lần iteration để tối ưu layer cache:
# 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
# Cache deps layer riêng biệt
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 — chỉ giữ những gì cần thiết
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
# Chạy với user non-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"]
Layer mix deps.get và mix deps.compile được tách riêng — chỉ rebuild khi mix.lock thay đổi. Build lần đầu khoảng 4 phút, các lần sau ~45 giây.
Bước 3: Thêm libcluster cho auto-discovery
Thêm dependency vào mix.exs:
defp deps do
[
{:libcluster, "~> 3.3"},
# ... deps khác
]
end
Cấu hình trong 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
]
]
]
Start libcluster trong Application supervisor trước các process khác:
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
Bước 4: Docker Compose production-ready với clustering
Mình đã chuyển từ docker-compose v1 sang v2 cho toàn bộ stack và quá trình khá smooth — syntax đơn giản hơn, bỏ khai báo version:, và Docker internal DNS hoạt động chính xác hơn với service discovery của 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 lookup service name này
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
Điểm mấu chốt: CLUSTER_DNS: app — libcluster DNS lookup service name app, Docker internal DNS tự trả về IP của tất cả replica đang chạy. Hai node tìm thấy nhau mà không cần config thêm gì. Khi scale lên 3 replica, node thứ 3 tự join cluster trong vòng 5 giây.
Bước 5: Migration và deployment script
Chạy database migration an toàn trước khi traffic vào app mới:
#!/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
# Verify cluster đã hình thành
docker compose exec app bin/my_app rpc "IO.inspect(Node.list())"
Module migration cần thêm vào 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
Lệnh cuối trong deploy script sẽ in ra danh sách node trong cluster — nếu thấy [:[email protected]] là hai container đã kết nối thành công.
Kết luận
Setup này tốn thêm vài giờ so với cách chạy mix phx.server trực tiếp trong container, nhưng đổi lại bạn có: image nhỏ hơn 10x, clustering tự động, zero-downtime deploy, và một stack thực sự production-ready. Với Elixir, bỏ qua OTP Releases và clustering là bỏ đi lý do chính mà nhiều team chọn ngôn ngữ này ngay từ đầu.
Sau 6 tháng vận hành với 2 replica liên tục, stack này xử lý tốt peak traffic mà không cần can thiệp thủ công. Khi một container gặp sự cố và restart, cluster tự rebalance, PubSub broadcast vẫn hoạt động xuyên container, và người dùng gần như không nhận ra có sự cố xảy ra.

