Erlang 热代码升级与 Release:从 appup 到滚动升级

系统讲解 Erlang/OTP 零停机热升级:appup 指令语法、code_change 状态转换、relx/rebar3 release 打包、relup 生成与滚动升级流程,以及生产环境的版本管理与回滚策略。

热代码升级(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/ 的集群能力,你的系统可以在不打断任何用户的情况下,完成从代码到状态的全量演进。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「erlang」更多文章

  1. Elixir 测试工程:ExUnit 深入、属性测试与 Mock 策略
  2. Elixir Ecto 数据访问层:Schema、Query、迁移与变更集
  3. Mnesia 分布式数据库:事务、容错副本与生产部署