进程探索中的升级

Appups 和 Relups 的打嗝

在 Erlang 中进行代码热加载是最简单的事情之一。您可以重新编译、进行完全限定的函数调用,然后尽情享受。但是,正确安全地进行操作要困难得多。

有一个非常简单的挑战使代码重新加载变得很麻烦。让我们使用我们惊人的 Erlang 编程大脑,并让它想象一个 gen_server 进程。该进程有一个接受一种类型参数的 handle_cast/2 函数。我将其更新为接受不同类型参数的函数,进行编译,然后将其推送到生产环境中。一切顺利,但由于我们有一个不想关闭的应用程序,因此我们决定将其加载到生产 VM 上以使其运行。

A chain of evolution/updates. First is a monkey, second is a human-like creature, both separated by an arrow with 'Update' written under it. Then appears an arrow with an explosion saying 'failed upgrade', pointing from the human-like creature to a pile of crap and a tombstone saying 'RIP, YOU'

然后,大量错误报告开始涌入。事实证明,您的不同 handle_cast 函数不兼容。因此,当它们第二次被调用时,没有一个子句匹配。客户很生气,您的老板也很生气。然后,运维人员也很生气,因为他必须到现场回滚代码、扑灭火灾等等。如果您幸运的话,您就是那个运维人员。您加班工作,破坏了清洁工的夜晚(他通常喜欢一边哼歌一边跳舞,但他在您的面前感到羞愧)。您很晚才回家,您的家人/朋友/WOW 突袭小队/孩子对您很生气,他们大喊大叫、关门,您被独自留下。您承诺不会出现任何错误,也不会出现任何停机时间。毕竟您使用的是 Erlang,对吧?但事实并非如此。您被独自留下,蜷缩在厨房角落,吃着冷冻的热口袋。

当然,情况并不总是那么糟糕,但重点在于。如果您正在更改模块向世界提供的接口:更改内部数据结构、更改函数名称、修改记录(请记住,它们是元组!)、更改内部数据结构、更改函数名称、修改记录(请记住,它们是元组!),等等,那么在生产系统上进行实时代码升级非常危险。它们都可能导致系统崩溃。

当我们第一次玩代码重新加载时,我们有一个进程,其中包含某种隐藏的消息来处理完全限定的调用。如果您还记得,一个进程可能看起来像这样

loop(N) ->
    receive
        some_standard_message -> N+1;
        other_message -> N-1;
        {get_count, Pid} ->
            Pid ! N,
            loop(N);
        update -> ?MODULE:loop(N);
    end.

但是,如果我们要更改 loop/1 的参数,这种方式无法解决我们的问题。我们需要像这样扩展它

loop(N) ->
    receive
        some_standard_message -> N+1;
        other_message -> N-1;
        {get_count, Pid} ->
            Pid ! N,
            loop(N);
        update -> ?MODULE:code_change(N);
    end.

然后 code_change/1 可以处理调用新版本的 loop。但这种技巧无法适用于通用循环。请看这个例子

loop(Mod, State) ->
    receive
        {call, From, Msg} ->
            {reply, Reply, NewState} = Mod:handle_call(Msg, State),
            From ! Reply,
            loop(Mod, NewState);
        update ->
            {ok, NewState} = Mod:code_change(State),
            loop(Mod, NewState)
    end.

看到问题了吗?如果我们想更新 Mod 并加载一个新版本,没有办法用这种实现安全地做到这一点。调用 Mod:handle_call(Msg, State) 已经完全限定,而且很可能在我们在重新加载代码和处理 update 消息之间接收到 {call, From, Msg} 形式的消息。在这种情况下,我们将以不受控制的方式更新模块。然后我们会崩溃。

找到正确方法的秘密隐藏在 OTP 的内脏之中。我们必须冻结时间之沙!为此,我们需要更多秘密消息:将进程挂起的消息、更改代码的消息,然后是恢复您之前操作的消息。在 OTP 行为的深处隐藏着一个特殊的协议来处理所有这种类型的管理。这是通过名为 sys 模块和另一个名为 release_handler 的模块来完成的,它是 SASL(系统架构支持库)应用程序的一部分。它们处理一切。

诀窍是,您可以通过调用 sys:suspend(PidOrName) 来挂起 OTP 进程(您可以使用监督树找到所有进程,并查看每个监督者拥有的子进程)。然后,您使用 sys:change_code(PidOrName, Mod, OldVsn, Extra) 强制进程更新自身,最后,您调用 sys:resume(PidOrName) 使事情再次进行。

