Rebar是个不错的东西,特别是它的模版配置自动生成功能,能省去大把机械操作。 但是凡是现成的东西用起来都会多多少少和你的项目性格不和——相信我,这事情屡试不爽——当然 rebar也不是个例外。

刚开始用的时候我承认有点不知所措,因为它的各种操作要么封装得太好,不满足需求的时候 甚至不知道从哪入手,要么一点都不经封装,直接用Erlang和OTP里现成的代码和配置格式。前 一种情况还好,用脚本炮制的土方法修补一下还是ok的;但是对后一种情况,貌似rebar的文档 认为这不关自己的事,应该由相应的Erlang文档说明,问题是前者里没有任何后者的pointer, 这对我这种Erlang刚入门的人来说太凶残了。

所以,我在这把这些乱七八糟的问题记一下,免得年纪大了忘掉。为什么我不给rebar来个 issue呢?或者直接提交补丁呢?因为这些东西顶多只能算额外feature,自己来加的话,等到 upstream接受补丁我们的项目可能已经结束了,这期间我没有人手和精力再给rebar维护一个 branch——好吧,我只是不想给开源软件做贡献而已。

我们的Release流程

大体上就是这样:

1
2
./rebar clean compile generate generate-appups \
        generate-upgrade previous_release=$PREVIOUS_RELEASE

不过这只是生成发布包的过程,如果有上个版本的话还会生成升级包,仅此而已。可能rebar 的开发者觉得版本管理不是他们能/应该管的事情,所以做升级包的时候要我们自己提供一个 previous_release. 然后Erlang的reltool比较笨——没错rebar用的是reltool——只会根据 *.appreltool.config里指定的版本确定发布版本。即使你把程序改得自己都不认识 了,只要配置文件里的版本没改,它就是同一个release.

于是这里有两个问题:

  1. previous_release从哪里来;
  2. 不同release的版本怎么确定。

对于第一个问题,由于我们用Jenkins做发布,所以我在rebar generate之后加了一个步 骤,在这个步骤里用shell脚本维护一个release列表,以前生成的每个release都会保存在 编译服务器上,用作后续发布的previous_release,除非有人手工删除。我们的服务没有 重要到必须保证随时能热更新的程度,所以这些保存下来的release也没有备份或者提交到 VCS(Subversion、Git……),到目前为止还算ok.

对于第二个问题,我们选择的方案是用程序代码的SVN revision,这大概是对开发者最友好 的方式(之一?)了吧……实际操作是在rebar compile之前,用脚本将*.appreltool.config里的版本号与SVN revision同步,这样就能保证每次修改代码之后生成的 release都是独一无二的,而且来历清清楚楚。

所以最后的发布包是这样出来的:

1
2
3
4
5
6
7
8
rebar clean
<确定新release版本>
rebar compile
rebar generate
<提供上一个release>
rebar generate-appups previous_release=$PREVIOUS_RELEASE
rebar generate-upgrade previous_release=$PREVIOUS_RELEASE
<保存到release列表>

Release包中配置文件的位置

好了,包打好了,来部署吧。

我们的服务实例可能需要不同的认证方式和数据库连接等,所以每个实例的配置都会不一 样,对我们来说最重要的是vm.argssys.config这两个文件——对,我们名字都懒得 改。Rebar生成的控制脚本支持三个文件位置:

  1. $CWD
  2. $ROOT/releases/$RELEASE/
  3. $ROOT/etc/

$CWD就是执行脚本的当前目录,$ROOT是部署目录,而$RELEASE是当前激活的release 版本。脚本执行时会按顺序检查这三个目录,如果存在vm.args或者sys.config就会用 上。$ROOT/releases/$RELEASE/目录下的配置是随release分发的,方便我们在不同 release间增加或者删除配置项;但是如果里面含有实例相关的选项,每个实例再单独修改 会非常麻烦。

所以我倾向于用$CWD,但是这也有问题:每次执行控制脚本之前都要cd到指定目录?万一 我忘了怎么办?写个wrapper?还不如直接改控制脚本。于是我就改了。vm.args的优先级 改成这样:

  1. $ROOT/etc/
  2. $ROOT/releases/$RELEASE/

