OTP 应用设计模式:监督树结构、release 打包与热升级

OTP 应用设计模式:Application 规格与回调(mod/start/stop)、监督树分层架构(Supervisor/Worker/Pool)、行为模式选择(GenServer/GenStatem/GenEvent)、Release 打包( distillery/mix release/relx)、热代码升级(code_change/upgrade/downgrade)、配置管理(Mix.Config/Config.Provider/runtime.exs)、环境分离(dev/test/prod)、远程 Shell 与调试(remsh/observer)、分布式部署策略、性能剖析与优化(fprof/eprof/cprof)。

引言

OTP 应用(Application)是 Erlang/Elixir 系统的基本部署单元——不是「手机 App」,而是「可启动、可监督、可热更新的代码包」。一个 OTP 应用包含监督树(Supervision Tree)、配置管理和生命周期回调,最终打包成 Release(自包含的运行时 + 代码 + 配置),可部署到单节点或分布式集群。本文从 Application 规格到监督树分层架构,从 Release 打包到热代码升级——给 OTP 系统的设计与部署一份完整地图。

前置:/elixir-otp-supervision-tasks/(OTP 核心)、/erlang-security-crypto/(安全加固)。


目录


1. OTP Application 规格与生命周期

1.1 Application 规格

% my_app.app(或在 mix.exs 定义)
{application, my_app, [
    {description, "My OTP Application"},
    {vsn, "1.0.0"},
    {modules, [my_app_app, my_app_sup, my_app_worker]},
    {registered, [my_app_worker]},
    {applications, [kernel, stdlib, sasl]},
    {mod, {my_app_app, []}},
    {env, [{port, 8080}]}
]}.

1.2 Elixir 中的 Application

# mix.exs
def application do
  [
    mod: {MyApp.Application, []},
    extra_applications: [:logger, :runtime_tools, :sasl]
  ]
end

# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MyApp.Repo,
      MyAppWeb.Endpoint,
      {Phoenix.PubSub, name: MyApp.PubSub}
    ]
    Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
  end

  def stop(_state) do
    :ok
  end
end

1.3 生命周期

启动:application:load → application:start → mod:start/2 → Supervisor 启动子进程
运行:Supervisor 监控,崩溃自动重启
停止:application:stop → mod:stop/1 → Supervisor 终止子进程
升级:release_handler:install_release → code:load_binary → code_change 回调

记忆 OTP Application = 规格(app 文件/mix.exs)+ 回调(start/stop)+ 监督树;mod 指定入口模块、applications 声明依赖、registered 列出注册名;生命周期 load→start→运行→stop→upgrade。


2. 监督树分层架构设计

2.1 三层监督树

Level 1: Application Supervisor
  ├── Repo Supervisor (Database connections)
  ├── Web Supervisor (Phoenix endpoints)
  │   ├── Endpoint
  │   ├── PubSub
  │   └── Presence
  ├── Business Supervisor
  │   ├── UserService (GenServer)
  │   ├── OrderService (GenServer)
  │   └── PaymentGateway (GenServer + Task pool)
  └── Background Supervisor
      ├── Scheduler (Quantum/Task)
      ├── Queue Worker Pool
      └── Event Consumer

2.2 设计原则

# 1) 按故障域分组:DB 故障不应影响 Web 服务
# 2) 叶子节点是 Worker:实际干活(GenServer/Agent/Task)
# 3) 中间节点是 Supervisor:负责重启策略
# 4) 根 Supervisor 用 one_for_one:局部故障不扩散
# 5) 依赖顺序:Repo → PubSub → Business → Background

2.3 命名与注册

# 本地注册(atom 名字,节点内唯一)
GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)

# 通过 Registry(推荐)
Registry.start_link(keys: :unique, name: MyApp.ServiceRegistry)
{:ok, pid} = MyService.start_link([])
Registry.register(MyApp.ServiceRegistry, :user_service, pid)

# 分布式(global)
:global.register_name(:user_service, pid)
:global.whereis_name(:user_service)

记忆 监督树三层——根 Supervisor(Application)→ 领域 Supervisor(DB/Web/Business/Background)→ Worker;按故障域分组、依赖顺序启动、one_for_one 防扩散;注册用 Registry(本机)或 :global(分布式)。


3. OTP 行为模式选择

3.1 行为模式对比

行为用途状态复杂度
GenServer通用有状态服务简单状态
GenStatem复杂状态机多状态/事件驱动
GenEvent事件处理(已弃用)事件流
Supervisor监督子进程子进程列表
Application应用入口监督树根

3.2 GenStatem 状态机