我们手动调用这些函数并编写临时脚本并不是很实际。相反,我们可以看看 relups 是如何完成的。

Erl 的第九层地狱

The 9 circles are: 0 (vestibule): handling syntax; 1. Records are tuples; 2. sharing nothing; 3. thinking asynchronously; 4. OTP behaviours (gen_server, gen_fsm, gen_event, supervisor); 5. OTP Applications; 6. Parse Transforms; 7. Common Test; 8. Releases; 9. Relups; and the center of erl is the distributed world with netsplits.

获取一个正在运行的版本,创建一个它的第二个版本并在它运行时进行更新是一件危险的事情。appups(包含有关如何更新单个应用程序的说明的文件)和 relups(包含有关如何更新整个版本的说明的文件)看似简单的组合,很快就会变成对 API 和未记录的假设的斗争。

我们正在进入 OTP 最复杂的部分之一,它难以理解和正确实现,而且非常耗时。事实上,如果您能避免整个过程(从现在开始称为 relup)并通过重新启动 VM 和启动新应用程序来进行简单的滚动升级,我建议您这样做。Relups 应该是这些“孤注一掷”的工具之一。只有在没有其他选择时才使用它。

在处理版本升级时,有许多不同的级别

每一个都可能比前一个更复杂。我们只看到了如何执行前 3 步。为了能够使用一个比以前更适合长时间运行升级的应用程序(嗯,谁在乎在不重启的情况下运行正则表达式),我们将引入一个很棒的视频游戏。

进度探索

进度探索 是一款革命性的角色扮演游戏。实际上,我称之为 RPG 的 OTP。如果您以前玩过 RPG,您会注意到许多步骤是相似的:四处跑,杀敌,获得经验,赚钱,升级,获得技能,完成任务。无限循环。强力玩家会有捷径,比如宏或机器人,为他们完成任务。

进度探索将所有这些通用步骤变成了一款简化的游戏,您所要做的就是坐下来享受您的角色完成所有工作

A screenshot of Progress Quest

在获得这款精彩游戏创造者 Eric Fredricksen 的许可后,我制作了一个非常简陋的 Erlang 克隆,名为 进程探索。进程探索在原理上与进度探索类似,但它不是单人应用程序,而是一个能够容纳多个原始套接字连接的服务器(可通过 telnet 使用),让用户可以使用终端临时玩游戏。

游戏由以下部分组成

regis-1.0.0

regis 应用程序是一个进程注册表。它的接口与常规 Erlang 进程注册表有些类似,但它可以接受任何项,并且旨在动态地工作。它可能会降低速度,因为所有调用在进入服务器时都会被序列化,但它会比使用常规进程注册表更好,后者并非为这种动态工作而设计。如果本指南可以自动使用外部库更新自身(工作量太大),我会使用 gproc。它包含几个模块,即 regis.erlregis_server.erlregis_sup.erl。第一个模块是围绕另外两个模块(以及应用程序回调模块)的包装器,regis_server 是主要的注册 gen_server,regis_sup 是应用程序的监督者。

processquest-1.0.0

这是应用程序的核心。它包含所有游戏逻辑。敌人、市场、战场和统计数据。玩家本身是一个 gen_fsm,它向自身发送消息以始终保持运行。它包含比 regis 更多的模块

pq_enemy.erl
此模块随机选择一个敌人来战斗,格式为 {<<"Name">>, [{drop, {<<"DropName">>, Value}}, {experience, ExpPoints}]}。这可以让玩家与敌人战斗。
pq_market.erl
它实现了一个市场,允许找到具有给定价值和给定强度的物品。返回的所有物品都采用 {<<"Name">>, Modifier, Strength, Value} 格式。有一些函数可以获取武器、盔甲、盾牌和头盔。
pq_stats.erl
这是您的角色的一个小型属性生成器。
pq_events.erl
一个围绕 gen_event 事件管理器的包装器。它充当一个通用的中心,订阅者在其中连接自身及其自己的处理程序以接收来自每个玩家的事件。它还可以处理等待给定的延迟以等待玩家的操作,以避免游戏瞬间完成。
pq_player.erl
中心模块。这是一个 gen_fsm,它遍历杀敌、前往市场、再次杀敌等等的状态循环。它使用所有上述模块来运行。
pq_sup.erl
一个监督者,位于一对 pq_eventpq_player 进程之上。这两个进程必须在一起才能工作,否则玩家进程将毫无用处且孤立,或者事件管理器将永远不会收到任何事件。
pq_supersup.erl
应用程序的顶层监督者。它位于一堆 pq_sup 进程之上。这允许您根据需要生成任意数量的玩家。
processquest.erl
一个包装器和应用程序回调模块。它为玩家提供基本界面:您启动一个玩家,然后订阅事件。

sockserv-1.0.0

A rainbow-colored sock

一个自定义的原始套接字服务器,专门为 processquest 应用程序而设计。它将生成每个负责一个 TCP 套接字的 gen_server,该套接字会将字符串推送到某个客户端。同样,您可以使用 telnet 与它交互。telnet 实际上并非为原始套接字连接而设计,它本身就是一个协议,但大多数现代客户端都可以无问题地接受它。以下是它的模块

sockserv_trans.erl
它将从玩家的事件管理器接收到的消息转换为可打印的字符串。
sockserv_pq_events.erl
一个简单的事件处理程序,它接收来自玩家的任何事件并将它们传递给套接字 gen_server。
sockserv_serv.erl
一个 gen_server,负责接受连接,与客户端通信并将信息转发给它。
sockserv_sup.erl
监督一群套接字服务器。
sockserv.erl
应用程序的整体回调模块。

版本

我已将所有内容设置在名为 processquest 的目录中,其结构如下

apps/
 - processquest-1.0.0
   - ebin/
   - src/
   - ...
 - regis-1.0.0
   - ...
 - sockserv-1.0.0
   - ...
rel/
  (will hold releases)
processquest-1.0.0.config

基于此,我们可以构建一个版本。

注意:如果您查看 processquest-1.0.0.config,您会看到包含了诸如 cryptosasl 之类的应用程序。Crypto 是为了更好地初始化伪随机数生成器而必需的,而 SASL 是为了能够在系统上执行 appups 而必需的。如果您忘记在版本中包含 SASL,则将无法升级系统

配置文件中出现了一个新过滤器:{excl_archive_filters, [".*"]}。此过滤器确保不会生成任何 .ez 文件,只生成常规文件和目录。这是因为我们将使用的工具无法查看 .ez 文件以查找它们需要的项目。

您还会发现,没有说明要求删除debug_info的指令。没有debug_info,执行appup将因某些原因而失败。

遵循上一章的说明,我们首先为所有应用程序调用erl -make。完成后,从processquest目录启动一个Erlang shell,并输入以下内容

1> {ok, Conf} = file:consult("processquest-1.0.0.config"), {ok, Spec} = reltool:get_target_spec(Conf), reltool:eval_target_spec(Spec, code:root_dir(), "rel").
ok

我们应该有一个功能齐全的版本。让我们试试。通过执行./rel/bin/erl -sockserv port 8888(或您想要的任何其他端口号,默认端口为 8082)启动任何版本的虚拟机。这将显示许多关于启动进程的日志(这是SASL的功能之一),然后是一个普通的Erlang shell。使用您想要的任何客户端在您的本地主机上启动一个telnet会话

$ telnet localhost 8888
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
What's your character's name?
hakvroot
Stats for your character:
  Charisma: 7
  Constitution: 12
  Dexterity: 9
  Intelligence: 8
  Strength: 5
  Wisdom: 16

Do you agree to these? y/n

对我来说,这有点太睿智和有魅力了。我输入了n,然后<Enter>

n
Stats for your character:
  Charisma: 6
  Constitution: 12
  Dexterity: 12
  Intelligence: 4
  Strength: 6
  Wisdom: 10

Do you agree to these? y/n

哦,是的,这很丑陋,很蠢,很弱。这正是我在基于我的英雄中所寻找的

y
Executing a Wildcat...
Obtained Pelt.
Executing a Pig...
Obtained Bacon.
Executing a Wildcat...
Obtained Pelt.
Executing a Robot...
Obtained Chunks of Metal.
...
Executing a Ant...
Obtained Ant Egg.
Heading to the marketplace to sell loot...
Selling Ant Egg
Got 1 bucks.
Selling Goblin hair
Got 1 bucks.
...
Negotiating purchase of better equipment...
Bought a plastic knife
Heading to the killing fields...
Executing a Pig...
Obtained Bacon.
Executing a Ant...

好的,对我来说,这已经足够了。输入quit,然后<Enter>关闭连接

quit
Connection closed by foreign host.

如果您愿意,可以将其保持打开状态,观看自己的等级提升,获得属性等等。游戏基本上可以正常工作,您可以尝试使用多个客户端。它应该会一直运行,不会出现问题。

很棒吧?好吧...

让 Process Quest 更出色

an ant being beheaded with a tiny axe

Process Quest 的当前版本应用程序存在一些问题。首先,我们在可击败的敌人方面,种类非常少。其次,我们有一些看起来很奇怪的文字(比如 Executing a Ant...)。第三个问题是游戏过于简单;让我们添加一个任务模式!另一个问题是,物品的价值在真实游戏中直接与您的等级绑定,而我们的版本却没有做到这一点。最后,除非您阅读代码并尝试自己关闭客户端,否则您看不到这一点,即客户端关闭其连接会使玩家进程在服务器上保持活动状态。哦,内存泄漏!

我必须修复这个问题!首先,我从需要修复的两个应用程序开始,制作了它们的副本。现在,我已经有了processquest-1.1.0sockserv-1.0.1,它们位于其他应用程序的顶部(我使用MajorVersion.Enhancements.BugFixes的版本方案)。然后,我实现了所有需要的更改。我不会详细介绍所有更改,因为这些细节对于本章的目的来说太多了——我们在这里升级应用程序,而不是了解它的所有细节和复杂性。如果您确实想知道所有细节,我确保以一种合理的方式对所有代码都进行了注释,这样您就可以找到需要的信息来理解它。首先,对processquest-1.1.0的更改。总而言之,更改已应用于pq_enemy.erlpq_events.erlpq_player.erl,我还添加了一个名为pq_quest.erl的文件,它基于玩家杀死的敌人数量来实现任务。在这些文件中,只有pq_player.erl的更改不兼容,需要暂停一段时间。我所做的更改是将记录

-record(state, {name, stats, exp=0, lvlexp=1000, lvl=1,
                equip=[], money=0, loot=[], bought=[], time=0}).

更改为以下记录

-record(state, {name, stats, exp=0, lvlexp=1000, lvl=1,
                equip=[], money=0, loot=[], bought=[],
                time=0, quest}).

其中quest字段将保存pq_quest:fetch/0给出的值。由于这一更改,我需要修改 1.1.0 版本中的code_change/4函数。实际上,我需要修改它两次:一次是在升级的情况下(从 1.0.0 升级到 1.1.0),另一次是在降级的情况下(从 1.1.0 降级到 1.0.0)。幸运的是,OTP 会在每种情况下向我们传递不同的参数。当我们升级时,我们会获得模块的版本号。在这一点上,我们并不关心它,我们很可能只是忽略它。当我们降级时,我们会得到{down, Version}。这使我们能够轻松地匹配每个操作

code_change({down, _}, StateName, State, _Extra) ->
    ...;
code_change(_OldVsn, StateName, State, _Extra) ->
    ....

但是等等!我们不能像往常一样盲目地获取状态。我们需要升级它。问题是,我们不能执行以下操作

code_change(_OldVsn, StateName, S = #state{}, _Extra) ->
   ....

我们有两个选择。第一个是声明一个新的状态记录,它将具有一个新的形式。我们最终会得到类似以下内容

-record(state, {...}).
-record(new_state, {...}).

然后,我们必须在模块的每个函数子句中更改该记录。这很烦人,而且不值得冒这个风险。相反,将该记录扩展到其底层的元组形式会更简单(请记住访问常用数据结构

code_change({down, _},
            StateName,
            #state{name=N, stats=S, exp=E, lvlexp=LE, lvl=L, equip=Eq,
                   money=M, loot=Lo, bought=B, time=T},
            _Extra) ->
    Old = {state, N, S, E, LE, L, Eq, M, Lo, B, T},
    {ok, StateName, Old};
code_change(_OldVsn,
            StateName,
            {state, Name, Stats, Exp, LvlExp, Lvl, Equip, Money, Loot,
             Bought, Time},
             _Extra) ->
    State = #state{
        name=Name, stats=Stats, exp=Exp, lvlexp=LvlExp, lvl=Lvl, equip=Equip,
        money=Money, loot=Loot, bought=Bought, time=Time, quest=pq_quest:fetch()
    },
    {ok, StateName, State}.

这是我们的code_change/4函数!它所做的只是在两种元组形式之间进行转换。对于新版本,我们还负责添加一个新的任务——添加任务会很无聊,但我们所有现有的玩家都无法使用它们。您会注意到,我们仍然忽略了_Extra变量。这个变量是从 appup 文件(将在后面介绍)中传递的,您将负责选择它的值。目前,我们并不关心,因为我们只能升级和降级到一个版本或从一个版本升级和降级。在一些更复杂的情况下,您可能希望在其中传递特定于版本的 信息。

对于sockserv-1.0.1应用程序,只有sockserv_serv.erl需要更改。幸运的是,它们不需要重新启动,只需要一个新的消息来匹配。

两个应用程序的两个版本都已修复。但这还不足以让我们继续快乐地前进。我们必须找到一种方法,让 OTP 知道哪些类型的更改需要不同的操作。

Appup 文件

Appup 文件是需要执行以升级给定应用程序的 Erlang 命令列表。它们包含元组和原子列表,用于告知要执行的操作以及在什么情况下执行。它们的通用格式为

{NewVersion,
 [{VersionUpgradingFrom, [Instructions]}]
 [{VersionDownGradingTo, [Instructions]}]}.

它们请求版本列表,因为可以升级和降级到许多不同的版本。在我们的案例中,对于processquest-1.1.0,这将是

{"1.1.0",
 [{"1.0.0", [Instructions]}],
 [{"1.0.0", [Instructions]}]}.

这些指令包含高级和低级命令。通常,我们只需要关心高级命令。

{add_module, Mod}
模块Mod将首次加载。
{load_module, Mod}
模块Mod已加载到虚拟机中,并且已修改。
{delete_module, Mod}
模块Mod将从虚拟机中删除。
{update, Mod, {advanced, Extra}}
这将挂起所有正在运行Mod的进程,使用Extra作为最后一个参数调用模块的code_change函数,然后恢复所有正在运行Mod的进程。Extra可用于将任意数据传递给code_change函数,以防升级需要它。
{update, Mod, supervisor}
调用它可以重新定义主管的init函数,以影响其重启策略(one_for_onerest_for_one等)或更改子项规范(这不会影响现有进程)。
{apply, {M, F, A}}
将调用apply(M,F,A)
模块依赖项
您可以使用{load_module, Mod, [ModDependencies]}{update, Mod, {advanced, Extra}, [ModDeps]}来确保仅在处理完其他一些模块之后,才会执行某个命令。如果Mod及其依赖项属于同一个应用程序,则这将特别有用。遗憾的是,无法为delete_module指令提供类似的依赖项。
添加或删除应用程序
在生成 relups 时,我们不需要任何特殊指令来删除或添加应用程序。生成relup文件(用于升级版本的 文件)的函数将负责为我们检测到这一点。

使用这些指令,我们可以为我们的应用程序编写以下两个 appup 文件。该文件必须命名为NameOfYourApp.appup,并放置在应用程序的ebin/目录中。这是processquest-1.1.0 的 appup 文件

{"1.1.0",
 [{"1.0.0", [{add_module, pq_quest},
             {load_module, pq_enemy},
             {load_module, pq_events},
             {update, pq_player, {advanced, []}, [pq_quest, pq_events]}]}],
 [{"1.0.0", [{update, pq_player, {advanced, []}},
             {delete_module, pq_quest},
             {load_module, pq_enemy},
             {load_module, pq_events}]}]}.

您可以看到,我们需要添加新的模块,加载不需要暂停的两个模块,然后以安全的方式更新pq_player。当我们降级代码时,我们会执行完全相同的操作,但顺序相反。有趣的是,在一个案例中,{load_module, Mod}将加载一个新版本,而在另一个案例中,它将加载旧版本。这一切都取决于升级和降级之间的上下文。

由于sockserv-1.0.1只有一个模块需要更改,并且不需要暂停,因此它的appup 文件只有

{"1.0.1",
 [{"1.0.0", [{load_module, sockserv_serv}]}],
 [{"1.0.0", [{load_module, sockserv_serv}]}]}.

哇!下一步是使用新模块构建一个新版本。这是文件processquest-1.1.0.config

{sys, [
    {lib_dirs, ["/Users/ferd/code/learn-you-some-erlang/processquest/apps"]},
    {erts, [{mod_cond, derived},
            {app_file, strip}]},
    {rel, "processquest", "1.1.0",
     [kernel, stdlib, sasl, crypto, regis, processquest, sockserv]},
    {boot_rel, "processquest"},
    {relocatable, true},
    {profile, embedded},
    {app_file, strip},
    {incl_cond, exclude},
    {excl_app_filters, ["_tests.beam"]},
    {excl_archive_filters, [".*"]},
    {app, stdlib, [{incl_cond, include}]},
    {app, kernel, [{incl_cond, include}]},
    {app, sasl, [{incl_cond, include}]},
    {app, crypto, [{incl_cond, include}]},
    {app, regis, [{vsn, "1.0.0"}, {incl_cond, include}]},
    {app, sockserv, [{vsn, "1.0.1"}, {incl_cond, include}]},
    {app, processquest, [{vsn, "1.1.0"}, {incl_cond, include}]}
]}.