然后我们在$ROOT/etc/里放symlink,指向真正的实例配置。

sys.config呢?我发现erl -config a -config b命令中b里存在的选项会覆盖掉a里 的同名选项(虽然没有任何官方文档提到这一点),所以在$ROOT/etc/里增加了 spec.config,在里面指定实例相关的选项,其他通用选项则还是放在 $ROOT/releases/$RELEASE/sys.config.

好吧,改完控制脚本之后,貌似服务启动时能找到正确的配置文件了,但是还有一个可能 没那么明显的坑:热更时的配置自动更新。

release_handler(OTP代码)里,执行install_release命令时,程序会固定到 $ROOT/releases/$NEW_RELEASE/目录下找sys.config文件(其实可以通过环境变量等方 式更改这个路径,但是仅限于erlang虚拟机启动的时候),并把这个文件读进来,作为新的 application配置。所以,如果我们像上面那样更改了配置文件的优先顺序,下次热更新的时 候这个顺序还是会被破坏掉,导致线上的配置与spec.config不一致;而且,决定热更新时 配置文件优先顺序的代码在OTP里,不好去改。

这里我选择了比较简单的方案:成功热更新之后,命令一个进程去主动读取spec.config 更新配置。当然这个方案并不完美,最坏的情况是,热更新完成和读取配置这两步之间有 一个竞争窗口,这个窗口里可能会有新代码带着旧配置去执行,结果不可预料——这个情况在我 们看来还是可以接受的,因为对于相对重要的配置,我们都要求通过重启服务实例来更新,热 更新时更改的配置不会造成不可挽回的后果。

动态生成配置文件

这是在后来翻rebar文档的时候发现的。比方说,在生成oceanus.app的时候,rebar 会去找叫做oceanus.app.script的文件,然后把里面的Erlang程序执行一遍,再将结果写 到oceanus.app里。上面release流程里提到的“确定新release版本”这一步我们是用shell 做的,现在发现其实可以动态生成……不过话说回来,这个做法还真有够dirty的,把程序写 在配置文件里,就不怕出事么→_→

热更新

Rebar用模版生成发布目录的时候还提供了协助热更新的脚本,一般就叫做 install_upgrade.escript,按照rebar官方文档的说法,升级包复制到$ROOT/releases 目录下后,执行rebar upgrade命令时其实就会去调用它。问题是这个脚本里做rpc:call(...) 的时候,脚本自己是用short name的,而我们为了方便节点间通信,部署的服务实例都用了 long name,按Erlang的规定,这两种节点没办法互相通信,热更新也铁定会失败。

所以,我在rebar create-node ...之后都会再给install_upgrade.escript打个补丁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Index: rel/files/install_upgrade.escript
===================================================================
--- rel/files/install_upgrade.escript   (revision 9011)
+++ rel/files/install_upgrade.escript   (working copy)
@@ -23,7 +23,7 @@

 start_distribution(NodeName, Cookie) ->
     MyNode = make_script_node(NodeName),
-    {ok, _Pid} = net_kernel:start([MyNode, shortnames]),
+    {ok, _Pid} = net_kernel:start([MyNode]),
     erlang:set_cookie(node(), list_to_atom(Cookie)),
     TargetNode = make_target_node(NodeName),
     case {net_kernel:hidden_connect_node(TargetNode),
@@ -37,8 +37,9 @@
     TargetNode.

 make_target_node(Node) ->
-    [_, Host] = string:tokens(atom_to_list(node()), "@"),
-    list_to_atom(lists:concat([Node, "@", Host])).
+    [NodeName, Host] = string:tokens(Node, "@"),
+    list_to_atom(lists:concat([NodeName, "@", Host])).

 make_script_node(Node) ->
-    list_to_atom(lists:concat([Node, "_upgrader_", os:getpid()])).
+    [NodeName, Host] = string:tokens(Node, "@"),
+    list_to_atom(lists:concat([NodeName, "_upgrader_", os:getpid(), "@", Host])).