Dockerize Elixir Phoenix: Multi-stage Build, OTP Releases và Erlang Clustering production-ready

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

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.getmix 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.

Share: