Erlang(和Elixir)无epmd之分布式

当你部署一个分布式Erlang应用的时候,你最可能必须要回答的问题是:“哪些端口需要在防火墙里打开?”。除非进行配置,否则答案就是:

  • epmd(Erlang端口映射守护进程)端口4369,
  • Erlang节点自己的端口,一个不可预知的大数字端口。

这个答案通常都不能简单地应用到防火墙的规则。通常的解决方法是用环境变量 inet_dist_listen_min 和 inet_dist_listen_max (官方的kernel文档里有描述)来限制分布式端口在一个小范围或者甚至是一个单独的端口。

但是如果我们限制了Erlang节点在一个单独的端口,那么我们还真的需要一个端口映射吗?运行epmd有以下几个潜在的缺点:

  • 我们可能想要以运行Erlang节点的用户来运行epmd,但是正如我们将见到的,这个很难得到保证。
  • 我们可能想要配置epmd监听一个特定的端口,但是这依赖于在本机上我们如何第一次运行epmd。
  • 任何人无需身份验证可以连接epmd并且可以列出本地的所有Erlang节点。
  • 与epmd的连接都没有加密,即使你按 Erlang distribution over TLS 这篇文章的方法去做,与epmd的连接也是不受保护的,并且有被中间人攻击的潜在风险。
  • 虽然epmd很稳定,它也有发生崩溃的可能。一旦它崩溃了,任何已经存在的分布式Erlang节点将继续运行,即使epmd恢复了,这些节点也不会重新连接empd。这意味着存在的分布式连接继续工作着,但是与这些节点的新的连接将不起作用。阅读俄文博客 Экстренная реанимация epmd 可能可以在不重启epmd的情况下解决这个问题。

那么我们来研究一下epmd是如何工作的,然后我们如何做就可以在不用epmd的情况下运行一个Erlang集群。

epmd是如何第一次启动的?

让我们来读读源码!epmd是在erlexec.c文件的start_epmd函数里被启动的。实际上,每一次一个Erlang分布式节点启动epmd都无条件地被启动。如果一个epmd实例已经在运行,新的epmd在侦听端口4369的时候将失败,它将因此悄无声息地结束运行。

事实上,这就是即使由另一个用户启动的epmd实例存在系统将发生的事情。任何epmd实例都非常乐意为由任何用户启动的Erlang节点服务,因此这个通常不会引起任何问题。

那么谁可以连接epmd?

任何人都可以!默认情况下,epmd侦听在所有有效的接口上,并且响应关于当前有哪些节点以及这些节点所侦听的端口的查询。一听到这个场景,系统管理员应该有点紧张。

你可以在epmd启动前通过手工指定 -address 选项或设定ERL_EPMD_ADDRESS 环境变量启动epmd来改变上述情况。这个方法在官方文档里有描述。这个方法需要在你第一次启动epmd的时候来做,否则,已经存在的epmd实例将不受影响地继续运行。

为什么我们需要一个端口映射守护进程?

很明显,epmd就是一个中间人。我们可以绕过这个中间人吗?

我们可以让每个Erlang节点侦听在一个大家熟知的端口–如果我们打算去除掉epmd的话,我们可以用empd默认保留的端口,4369。但是这就意味着我们只能够在每个主机上运行一个Erlang节点(当然,在某些场景下这就足够了)。

那么让我们指定一些其他端口。前面我们提到变量 inet_dist_listen_min 和 inet_dist_listen_max 。这两个变量定义一个端口范围,不过如果我们设置它们为相同的值,我们就把范围缩小到一个端口:

1
2
3
erl -sname foo \
-kernel inet_dist_listen_min 4370 \
inet_dist_listen_max 4370

这个方法不错,不过我们也需要告诉其他节点不要再去询问epmd来获取端口号,而是只用这个端口号来代替。如果我们在同一个主机上有几个节点,我们需要为这些节点配置不同的端口号。

用别的方法

在Erlang/OTP 19.0里,有两个新的命令行选项:

  • 当我们指定 -start_epmd false,Erlang将不会在启动一个分布式节点的时候尝试去启动epmd;
  • 选项 -epmd_module foo 让你指定一个不同的模块替代默认的模块erl_epmd,用来节点名字注册和查询。

接下来我们来构建一些代码!

我想用一个无状态的方式来实现这个方法:既然发起连接的节点知道了它要连接的节点的名字,那么我就用这个名字作为资源来得到相应的端口号。我选择4370这个端口作为基准端口,它比epmd的端口大一。接着我从节点名称的“节点”部分抽取出数字,比如节点名称为 myapp3@foo.example.com 抽取的数字是3。最后我把这个数字和基准端口相加。由此得知,节点 myapp3@foo.example.com 侦听在端口4373。如果节点名字最后不是数字,我这种情况将处理为0。这意味着节点 myapp3 和 myotherapp3 不能运行在同一个主机上,但是我准备好了处理对这种场景。(感谢 Luca Favatella 的完美建议)

为此我们来写一个小模块:

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
27
28
-module(epmdless).
-export([dist_port/1]).
%% 返回被一个指定节点使用的端口号。
dist_port(Name) when is_atom(Name) ->
dist_port(atom_to_list(Name));
dist_port(Name) when is_list(Name) ->
%% 获取基端口号。如果没有在 kernel 的环境变量
%% inet_dist_base_port 里指定, 则默认为
%% 4370, 比epmd端口大1。
BasePort = application:get_env(kernel, inet_dist_base_port, 4370),
%% 现在,算出我们相对于基端口号的偏移值。
%% 这个偏移值就是我们节点名的@符号左边的数字。
%% 如果这个数字不存在,则偏移量设置为0。
%%
%% 同时我们也处理主机名没有指定的情况。
NodeName = re:replace(Name, "@.*$", ""),
Offset =
case re:run(NodeName, "[0-9]+$", [{capture, first, list}]) of
nomatch ->
0;
{match, [OffsetAsString]} ->
list_to_integer(OffsetAsString)
end,
BasePort + Offset.

同时我们还要写一个模块用作 -epmd_module。有一点小复杂就是因为OTP19.0期望这个模块导出 register_node/2 函数,而从OTP19.1开始则是期望导出 register_node/3 函数。我们把这两个函数都包含进去。

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
27
28
29
30
31
32
33
34
35
36
37
-module(epmdless_epmd_client).
%% epmd_module 回调函数
-export([start_link/0,
register_node/2,
register_node/3,
port_please/2,
names/1]).
%% 监督者模块 erl_distribution 尝试着把我们当做一个被监督的子进程。
%% 我们不需要子进程,所以返回 ignore 。
start_link() ->
ignore.
register_node(_Name, _Port) ->
%% 本函数是我们连接epmd并告诉它我们监听哪个端口,
%% 不过因为我们是无epmd的,所以我们不需要做这些。
%% 需要返回一个1到3的 ”creation” 数字。
Creation = rand:uniform(3),
{ok, Creation}.
%% 从Erlang/OTP19.1开始,register_node/3函数替代register_node/2函数,
%% 该函数多了一个地址族的入参,取值为 ‘inet_tcp’ 或 ‘inet6_tcp’。
%% 这一点对我们来说没有多大的改动。
register_node(Name, Port, _Family) ->
register_node(Name, Port).
port_please(Name, _IP) ->
Port = epmdless:dist_port(Name),
%% 分布式协议版本从Erlang/OTP16开始就是5。
Version = 5,
{port, Port, Version}.
names(_Hostname) ->
%% 因为我们没有epmd,所以我们真的不知道此处是否有其他节点。
{error, address}.

正如你所见,大多数事情基本完成了。