它只是旧文件的一个副本,更改了几个版本。首先,使用erl -make编译两个新应用程序。如果您之前下载了zip 文件,它们已经在那里为您准备好了。然后,我们可以生成一个新版本。首先,编译两个新应用程序,然后输入以下内容

$ erl -env ERL_LIBS apps/
1> {ok, Conf} = file:consult("processquest-1.1.0.config"), {ok, Spec} = reltool:get_target_spec(Conf), reltool:eval_target_spec(Spec, code:root_dir(), "rel").
ok

不要喝太多酷乐饮料
为什么我们不直接使用systools?好吧,systools也存在一些问题。首先,它将生成有时包含奇怪版本的 appup 文件,并且无法完美地工作。它还会假设一个目录结构,该结构几乎没有文档记录,但与 reltool 使用的结构有些接近。然而,最大的问题是,它将使用您的默认 Erlang 安装作为根目录,这在需要解包内容时,可能会产生各种权限问题等等。

这两种工具都没有简单的方法,我们需要进行大量的手动操作。因此,我们创建了一系列命令,以一种相当复杂的方式使用这两个模块,因为这样最终会减少一些工作量。

但等等,还需要更多的手动操作!

  1. rel/releases/1.1.0/processquest.rel复制为rel/releases/1.1.0/processquest-1.1.0.rel
  2. rel/releases/1.1.0/processquest.boot复制为rel/releases/1.1.0/processquest-1.1.0.boot
  3. rel/releases/1.1.0/processquest.boot复制为rel/releases/1.1.0/start.boot
  4. rel/releases/1.0.0/processquest.rel复制为rel/releases/1.0.0/processquest-1.0.0.rel
  5. rel/releases/1.0.0/processquest.boot复制为rel/releases/1.0.0/processquest-1.0.0.boot
  6. rel/releases/1.0.0/processquest.boot复制为rel/releases/1.0.0/start.boot

现在,我们可以生成relup文件。为此,启动一个 Erlang shell 并调用以下命令

$ erl -env ERL_LIBS apps/ -pa apps/processquest-1.0.0/ebin/ -pa apps/sockserv-1.0.0/ebin/
1> systools:make_relup("./rel/releases/1.1.0/processquest-1.1.0", ["rel/releases/1.0.0/processquest-1.0.0"], ["rel/releases/1.0.0/processquest-1.0.0"]).
ok

由于ERL_LIBS环境变量只会查找应用程序的最新版本,因此我们还需要添加-pa <Path to older applications>,以便 systools 的 relup 生成器能够找到所有内容。完成后,将 relup 文件移动到rel/releases/1.1.0/。在更新代码时,将检查该目录,以便在其中找到正确的内容。但是,我们会遇到的一个问题是,版本控制模块将依赖于它假定存在的一些文件,但这些文件可能不存在。

A cup of coffee with cookies and a spoon. Text says 'take a break'

升级版本

太棒了,我们有一个 relup 文件。在能够使用它之前,还需要做一些事情。下一步是为版本的新版本生成一个 tar 文件

2> systools:make_tar("rel/releases/1.1.0/processquest-1.1.0").
ok

该文件将位于rel/releases/1.1.0/中。现在,我们需要手动将其移动到rel/releases,并在执行此操作时重命名以添加版本号。更多硬编码的垃圾!$ mv rel/releases/1.1.0/processquest-1.1.0.tar.gz rel/releases/是我们摆脱这种情况的方法。

现在,这是您在启动实际的生产应用程序之前随时执行的步骤。这是您启动应用程序之前需要执行的步骤,因为它允许您在 relup 之后回滚到初始版本。如果您没有执行此步骤,您将只能将生产应用程序降级到比第一个版本更新的版本,但不能降级到第一个版本!

打开一个 shell 并运行以下命令

1> release_handler:create_RELEASES("rel", "rel/releases", "rel/releases/1.0.0/processquest-1.0.0.rel", [{kernel,"2.14.4", "rel/lib"}, {stdlib,"1.17.4","rel/lib"}, {crypto,"2.0.3","rel/lib"},{regis,"1.0.0", "rel/lib"}, {processquest,"1.0.0","rel/lib"},{sockserv,"1.0.0", "rel/lib"}, {sasl,"2.1.9.4", "rel/lib"}]).

函数的通用格式为release_handler:create_RELEASES(RootDir, ReleasesDir, Relfile, [{AppName, Vsn, LibDir}])。这将在rel/releases目录(或任何其他ReleasesDir)中创建一个名为RELEASES的文件,该文件将包含有关您的发布的基本信息,以便 relup 在查找要重新加载的文件和模块时使用。

