Erlang/Elixir 容器化与集群部署:Release、Docker 与 libcluster

系统讲解 BEAM 应用的容器化部署:Mix release 构建与产物结构、多阶段 Dockerfile 与镜像瘦身、runtime.exs 与 RELEASE_* 运行时配置、节点命名与 EPMD 陷阱、libcluster 的 K8s DNS 与 gossip 策略,以及滚动升级、优雅停机和健康检查。

把 BEAM 应用放进容器,看似只是「写个 Dockerfile」,实际上要跨过三道坎。第一道是构建:BEAM 的发布包包含 ERTS 运行时、编译后的 beam 文件、启动脚本与配置,与 JVM 的 fat jar 或 Go 的静态二进制都不同,需要用 mix release 生成。第二道是配置:编译期固化配置会让同一个镜像无法在不同环境运行,而 BEAM 传统的 sys.config 又依赖文件挂载。第三道是集群:Erlang 的分布式能力依赖节点名与 EPMD,而容器里 Pod IP 是动态的,节点名不能写死。

这三道坎都有成熟的解法,但它们彼此关联——节点名影响 RELEASE_* 变量,运行时配置影响镜像的可移植性,集群策略又影响滚动升级的行为。本文按「构建 → 配置 → 集群 → 发布」的顺序,把这条链路完整走一遍。

一、BEAM 应用的部署模型演进

1.1 三代部署方式

方式做法问题
裸机 + erl直接 erl -pa ebin 启动依赖环境、无版本管理
rebar3 release / distillery打 release 包,外挂 sys.config配置需挂载,镜像不自治
Mix release(Elixir 1.9+)官方内置,runtime.exs 支持运行时配置需要 Elixir 1.9+

Mix release 成为事实标准的原因是它把「运行时配置」做进了一等公民:config/runtime.exs 在节点启动时执行,可以读环境变量,从而做到「一个镜像跑遍所有环境」。

