热代码升级(Hot Code Upgrade)是 Erlang/OTP 最令人惊叹的能力之一:在不停止服务、不重启进程的前提下,用新版本代码替换运行中的代码。爱立信 AXD 301 交换机正是依靠它实现了「九个 9」的可用性。对现代互联网服务而言,这意味着发布窗口不再是「维护时间」——你可以在流量高峰期间完成版本迭代。本文将从代码加载机制讲起,深入 appup/relup 文件格式、code_change 状态转换、release 打包与滚动升级的完整流程。
一、热升级的运行时基础
1.1 BEAM 的双版本代码机制
BEAM 每个模块同时保留 两个版本:old 与 current。加载新版本后,正在执行旧版本的进程继续跑旧代码,新调用走新代码:
加载前 加载后
current: v1.0 current: v2.0
old: (无) old: v1.0
- 已运行进程继续执行 v1.0 的帧
- 新进程 / 新调用使用 v2.0
- 所有进程都结束旧帧后,v1.0 可被 purge
%% 查看模块当前版本信息
> code:is_loaded(my_module).
{file, "my_module.beam"}
> code:get_object_code(my_module).
{my_module, Binary, "my_module.beam"}
> code:soft_purge(my_module). % true=旧版本已无进程使用,可清除
> code:purge(my_module). % 强制清除(会杀死仍在使用旧版本的进程)
1.2 升级的三种方式
| 方式 | 机制 | 停机 | 用途 |
|---|---|---|---|
| 重启加载 | code:load_file/1 | 无(新调用用新版) | 简单模块修复 |
| 软升级 | code:soft_purge + load | 无 | 无需状态转换 |
| 热升级 | release_handler 全套 | 无 | 状态转换 + 多应用协调 |
code:load_file 只能改代码,不能改进程状态。真正的热升级通过 release_handler 在应用级别编排:加载模块 + 调用 code_change + 重启 supervisor 子树。
二、appup 文件:应用升级脚本
2.1 appup 语法结构
每个 OTP 应用目录下有一个 <app>.appup 文件,声明如何从旧版本升级/降级:
%% src/my_app.appup
{"1.1.0", %% 目标版本
[{"1.0.0", [ %% 从 1.0.0 升级到 1.1.0
{update, my_app_server, {advanced, []}}, %% 需要状态转换
{load_module, my_app_util}, %% 无状态模块,直接加载
{add_module, my_new_module}, %% 新增模块
{delete_module, my_deprecated_module} %% 删除模块
]}],
[{"1.0.0", [ %% 从 1.1.0 降级回 1.0.0
{update, my_app_server, {advanced, []}},
{load_module, my_app_util},
{add_module, my_deprecated_module},
{delete_module, my_new_module}
]}]
}.
2.2 appup 指令详解
| 指令 | 语义 | 参数 |
|---|---|---|
{update, Mod, {advanced, Extra}} | 升级模块并调用 Mod:code_change/3 做状态转换 | Extra 传给 code_change 的第三参数 |
{load_module, Mod} | 加载新代码,无需状态转换 | 纯函数 / 无状态模块 |
{add_module, Mod} | 加载新模块 | 新模块首次出现 |
{delete_module, Mod} | 删除旧模块 | 需确保无进程引用 |
{update, Mod, supervisor} | 重启 supervisor 及其子树 | 用于行为或子进程规格变更 |
{restart_application, App} | 重启整个应用 | 无法热处理的深度变更 |
{apply, {M, F, A}} | 升级过程中执行任意函数 | 数据迁移、环境变更 |
2.3 code_change 状态转换
需要保持状态的 gen_server / gen_statem 必须实现 code_change/3(新签名 code_change/4):
%% my_app_server.erl(旧版本)
-module(my_app_server).
-behaviour(gen_server).
-export([code_change/3]).
-record(state, {version = 1, config}).
code_change({down, OldVsn}, #state{version = 2} = State, _Extra) ->
%% 降级:从 v2 状态回退到 v1
{ok, State#state{version = 1}};
code_change(OldVsn, #state{version = 1} = State, _Extra) ->
%% 升级:v1 → v2,这里可以改写状态结构
NewState = migrate_state(State),
{ok, NewState}.
关键语义:
code_change返回{ok, NewState}表示转换成功,进程继续运行;- 返回
{error, Reason}会使升级失败并回滚; {down, OldVsn}表示正在降级,参数顺序相反;- 状态 record 布局变化时,必须在这里完成字段迁移。
三、gen_statem / gen_event 的状态转换
%% gen_statem 的 code_change/4(OTP 20+)
code_change(OldVsn, StateName, Data, Extra) ->
%% 返回 {ok, NewStateName, NewData}
{ok, StateName, Data}.
%% gen_event Handler 的 code_change/3
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
注意:gen_event 的 Handler 升级需要 {update, HandlerMod, {advanced, Extra}} 指令,且升级会替换 Handler。若 Handler 是运行时动态添加的,appup 中应列出可能的 Handler 模块。
四、Release 打包:rebar3 与 relx
4.1 rebar3 项目结构
myapp/
├── src/
│ ├── myapp.app.src
│ ├── myapp_app.erl
│ ├── myapp_sup.erl
│ └── myapp_server.erl
├── rebar.config
├── rel/
│ └── myapp/
│ ├── sys.config
│ └── vm.args
└── test/
4.2 rebar.config 配置 release
%% rebar.config
{erl_opts, [debug_info, warnings_as_errors]}.
{relx, [
%% release 名称、版本、应用
{release, {myapp, "1.0.0"},
[myapp, sasl]},
%% 升级时生成 appup 的目录
{dev_mode, false},
{include_erts, true}, % 打包 ERTS(自包含运行环境)
{extended_start_script, true}, % 生成功能丰富的启动脚本
{system_config, "rel/myapp/sys.config"},
{vm_args, "rel/myapp/vm.args"}
]}.
{profiles, [
{prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]}
]}.
4.3 构建 release
# 开发模式构建(软链接,快速迭代)
rebar3 release
# 生产模式构建(复制文件)
rebar3 as prod release
# 产物结构
_build/prod/rel/myapp/
├── bin/
│ ├── myapp # 启动脚本
│ ├── myapp-1.0.0 # 版本化启动脚本
│ └── install_upgrade.escript
├── releases/
│ ├── 1.0.0/
│ │ ├── myapp.rel # release 规格
│ │ ├── sys.config
│ │ ├── vm.args
│ │ └── start_clean.boot
│ └── RELEASES # 已安装版本记录
├── lib/
│ └── myapp-1.0.0/
└── erts-*/ # 打包的运行时
4.4 appup 与版本管理
OTP 版本管理有两个层面:
- 应用版本:
myapp.app.src中的{vsn, "1.0.0"},appup 以应用版本为键; - Release 版本:release 的版本,记录在
.rel文件中。
每次发布新版本,都需要:
# 1. 更新应用版本
# myapp.app.src: {vsn, "1.1.0"}
# 2. 编写 src/myapp.appup
# 声明 1.0.0 → 1.1.0 的升级脚本
# 3. 更新 release 版本并构建
# rebar.config: {release, {myapp, "1.1.0"}, [myapp, sasl]}
rebar3 as prod release
# 4. 生成 relup(升级包)
rebar3 as prod release
五、relup 与滚动升级流程
5.1 生成 relup
relup 文件由 systools:make_relup/3 根据 appup 生成,rebar3 在 release 构建时自动执行:
%% 手动生成 relup 的等价命令
systools:make_relup(
"myapp-1.1.0", % 新 release 文件
["myapp-1.0.0"], % 旧版本(可多个)
["myapp-1.0.0"], % 降级目标(可多个)
[{path, ["_build/prod/lib/*/ebin"]}]
).
生成的 relup 是一个 Erlang 指令列表,描述从旧版本升级需要执行的所有步骤。
5.2 完整滚动升级步骤
# 1. 生产目录结构(部署新版本到服务器)
# 把新 release 解压到 releases 目录下:
# releases/1.1.0/myapp.rel + myapp-1.1.0.tar.gz
# 2. 安装升级包(在线,不停止服务)
_build/prod/rel/myapp/bin/myapp install "1.1.0"
# 3. 确认升级结果
_build/prod/rel/myapp/bin/myapp status
# 输出中应看到当前 release 版本 1.1.0
# 4. 固化(permanent)—— 可选但推荐
# install 后默认仍是临时状态,重启回退到旧版本
# 确认无误后固化:
_build/prod/rel/myapp/bin/myapp permanent "1.1.0"
对应的代码路径:
%% 通过 RPC 在线安装(生产脚本常用)
> rpc:call(Node, release_handler, install_release, ["1.1.0"]).
{ok, "1.1.0", ["1.0.0"]}
%% 查看已安装版本
> release_handler:which_releases().
%% 固化当前版本
> release_handler:make_permanent("1.1.0").
5.3 升级中的状态检查
升级不是「点一下就完」——你应该在关键步骤间检查系统状态:
%% 升级前:记录当前版本与进程树
release_handler:which_releases().
supervisor:which_children(myapp_sup).
%% 升级后:验证模块新版本已加载
code:get_object_code(myapp_server). % 返回 v1.1.0 的 binary
%% 验证进程仍存活且状态正确
sys:get_state(whereis(myapp_server)).
%% 若发现问题:立即回滚
release_handler:install_release("1.0.0"),
release_handler:make_permanent("1.0.0").
5.4 降级(Rollback)
降级是升级的逆过程,使用 appup 中的降级脚本:
# 回滚到 1.0.0
bin/myapp install "1.0.0"
bin/myapp permanent "1.0.0"
降级的注意点:
- 新版本写入的数据格式可能无法被旧代码解析——
code_change的{down, OldVsn}分支负责转换; - 数据库 schema 变更无法通过 relup 回滚,需要单独的数据迁移工具;
- 降级窗口有限:一旦
make_permanent固化旧版本,新的永久版本会清除旧 release 的临时状态。
六、生产实践:自动化升级
6.1 升级脚本模板
#!/usr/bin/env bash
# scripts/hot_upgrade.sh —— 滚动升级脚本
set -euo pipefail
NODE="${1:?Usage: $0 <node_name> <version>}"
VERSION="${2:?}"
echo "=== [1/5] 上传新 release 到目标节点 ==="
scp "_build/prod/rel/myapp/releases/${VERSION}/myapp-${VERSION}.tar.gz" \
"${NODE}:/opt/myapp/releases/"
echo "=== [2/5] 在目标节点安装升级包 ==="
ssh "${NODE}" "/opt/myapp/bin/myapp install '${VERSION}'"
echo "=== [3/5] 校验升级结果 ==="
ssh "${NODE}" "/opt/myapp/bin/myapp status" | grep "${VERSION}"
echo "=== [4/5] 运行冒烟测试(业务健康检查) ==="
ssh "${NODE}" "curl -sf http://localhost:8080/health || exit 1"
echo "=== [5/5] 固化版本 ==="
ssh "${NODE}" "/opt/myapp/bin/myapp permanent '${VERSION}'"
echo "=== 升级完成 ==="
6.2 多节点滚动升级
多节点集群应逐节点升级,避免全网同时切换:
节点 A ──升级──▶ 验证 ──▶ 节点 B ──升级──▶ 验证 ──▶ 节点 C ...
│
└─ 升级期间:A 运行新版,B/C 运行旧版
通过分布式协议保证两版互操作(消息格式兼容)
版本兼容要求:滚动升级期间新旧节点并存,必须保证:
- 节点间消息格式在相邻版本间兼容;
rpc/global注册不因版本分裂而冲突;- 共享 ETS/Mnesia 表结构在相邻版本间稳定。
6.3 常见陷阱
| 陷阱 | 表现 | 对策 |
|---|---|---|
| 状态 record 未迁移 | code_change 后字段错乱 | 升级前用 sys:get_state 抓快照对比 |
| 模块被进程栈占用 | soft_purge 返回 false,升级失败 | 确认无长事务进程;必要时 purge |
code_change 抛异常 | 升级中断,部分模块已加载 | 指令顺序:先加载后转换;异常时手动回滚 |
| Handler 动态添加 | gen_event Handler 未在 appup 中列出 | appup 明确列出所有可能 Handler |
| 数据库 schema 不兼容 | 新代码读旧表 / 旧代码读新表 | schema 变更独立于热升级处理 |
无 sasl 应用 | release_handler 不可用 | release 中必须包含 sasl |
七、最佳实践与总结
- 版本语义化 + 相邻兼容:热升级只保证相邻版本可互操作,跨越多个版本的升级需要逐版执行;
- 状态转换优先:凡改
record布局、进程行为、supervisor 树结构,都必须写code_change与supervisor更新指令; - 灰度 + 快速回滚:多节点逐个升级,每个节点升级后立即健康检查,异常立即
install_release回滚; - 数据库变更分离:Mnesia 表结构变更可结合
mnesia:transform_table,但外部数据库(PostgreSQL 等)的迁移要独立编排,参考 https://plumephp.com/elixir-ecto-data-access/ 的迁移章节; - 升级包纳入 CI/CD:appup 由脚本生成(
rebar3 appup/systools:make_relup),避免手写出错; - 监控升级过程:升级期间关注
code:is_loaded版本、进程存活数、消息队列长度,见 https://plumephp.com/erlang-production-cases/ 的监控指标。
热升级把「发布」从运维动作变成运行时能力。它要求代码以「版本可迁移」的方式编写——状态显式、接口稳定、迁移有函数。掌握 appup/relup 与 code_change,就掌握了 OTP 应用最独特的零停机部署武器。配合 https://plumephp.com/erlang-distributed-programming/ 的集群能力,你的系统可以在不打断任何用户的情况下,完成从代码到状态的全量演进。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。