现在我们可以开始运行旧版本的代码。如果您启动rel/bin/erl,它将默认启动 1.1.0 版本。这是因为我们在启动 VM 之前构建了新版本。为了演示,我们需要使用./rel/bin/erl -boot rel/releases/1.0.0/processquest启动版本。您应该看到一切都在启动。启动一个 telnet 客户端连接到我们的套接字服务器,以便我们可以看到实时升级过程。

只要您准备好升级,请转到当前运行 ProcessQuest 的 Erlang shell,并调用以下函数

1> release_handler:unpack_release("processquest-1.1.0").
{ok,"1.1.0"}
2> release_handler:which_releases().
[{"processquest","1.1.0",
  ["kernel-2.14.4","stdlib-1.17.4","crypto-2.0.3",
   "regis-1.0.0","processquest-1.1.0","sockserv-1.0.1",
   "sasl-2.1.9.4"],
  unpacked},
 {"processquest","1.0.0",
  ["kernel-2.14.4","stdlib-1.17.4","crypto-2.0.3",
   "regis-1.0.0","processquest-1.0.0","sockserv-1.0.0",
   "sasl-2.1.9.4"],
  permanent}]

此处的第二个提示告诉您版本已准备好升级,但尚未安装或永久生效。要安装它,请执行以下操作

3> release_handler:install_release("1.1.0").
{ok,"1.0.0",[]}
4> release_handler:which_releases().
[{"processquest","1.1.0",
  ["kernel-2.14.4","stdlib-1.17.4","crypto-2.0.3",
   "regis-1.0.0","processquest-1.1.0","sockserv-1.0.1",
   "sasl-2.1.9.4"],
  current},
 {"processquest","1.0.0",
  ["kernel-2.14.4","stdlib-1.17.4","crypto-2.0.3",
   "regis-1.0.0","processquest-1.0.0","sockserv-1.0.0",
   "sasl-2.1.9.4"],
  permanent}]

因此,现在版本 1.1.0 应该正在运行,但它还不永久。尽管如此,您可以让您的应用程序继续以这种方式运行。调用以下函数使更改永久生效

5> release_handler:make_permanent("1.1.0").
ok.

哎哟。现在很多进程都死了(上面的示例中已删除错误输出)。不过,如果您查看我们的 telnet 客户端,似乎它确实升级正常。问题是,所有在 sockserv 中等待连接的 gen_servers 都无法监听消息,因为接受 TCP 连接是一个阻塞操作。因此,当加载新版本的代码时,服务器无法升级,并被 VM 终止。看看如何确认这一点

6> supervisor:which_children(sockserv_sup).
[{undefined,<0.51.0>,worker,[sockserv_serv]}]
7> [sockserv_sup:start_socket() || _ <- lists:seq(1,20)].
[{ok,<0.99.0>},
 {ok,<0.100.0>},
 ...
 {ok,<0.117.0>},
 {ok,<0.118.0>}]
8> supervisor:which_children(sockserv_sup).
[{undefined,<0.112.0>,worker,[sockserv_serv]},
 {undefined,<0.113.0>,worker,[sockserv_serv]},
 ...
 {undefined,<0.109.0>,worker,[sockserv_serv]},
 {undefined,<0.110.0>,worker,[sockserv_serv]},
 {undefined,<0.111.0>,worker,[sockserv_serv]}]

第一个命令表明所有等待连接的子进程都已经死了。剩下的进程将是那些正在进行活动会话的进程。这表明保持代码响应的重要性。如果我们的进程能够接收消息并对其进行处理,情况就会好很多。

A couch, with 'heaven' written on it

在最后两个命令中,我只是启动更多工作进程来解决问题。虽然这有效,但这需要运行升级的人员手动操作。无论如何,这远非最佳解决方案。解决问题的更好方法是改变应用程序的工作方式,以有一个监控进程监视sockserv_sup有多少个子进程。当子进程数量低于给定阈值时,监控进程会启动更多子进程。另一种策略是更改代码,使接受连接通过在几个秒钟的时间间隔内阻塞来完成,并在暂停后继续重试,在此期间可以接收消息。这将给 gen_servers 足够的时间在需要时升级自己,假设您在安装版本和使其永久生效之间等待了正确的延迟时间。实现这些解决方案中的一个或两个留给读者作为练习,因为我有点懒。这类崩溃是您在对真实系统进行这些更新之前想要测试代码的原因。

无论如何,我们现在已经解决了问题,我们可能想检查升级过程进行得如何