defmodule Connection do
  @behaviour :gen_statem

  def callback_mode(), do: :state_functions

  def disconnected(:cast, :connect, data) do
    case connect(data.host, data.port) do
      {:ok, socket} -> {:next_state, :connected, %{data | socket: socket}}
      {:error, _} -> {:keep_state, data, [{:state_timeout, 5000, :retry}]}
    end
  end

  def connected(:cast, :disconnect, data) do
    :gen_tcp.close(data.socket)
    {:next_state, :disconnected, %{data | socket: nil}}
  end
end

3.3 选择指南

GenServer:CRUD 服务、缓存、计数器(90% 场景)
GenStatem:连接管理、协议状态机、复杂工作流
GenEvent:用 GenStage 替代(背压更好)
# 原则:从简单开始,需要时再引入状态机

记忆 行为模式——GenServer(通用有状态,90% 场景)、GenStatem(复杂状态机,连接/协议/工作流)、Supervisor(监督)、Application(入口);从简单开始,需要状态机再升级。


4. Release 打包与部署

4.1 Mix Release(Elixir 1.9+)

# 打包
MIX_ENV=prod mix release

# 产物
_build/prod/rel/my_app/
  bin/my_app          # 启动脚本
  erts-12.2/          # Erlang 运行时
  lib/                # 应用代码
  releases/           # 版本信息

# 启动
_build/prod/rel/my_app/bin/my_app start
_build/prod/rel/my_app/bin/my_app daemon
_build/prod/rel/my_app/bin/my_app remote  # 远程 shell

4.2 Release 配置

# mix.exs
def project do
  [
    releases: [
      my_app: [
        include_executables_for: [:unix],
        applications: [runtime_tools: :permanent],
        strip_beams: true
      ]
    ]
  ]
end

4.3 Docker 部署

FROM hexpm/elixir:1.16-erlang-26-debian-bullseye-20240101
WORKDIR /app
COPY . .
RUN mix deps.get && mix compile && MIX_ENV=prod mix release

FROM debian:bullseye-slim
RUN apt-get update && apt-get install -y openssl
WORKDIR /app
COPY --from=0 /app/_build/prod/rel/my_app .
CMD ["bin/my_app", "start"]

记忆 Mix Release(Elixir 1.9+)= 自包含运行时 + 代码 + 配置;mix release 打包、bin/my_app start/daemon/remote;Docker 多阶段构建减小镜像;包含 erts 无需目标机装 Erlang。


5. 配置管理三层模型

5.1 三层配置

编译时 config/config.exs     → 编译进 beam 文件(不变)
运行时 config/runtime.exs    → 启动时读取(可变)
环境变量 System.get_env/1    → 外部注入(最灵活)

5.2 运行时配置

# config/runtime.exs
import Config

config :my_app, MyApp.Repo,
  url: System.get_env("DATABASE_URL") || raise("DATABASE_URL missing")

config :my_app, MyAppWeb.Endpoint,
  secret_key_base: System.get_env("SECRET_KEY_BASE") || raise("SECRET_KEY_BASE missing")

5.3 Config.Provider(Release 专用)

# 在 release 中动态加载配置
config_providers: [
  {Config.Reader, "/etc/my_app/config.exs"},
  {Config.Reader, {:system, "RELEASE_ROOT", "/config.exs"}}
]

# 启动时按顺序加载,覆盖编译时配置

记忆 配置三层——config.exs(编译时固定)、runtime.exs(启动时读取)、环境变量(外部注入);release 用 Config.Provider 从文件/环境动态加载;敏感信息走环境变量/runtime.exs。


6. 热代码升级原理与实践

6.1 热升级原理

# BEAM 支持「两版本共存」
# 加载新版本后,旧进程继续运行旧代码
# 新进程启动时运行新代码
# code_change 回调将旧状态迁移到新状态
# 旧版本在 GC 后逐步淘汰

6.2 code_change 回调

defmodule MyServer do
  use GenServer

  # 从旧版本升级(OldVsn → Current)
  def code_change("1.0.0", state, _extra) do
    new_state = Map.put(state, :new_field, :default)
    {:ok, new_state}
  end

  def code_change(_old_vsn, state, _extra) do
    {:ok, state}  # 无需迁移
  end
end

6.3 使用 Release 升级

# 构建新版本 release
MIX_ENV=prod mix release

# 复制到新版本目录
_build/prod/rel/my_app/releases/1.1.0/

# 使用 release_handler(Erlang)或升级脚本
bin/my_app upgrade 1.1.0

6.4 热升级局限

# 适用:小幅修改、状态可迁移
# 不适用:
#   - 监督树结构变化(需重启)
#   - ETS 表结构变化
#   - 数据库 schema 变更
#   - 核心库(kernel/stdlib)升级
# 生产建议:蓝绿部署代替热升级

记忆 热升级 = 两版本共存 + code_change 状态迁移 + 旧进程逐步淘汰;用 bin/my_app upgrade;局限——监督树/ETS/schema/核心库变化需重启;生产推荐蓝绿部署。