1.2 Release 相对源码部署的优势

  • 自包含:包含 ERTS,目标机器无需安装 Erlang/Elixir;
  • 可预测:依赖在构建期锁定,不会出现「线上装的依赖和测试时不一样」;
  • 可运维:内置 start/stop/remote/eval/rpc 等命令,无需额外的进程管理工具;
  • 可热升级:配合 appup/relup 支持零停机升级(见 https://plumephp.com/erlang-hot-code-upgrade/)。

二、Mix Release 构建与产物结构

2.1 构建

MIX_ENV=prod mix release

产物落在 _build/prod/rel/my_app/:

_build/prod/rel/my_app/
├── bin/my_app              # 主启动脚本(另有 my_app.bat)
├── lib/
│   ├── my_app-0.1.0/ebin/  # 应用自身的 beam
│   └── ecto-3.12.0/ebin/   # 依赖的 beam
├── releases/
│   ├── 0.1.0/
│   │   ├── my_app.rel      # release 规格
│   │   ├── start.boot      # 引导脚本
│   │   ├── runtime.exs     # 编译后的运行时配置
│   │   ├── env.sh          # 环境变量脚本(可覆盖)
│   │   └── vm.args         # VM 参数
│   └── COOKIE              # 分布式 Cookie
└── erts-15.1/              # 打包的 ERTS 运行时

2.2 内置命令

命令作用
bin/my_app start前台启动(容器场景用这个)
bin/my_app daemon后台启动(传统部署用)
bin/my_app stop优雅停止
bin/my_app remote打开连接到运行节点的远程 shell
bin/my_app rpc "Mod.fun()"在运行节点上执行表达式
bin/my_app eval "Mod.fun()"启动一个临时节点执行表达式后退出

容器里必须用 start 而不是 daemon:daemon 会 fork 到后台,导致容器的 PID 1 退出,Kubernetes 认为容器已结束。

2.3 数据库迁移的发布钩子

迁移不能放在应用启动流程里(多副本会并发执行),应做成显式的一次性任务:

defmodule MyApp.Release do
  @app :my_app

  def migrate do
    Application.load(@app)

    for repo <- Application.fetch_env!(@app, :ecto_repos) do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end
end

在部署流水线中作为独立的 Job 执行:

kubectl run migrate --rm -it --restart=Never \
  --image=registry/my-app:1.2.3 \
  -- /app/bin/my_app eval "MyApp.Release.migrate()"

三、多阶段 Dockerfile 与镜像瘦身

3.1 完整的多阶段构建

# ---------- 构建阶段 ----------
FROM hexpm/elixir:1.17.3-erlang-27.1.2-debian-bookworm-20240926-slim AS build

RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential git curl ca-certificates && rm -rf /var/lib/apt/lists/*

WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV=prod

# 先复制依赖清单,利用层缓存
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod && mix deps.compile

COPY config config
COPY priv priv
COPY lib lib

RUN mix compile && mix release

# ---------- 运行阶段 ----------
FROM debian:bookworm-slim AS app

RUN apt-get update && apt-get install -y --no-install-recommends \
      libstdc++6 openssl libncurses6 locales ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && 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
WORKDIR /app
RUN chown nobody /app

COPY --from=build --chown=nobody:root /app/_build/prod/rel/my_app ./

USER nobody
ENV HOME=/app
EXPOSE 4000
CMD ["/app/bin/server"]

3.2 关键设计点

设计原因
两个阶段同用 Debian 系glibc 版本一致,避免运行期动态链接失败
COPY mix.exs mix.lock 先行依赖未变时复用缓存层,构建从分钟降到秒
--chown=nobody:root运行阶段非 root 用户,但属组为 root 便于读文件
只复制 _build/prod/rel/my_app源码、测试、依赖源码都不进最终镜像
locales + LANG缺 UTF-8 locale 会导致字符串处理异常

3.3 镜像体积对比

方案体积说明
单阶段 hexpm/elixir~1.2 GB含完整工具链
多阶段 + debian:bookworm-slim~120 MB推荐基线
多阶段 + alpine~80 MB需 musl 版本 ERTS,注意兼容性

体积不是唯一目标,但小镜像带来更快的拉取、更小的攻击面与更低的存储成本。生产镜像控制在 150MB 以内是合理的。

3.4 .dockerignore

_build
deps
.git
.github
assets/node_modules
test
*.md
.env*

漏掉 _build 与 deps 会把本地的构建产物复制进镜像,导致「构建缓存失效 + 平台不匹配」的双重问题。

四、运行时配置:runtime.exs 与 RELEASE_*

4.1 为什么需要运行时配置

编译期配置(config/prod.exs)会被固化进 sys.config,同一份镜像无法在不同环境运行——这正是「构建一次,到处部署」的反面。config/runtime.exs 在节点启动时执行,可以自由读取环境变量:

import Config

if config_env() == :prod do
  config :my_app, MyApp.Repo,
    url: System.fetch_env!("DATABASE_URL"),
    pool_size: String.to_integer(System.get_env("POOL_SIZE", "10"))

  config :my_app, MyAppWeb.Endpoint,
    http: [port: String.to_integer(System.get_env("PORT", "4000"))],
    secret_key_base: System.fetch_env!("SECRET_KEY_BASE"),
    server: true

  config :my_app, MyApp.Guardian, secret_key: System.fetch_env!("GUARDIAN_SECRET_KEY")
end

System.fetch_env!/1 在变量缺失时直接崩溃——这是刻意的:配置错误必须在启动时暴露,而不是在第一次请求时才报错。

4.2 RELEASE_* 环境变量

Mix release 识别一组以 RELEASE_ 开头的环境变量,用于覆盖启动脚本的行为:

变量作用示例
RELEASE_NODE节点名my_app@10.1.2.3
RELEASE_COOKIE分布式 Cookie从 Secret 注入
RELEASE_DISTRIBUTIONname 或 snamename(容器里用 FQDN 或 IP)
RELEASE_VM_ARGS覆盖 vm.args 路径调优时用

4.3 覆盖 env.sh

releases/<vsn>/env.sh 在启动脚本最前面被 source,是注入动态变量的最佳位置:

#!/bin/sh
# releases/0.1.0/env.sh
export RELEASE_DISTRIBUTION="name"
export RELEASE_NODE="my_app@${POD_IP:-127.0.0.1}"
export RELEASE_COOKIE="$(cat /run/secrets/erlang_cookie 2>/dev/null || echo 'local_dev_cookie')"
export RELEASE_TMP="/tmp"

注意 RELEASE_NODE 里的 IP 来自 Downward API 注入的 POD_IP,这是容器环境下节点命名的标准做法。

4.4 容器中的 BEAM 参数调优

BEAM 的默认调度器数量按宿主机 CPU 数决定,在容器里会远超配额,导致大量上下文切换与 CPU 节流。必须显式限制:

export ERL_FLAGS="+S 2:2 +sbwt none +sbwtdcpu none +sbwtdio none"
参数作用
+S 2:2固定 2 个调度器与 2 个在线调度器
+sbwt none关闭调度器忙等,避免空转烧 CPU 配额
+sbwtdcpu none / +sbwtdio none关闭 dirty 调度器忙等
+MMscs <MB>限制单进程堆最大尺寸

配合 resources.limits.cpu 设置为 2,两者保持一致,否则会出现「CPU 用不满但延迟很高」的怪现象。

五、节点命名、EPMD 与 libcluster 集群

5.1 节点名与 EPMD

Erlang 分布式依赖两个要素:

  • 节点名:name@host,两个节点互连必须能解析对方的名字;
  • EPMD(Erlang Port Mapper Daemon):默认监听 4369 端口,把节点名映射到实际端口。

容器里的经典陷阱:

陷阱表现解法
节点名写成 localhost多 Pod 名字冲突,无法互连用 Pod IP 或 FQDN
未开放 4369 端口Node.connect 超时同 Pod 内容器共享网络命名空间即可
未固定分布式端口随机端口被防火墙拦截vm.args 里指定 -kernel inet_dist_listen_min/max
Cookie 不一致Node.connect 返回 false用 Secret 统一注入 RELEASE_COOKIE
# vm.args 片段:固定分布式端口范围,便于网络策略放行
-kernel inet_dist_listen_min 9100
-kernel inet_dist_listen_max 9155

5.2 libcluster 策略

libcluster 负责「发现彼此并自动连接」,常用策略如下:

策略适用环境关键配置
Cluster.Strategy.Kubernetes.DNSK8s(推荐)service、application_name、polling_interval
Cluster.Strategy.KubernetesK8s(API 模式)需 RBAC 权限读 Endpoints
Cluster.Strategy.Gossip任意(组播可用)port、multicast_addr
Cluster.Strategy.Epmd静态列表hosts
Cluster.Strategy.DNSPoll云 DNSquery、node_basename

DNS 策略配置:

config :libcluster,
  topologies: [
    k8s: [
      strategy: Cluster.Strategy.Kubernetes.DNS,
      config: [service: "my-app-headless", application_name: "my_app",
               polling_interval: 5_000]
    ]
  ]

必须使用 headless Service(clusterIP: None):普通 Service 只解析出一个虚拟 IP,DNS 策略拿不到各个 Pod 的地址,集群永远只有自己一个节点。

5.3 集群形成后的行为

节点上下线通过 :net_kernel.monitor_nodes(true, node_type: :all) 订阅,在 handle_info 中处理 {:nodeup, node, _} 与 {:nodedown, node, _} 消息即可记录日志或触发再平衡。

集群形成后要思考三件事:进程注册(Registry 还是 :global)、状态同步(哪些数据需要跨节点一致)、故障转移(节点掉线后其上的进程如何接管)。这些属于分布式编程的核心议题,见 https://plumephp.com/erlang-distributed-programming/。

六、滚动升级、健康检查与可观测

6.1 Deployment 关键配置

spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate: {maxSurge: 1, maxUnavailable: 0}   # 先起新的,再停旧的
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: registry/my-app:1.2.3
          env:
            - name: POD_IP
              valueFrom: {fieldRef: {fieldPath: status.podIP}}
          lifecycle:
            preStop: {exec: {command: ["/bin/sh", "-c", "sleep 5"]}}   # 等 LB 摘除
          readinessProbe: {httpGet: {path: /ready, port: 4000}, periodSeconds: 5}
          livenessProbe: {httpGet: {path: /health, port: 4000}, periodSeconds: 10, failureThreshold: 3}
          resources:
            limits: {cpu: "2", memory: 2Gi}
配置作用
maxUnavailable: 0升级期间不减少可用副本,容量恒定
preStop: sleep 5给 Service 摘除 endpoint 留出时间,避免请求打到正在关闭的 Pod
terminationGracePeriodSeconds: 60给 BEAM 优雅停机留足时间
readinessProbe 指向 /ready依赖未就绪时不接收流量

6.2 健康检查的两个端点

defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  # 存活探针:只检查进程本身能否响应
  def health(conn, _params), do: send_resp(conn, 200, "ok")

  # 就绪探针:检查依赖与集群状态
  def ready(conn, _params) do
    checks = %{db: db_ready?(), cluster: cluster_ready?()}

    if Enum.all?(checks, fn {_, v} -> v end) do
      json(conn, %{status: "ready"})
    else
      conn |> put_status(503) |> json(%{status: "not_ready", checks: checks})
    end
  end

  defp db_ready?, do: match?({:ok, _}, Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []))

  defp cluster_ready? do
    expected = String.to_integer(System.get_env("EXPECTED_NODES", "1")) - 1
    length(Node.list()) >= expected
  end
end

存活探针绝不能检查依赖:数据库短暂不可用时,/health 返回 503 会导致所有 Pod 被同时重启,把「降级」变成「全崩」。

6.3 优雅停机

BEAM 收到 SIGTERM 后会触发应用的 stop/2 回调。要真正做到优雅停机,需要:

# 让 supervisor 给子进程足够的关闭时间(默认仅 5000ms)
{MyApp.Repo, shutdown: 30_000},
{MyApp.Workers, shutdown: 60_000}

关键点:supervisor 默认的 shutdown 是 5000ms,对于需要等待数据库事务提交或把内存中任务落盘的进程,5 秒远远不够。显式设置 shutdown 是避免「停机丢数据」的必要动作。

6.4 可观测性

容器环境下的可观测有三点值得强调:日志输出到 stdout(不写文件,交给容器运行时收集)、结构化日志(JSON 便于日志系统解析)、暴露 Prometheus 指标(/metrics 端点由 ServiceMonitor 抓取)。完整的指标体系见 https://plumephp.com/erlang-logging-telemetry-observability/。

6.5 常见故障与排查

现象根因排查手段
集群只有自己一个节点Service 不是 headlessnslookup 看解析出几个 IP
节点互连失败Cookie 不一致或端口未放行Node.ping/1 + 检查 4369/9100-9155
Pod 反复重启存活探针检查了依赖看探针路径与最近一次失败原因
CPU 打满但吞吐低调度器数远超配额设 +S 与 +sbwt none
停机丢任务shutdown 超时太短显式设置子进程 shutdown

七、最佳实践与总结

  • mix release 是唯一正确的打包方式:自包含 ERTS、可运行时配置、可热升级,优于源码部署与 distillery 方案;
  • 配置全部走 runtime.exs + 环境变量:镜像构建一次,所有环境复用,杜绝「改配置重新构建」;
  • 容器里用 start 不用 daemon:保持 PID 1 存活,让 Kubernetes 能感知进程状态;
  • 多阶段构建 + 同系基础镜像:控制在 150MB 以内,同时避免 glibc 版本不一致导致的诡异崩溃;
  • 节点名必须唯一且可解析:用 POD_IP 注入,配合 headless Service,这是集群能形成的前提;
  • 显式限制调度器数量:+S 与配额一致,+sbwt none 关闭忙等,否则容器会持续被 CPU 节流;
  • 健康检查分存活与就绪:存活只查进程,就绪查依赖,混用会把局部故障放大成全站重启;
  • 优雅停机要给足时间:preStop 摘流量 + shutdown 等落盘,两件事缺一不可;
  • 滚动升级保证容量不降:maxUnavailable: 0 + maxSurge: 1,配合 PDB 应对节点维护。

BEAM 的部署模型与它的并发哲学一脉相承:每个节点是自治的,节点之间通过消息协作,任何节点消失都不该让系统停止服务。容器化恰好放大了这一优势——Pod 可以被随意调度、重建、迁移,只要节点名能解析、Cookie 一致、集群策略正确,它们就会自动重新组成一张完整的网络。理解了构建、配置、集群这三层,你就能把 OTP 的容错能力从「单机进程树」扩展到「整个集群」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. Elixir 认证授权实战:JWT、Guardian 与 Phoenix.Token
  2. Elixir HTTP 客户端与连接池:Mint、Finch 与 Req 实战
  3. Erlang/Elixir gRPC 与 Protobuf:从编码原理到 grpcbox 实战