9> release_handler:which_releases().
[{"processquest","1.1.0",
  ["kernel-2.14.4","stdlib-1.17.4","crypto-2.0.3",
   "regis-1.0.0","processquest-1.1.0","sockserv-1.0.1",
   "sasl-2.1.9.4"],
  permanent},
 {"processquest","1.0.0",
  ["kernel-2.14.4","stdlib-1.17.4","crypto-2.0.3",
   "regis-1.0.0","processquest-1.0.0","sockserv-1.0.0",
   "sasl-2.1.9.4"],
  old}]

值得击掌庆祝。您可以尝试通过执行release_handler:install(OldVersion).降级安装。这应该可以正常工作,尽管它可能会冒着终止更多从未更新自身的进程的风险。

不要喝太多酷乐饮料
如果由于某种原因,在尝试使用本章所示的技术回滚到版本的第一个版本时,回滚总是失败,您可能忘记了创建 RELEASES 文件。如果在调用release_handler:which_releases()时看到{YourRelease,Version,[],Status}中的空列表,则可以知道这一点。这是一个列出加载和重新加载模块位置的列表,它在启动 VM 并读取 RELEASES 文件时,或者解压缩新版本时首次构建。

好的,以下是要执行所有操作以使 relup 正常工作的步骤列表

  1. 为您的第一个软件迭代编写 OTP 应用程序
  2. 编译它们
  3. 使用 Reltool 构建一个版本(1.0.0)。它必须包含调试信息,并且没有.ez存档。
  4. 确保在启动生产应用程序之前,您在某个时候创建了 RELEASES 文件。您可以使用release_handler:create_RELEASES(RootDir, ReleasesDir, Relfile, [{AppName, Vsn, LibDir}])来完成此操作。
  5. 运行版本!
  6. 找出其中的错误
  7. 在应用程序的新版本中修复错误
  8. 为每个应用程序编写appup文件
  9. 编译新应用程序
  10. 构建一个新版本(在本例中为 1.1.0)。它必须包含调试信息,并且没有.ez存档
  11. rel/releases/NewVsn/RelName.rel复制为rel/releases/NewVsn/RelName-NewVsn.rel
  12. rel/releases/NewVsn/RelName.boot复制为rel/releases/NewVsn/RelName-NewVsn.boot
  13. rel/releases/NewVsn/RelName.boot复制为rel/releases/NewVsn/start.boot
  14. rel/releases/OldVsn/RelName.rel复制为rel/releases/OldVsn/RelName-OldVsn.rel
  15. rel/releases/OldVsn/RelName.boot复制为rel/releases/OldVsn/RelName-OldVsn.boot
  16. rel/releases/OldVsn/RelName.boot复制为rel/releases/OldVsn/start.boot
  17. 使用systools:make_relup("rel/releases/Vsn/RelName-Vsn", ["rel/releases/OldVsn/RelName-OldVsn"], ["rel/releases/DownVsn/RelName-DownVsn"]).生成一个 relup 文件。
  18. 将 relup 文件移动到rel/releases/Vsn
  19. 使用systools:make_tar("rel/releases/Vsn/RelName-Vsn").生成新版本的 tar 文件。
  20. 将 tar 文件移动到rel/releases/
  21. 打开一些仍然运行版本第一个版本的 shell
  22. 调用release_handler:unpack_release("NameOfRel-Vsn").
  23. 调用release_handler:install_release(Vsn).
  24. 调用release_handler:make_permanent(Vsn).
  25. 确保一切顺利。如果没有,请通过安装旧版本进行回滚。

您可能想编写一些脚本来自动执行此操作。

A podium with 3 positions: 1. you, 2. relups, 3. the author (3rd person)

再次强调,relup 是 OTP 中非常混乱的一部分,很难理解。您可能会发现自己发现了很多新的错误,这些错误都比之前的错误更难理解。关于如何运行事物,有一些假设,在创建版本时选择不同的工具将改变操作方式。您可能很想使用sys模块的函数编写自己的更新代码!或者可能使用像rebar3这样的工具,它将自动执行一些痛苦的步骤。无论如何,本章及其示例都是根据作者的最佳知识编写的,作者有时喜欢以第三人称谈论自己。

如果可以通过不需要 relup 的方式升级您的应用程序,我建议您这样做。据说使用 relup 的爱立信部门在测试 relup 上花费的时间与测试应用程序本身的时间一样多。它们是用于处理永远无法强制关闭的产品的工具。您将在需要时使用它们,主要是因为您已准备好经历使用它们的麻烦(必须热爱这种循环逻辑!)。当需要出现时,relup 非常有用。

我们现在开始学习一些更友好的 Erlang 功能吧?