当这个模块被加为 erl_distribution 监督者的子进程的时候,start_link/0 将被调用。我们并不需要在这个启动一个进程,因此我们只是返回 ignore。

register_node函数通常会连接epmd并告诉它我们使用哪一个端口。epmd将返回一个“creation”数字。“creation”数字上一个介于1和3的整数。epmd跟踪每一个节点名字的“creation”数字并且当一个确定名字的节点重连的时候增加这个数字。这意味着它可以区分先前某一个节点“存活期间”的进程id。

既然我们没有epmd,那么我们就没有获得它跟踪节点生命周期的好处。在这里我们返回一个随机数,它有三分之二的机会不同于前面的“creation”数字。

port_please/2 获取远程主机的IP地址以便连接它的epmd,但是我们不关心这些;我们用我们自己的算法来算出我们的端口号。

我们也需要返回一个分布式协议版本号。从Erlang/OTP 6开始它就是5(参阅 分布式协议官方文档 ),就这么简单。

最后,names/1 函数被调用来返回某个主机上的Erlang节点列表。我们没有办法知道这些信息,所以让我们假装我们连接不上这个主机。

目前为止,一起都顺利。但是我们需要一个方法来确认我们正侦听在合适的端口上。我认为最好的方法是写一个新的分布式协议模块,该模块仅是设置端口号而让真正的协议模块做它自己的事情:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
-module(epmdless_dist).
-export([listen/1,
select/1,
accept/1,
accept_connection/5,
setup/5,
close/1,
childspecs/0]).
listen(Name) ->
%% 在此处我们算出我们想要侦听在什么端口。
Port = epmdless:dist_port(Name),
%% 设置 “最小” 和 “最大” 两个变量,强制侦听端口为一个值。
ok = application:set_env(kernel, inet_dist_listen_min, Port),
ok = application:set_env(kernel, inet_dist_listen_max, Port),
%% 最后运行真正的函数!
inet_tcp_dist:listen(Name).
select(Node) ->
inet_tcp_dist:select(Node).
accept(Listen) ->
inet_tcp_dist:accept(Listen).
accept_connection(AcceptPid, Socket, MyNode, Allowed, SetupTime) ->
inet_tcp_dist:accept_connection(AcceptPid, Socket, MyNode, Allowed, SetupTime).
setup(Node, Type, MyNode, LongOrShortNames, SetupTime) ->
inet_tcp_dist:setup(Node, Type, MyNode, LongOrShortNames, SetupTime).
close(Listen) ->
inet_tcp_dist:close(Listen).
childspecs() ->
inet_tcp_dist:childspecs().

几乎所有的代码都在这。listen/1函数在将控制权交给真正的处理模块inet_tcp_dist前,根据我们的节点名来设置inet_dist_listen_min和inet_dist_listen_max两个变量。

(请注意:虽然inet_tcp_dist是默认的处理模块,但是它仅提供基于IPv4上的未加密连接。如果你想用IPv6,你应该用inet6_tcp_dist,而如果你想用基于TLS上的Erlang分布式功能,那就应该选择inet_tsl_dist或inet6_tsl_dist。增加这些弹性的尝试留给读者自己练习。)

我们准备好了!现在我们启动两个节点,foo1和foo2,并且使它们彼此互连:

1
2
erl -proto_dist epmdless -start_epmd false -epmd_module epmdless_epmd_client -sname foo1
erl -proto_dist epmdless -start_epmd false -epmd_module epmdless_epmd_client -sname foo2

系统能正常运行吗?

1
2
(foo2@poki-sona-sin)1> net_adm:ping('foo1@poki-sona-sin').
pong

看起来没问题!

接着在Elixir下尝试

当然,因为Erlang和Elixir都运行在同样的虚拟机上,那么就没有什么可以阻止我们在Elixir里实现上述功能了。

