把 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_DISTRIBUTION | name 或 sname | name(容器里用 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.DNS | K8s(推荐) | service、application_name、polling_interval |
Cluster.Strategy.Kubernetes | K8s(API 模式) | 需 RBAC 权限读 Endpoints |
Cluster.Strategy.Gossip | 任意(组播可用) | port、multicast_addr |
Cluster.Strategy.Epmd | 静态列表 | hosts |
Cluster.Strategy.DNSPoll | 云 DNS | query、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 不是 headless | nslookup 看解析出几个 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 的容错能力从「单机进程树」扩展到「整个集群」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。