7. 远程 Shell 与生产调试

7.1 远程 Shell

# 连接到运行中的节点
_build/prod/rel/my_app/bin/my_app remote

# 或手动
iex --sname debug --cookie $(cat $RELEASE_ROOT/releases/COOKIE) \
  --remsh my_app@myhost

# 查看进程
iex> Process.whereis(MyApp.Worker) |> Process.info()

# 查看 ETS
iex> :ets.tab2list(MyApp.Cache)

7.2 observer_cli(无 GUI)

# 在远程 shell 中启动
iex> :observer_cli.start()
# 实时:进程/内存/ETS/调度器/端口

7.3 安全注意

# 远程 shell 有全部权限 = 生产危险
# 建议:
#   - 只在 VPN/内网开放 Erlang 端口
#   - 使用本地 socket 而非 TCP(-dist 参数)
#   - 审计日志记录远程连接
#   - 调试完立即断开

记忆 远程 Shell——bin/my_app remote 或 iex –remsh;observer_cli 终端实时看进程/内存/ETS;生产谨慎用——VPN 内网、本地 socket、审计日志、用完即断。


8. 分布式部署策略

8.1 单节点 + LB

# 多实例独立运行,LB 分发请求
# 优点:简单、无分布式复杂度
# 缺点:无共享状态、PubSub 不跨节点
# 适合:无状态 Web 服务

8.2 分布式集群

# 节点间共享 PubSub/ETS/Registry
# 使用 :global / pg / Syn 做进程发现
# 部署:K8s StatefulSet 固定主机名
#       libcluster 自动发现(K8s DNS/EC2/Erlang 广播)

8.3 libcluster 配置

# 使用 K8s DNS 自动发现
config :libcluster,
  topologies: [
    k8s_dns: [
      strategy: Cluster.Strategy.Kubernetes.DNS,
      config: [
        service: "my-app-headless",
        application_name: "my_app",
        namespace: "default",
        polling_interval: 5_000
      ]
    ]
  ]

记忆 部署策略——单节点+LB(简单无状态)、分布式集群(共享 PubSub/ETS,用 libcluster 自动发现);K8s 用 StatefulSet+Headless Service+libcluster DNS 策略。


9. 性能剖析工具链

9.1 时间剖析:fprof

% 函数调用耗时分析
fprof:apply(Module, Function, Args).
fprof:profile().
fprof:analyse([{dest, "fprof.analysis"}]).

% 看:调用次数、耗时、子调用占比

9.2 调用计数:cprof

% 统计函数调用次数
cprof:start(),
Module:function(),
cprof:pause(),
cprof:analyse().
% 不看时间只看调用频次

9.3 执行时间:eprof

% 统计每个函数的墙钟时间和 CPU 时间
eprof:start(),
eprof:profile([Module:function/Arity]),
eprof:analyse(),
eprof:stop().

9.4 内存剖析

# 进程内存
Process.info(pid, [:memory, :heap_size, :stack_size, :total_heap_size])

# 二进制内存
:erlang.memory(:binary)
:recon.bin_leak(100)   # 找二进制泄漏

# ETS 内存
:ets.info(:my_table, :memory)

记忆 性能工具——fprof(函数耗时分析)、cprof(调用计数)、eprof(墙钟时间);内存用 Process.info + erlang:memory + recon.bin_leak;ETS 用 :ets.info。


10. 速查表与一句话记忆

概念一句话
ApplicationOTP 应用入口
mod启动回调模块
Supervisor监督子进程
GenServer通用有状态
GenStatem复杂状态机
mix releaseElixir 打包
runtime.exs运行时配置
code_change热升级状态迁移
remote远程 Shell
libclusterK8s 自动发现
fprof函数耗时分析

一句话记忆:OTP Application = 规格(app 文件)+ 回调(start 返回 Supervisor)+ 监督树;分层架构按故障域分组(DB/Web/Business/Background),叶子 Worker、中间 Supervisor;行为模式 GenServer(90%)/GenStatem(状态机);Mix Release 自包含运行时+代码+配置,bin/start/daemon/remote;配置三层——config.exs(编译固定)、runtime.exs(启动读取)、环境变量(外部注入);热升级靠两版本共存+code_change 状态迁移,局限在监督树/ETS/schema 变更;远程 Shell 生产谨慎用(VPN+审计);部署单节点+LB 简单、分布式用 libcluster + K8s StatefulSet;性能 fprof 耗时/cprof 计数/eprof 时间——「OTP 的设计哲学是运行时不停止」。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「erlang」更多文章

  1. Erlang/Elixir 安全加固:加密、认证与分布式信任
  2. Ecto 高级查询与数据库工程:关联、多态与性能优化
  3. Erlang/Elixir 可观测性:日志、Telemetry 指标与分布式追踪