在Elixir里,我们可以将所有代码放在一个单独文件里,编译器会将它编译成我们需要的不同的模块:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# 一个包含根据一个节点名来决定端口号的函数的模块。
defmodule Epmdless do
def dist_port(name) when is_atom(name) do
dist_port Atom.to_string name
end
def dist_port(name) when is_list(name) do
dist_port List.to_string name
end
def dist_port(name) when is_binary(name) do
# 算出基端口。如果没有指定则用
# inet_dist_base_port 这个kernel的环境变量,
# 默认值是4370, 比epmd的端口大1。
base_port = :application.get_env :kernel, :inet_dist_base_port, 4370
# 现在,基于基端口算出我们的“偏移量”。
# 偏移量就是一个我们节点名@符号左边的整数。
# 如果这个整数不存在,则偏移量是0。
#
# 也处理主机名没有指定的情况。
node_name = Regex.replace ~r/@.*$/, name, ""
offset =
case Regex.run ~r/[0-9]+$/, node_name do
nil ->
0
[offset_as_string] ->
String.to_integer offset_as_string
end
base_port + offset
end
end
defmodule Epmdless_dist do
def listen(name) do
# 此处我们算出我们想要侦听的端口。
port = Epmdless.dist_port name
# 设置“min”和“max”变量,强制两个端口为一个值。
:ok = :application.set_env :kernel, :inet_dist_listen_min, port
:ok = :application.set_env :kernel, :inet_dist_listen_max, port
# 最后运行真正的函数!
:inet_tcp_dist.listen name
end
def select(node) do
:inet_tcp_dist.select node
end
def accept(listen) do
:inet_tcp_dist.accept listen
end
def accept_connection(accept_pid, socket, my_node, allowed, setup_time) do
:inet_tcp_dist.accept_connection accept_pid, socket, my_node, allowed, setup_time
end
def setup(node, type, my_node, long_or_short_names, setup_time) do
:inet_tcp_dist.setup node, type, my_node, long_or_short_names, setup_time
end
def close(listen) do
:inet_tcp_dist.close listen
end
def childspecs do
:inet_tcp_dist.childspecs
end
end
defmodule Epmdless_epmd_client do
# erl_distribution想要我们启动一个工作进程,然而我们并不需要。
def start_link do
:ignore
end
# 因为Erlang/OTP 19.1用register_node/3来替换register_node/2,
# 只是多传了一个参数:地址族,’inet_tcp’ 或 ‘inet6_tcp’。
# 这对于我们来说没什么问题。
def register_node(name, port, _family) do
register_node(name, port)
end
def register_node(_name, _port) do
# 在此处我们应该连接epmd并告诉它哪个是我们侦听的端口,
# 但是因为我们是无epmd的,所以我们不需要这么做。
# 需要返回一个介于1和3的"creation”数字。
creation = :rand.uniform 3
{:ok, creation}
end
def port_please(name, _ip) do
port = Epmdless.dist_port name
# 分布式协议版本数字从Erlang/OTP R6开始就是5。
version = 5
{:port, port, version}
end
def names(_hostname) do
# 既然我们没有epmd,那么我们就不真正需要知道其他节点。
{:error, :address}
end
end

当我们启动Elixir,我们需要用 - -erl 来传递一些参数来使得功能启用:

1
iex --erl "-proto_dist Elixir.Epmdless -start_epmd false -epmd_module Elixir.Epmdless_epmd_client" --sname foo3

让我们ping我们前面已经启动的两个节点:

1
2
3
4
iex(foo3@poki-sona-sin)1> Node.ping :"foo1@poki-sona-sin"
:pong
iex(foo3@poki-sona-sin)2> Node.ping :"foo2@poki-sona-sin"
:pong

它们都连通了并且没有epmd参与!

结论

本文描述的只是其中一种不需要epmd的Erlang分布式方式;我相信你可以用别的办法来更好地符合你的需求。我希望上述的示例代码是有用的指南!

原文链接: https://www.erlang-solutions.com/blog/erlang-and-elixir-distribution-without-epmd.html