5

getaddrinfo 中最长前缀匹配实现导致的DNS 负载均衡失效

 2 years ago
source link: https://diabloneo.github.io//2021/10/08/DNS-Round-Robin-Fail-and-Longest-Matching-Prefix-Implementation-in-getaddrinfo/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

DNS 负载均衡方案的失效

通过 DNS 实现负载均衡是一种常见的方案。这种方案通常会返回多个 A 记录,客户端会按照 DNS 响应中的顺序依次尝试去连接服务器,直到成功为止。这个方案对于客户端是有要求的,即客户端必须严格按照 DNS 响应中的地址顺序来访问服务器。在一段不算短的时间以前,大概是 Linux 还未成熟的时候,很多应用还是使用 gethostbyname 来进行 DNS 解析。gethostbyname 接口会严格返回 DNS 响应中的地址顺序,因此应用使用一个循环来进行连接尝试时,就达到了负载均衡的目的。但是,在 getaddrinfo 接口被实现,并且被推荐用来替代 gethostbyname 之后,这个情况就变了。原因是 getaddrinfo 会实现 RFC 3484 (Default Address Selection for Internet Protocol version 6 (IPv6)) 中的地址选择功能,其中的目标地址选择功能直接导致了 DNS 负载均衡方案的失效。简单的说,目标地址选择功能会修改返回给应用程序的 DNS 地址记录的顺序,导致应用程序是按照目标地址选择功能决定的顺序,而不是 DNS 服务器决定的顺序来访问服务器。会导致地址返回顺序被修改的场景很多,本文会描述一种我觉得最常见的失效场景。

失效场景:与 IP 地址的选择有关

失效的场景设置如下图所示:

getaddrinfo_and_dns

整个业务的流程是这样的:

  1. 某个业务有一个客户端,以及三台服务器。采用 DNS 负载均衡方案,让客户端按照一定的比例将请求转发到三个服务器上。
  2. 客户端需要通过内部的 DNS 服务器来解析域名,获得可以访问的服务器地址。在实现上,从 getaddrinfo 返回的第一个地址开始尝试。

DNS 服务器返回的其中一个 DNS 响应如下:

test.dom.               3600    IN      A       192.168.192.128
test.dom.               3600    IN      A       192.168.192.127
test.dom.               3600    IN      A       192.168.192.129

因为我们采用了负载均衡的策略,所以返回的 DNS 响应,每次都会重新排列所有的地址,保证每个地址出现在第一条的概率基本一样。所以,实际上有 6 中排列组合方式。

客户端也有一个同网段的地址:192.168.192.121。所有这些地址,都属于 192.168.192.0/24 这个子网。如本章的标题所示,这些地址的选择是非常重要的,就是地址的值导致了 DNS 负载均衡的失效

在这个场景中,我们发现我们的客户端程序每次都是去连接 192.168.192.127 这个地址,从来不使用其他两个地址,不管这个 192.168.192.127 的地址是出现在响应中的哪个位置。

问题定位与分析

我们首先排除了 DNS 服务器的问题,以及客户端实现的问题。所以,问题出现在 DNS 请求成功后,到将地址列表返回给客户端程序之前。为了简化问题定位,我们发现 ping 程序也遇到了同样的问题,即 ping 这个域名,都只会使用 192.168.192.127 这个地址。

使用 strace 命令来分析问题

我们先使用 strace ping -c 1 test.dom 命令来看看程序到底做了什么。下面是其中的相关内容(为了好看,我删掉了不相关的内容。另外,如果你想看到更多的参数信息,可以使用 strace-s 参数):

# 先连接 DNS 服务器,发出 DNS 请求,并且收到响应。
socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 4
connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("192.168.192.132")}, 16) = 0
sendto(4, "\272\273\1\0\0\1\0\0\0\0\0\0\4test\3dom\0\0\1\0\1", 26, MSG_NOSIGNAL, NULL, 0) = 26
recvfrom(4, "\272\273\205\0\0\1\0\3\0\1\0\0\4test\3dom\0\0\1\0\1\4test\3"..., 1024, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("192.168.192.132")}, [16]) = 121
close(4)                                = 0

# 尝试打开这个文件,见下文。
open("/etc/gai.conf", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)

# 通过 NETLINK 获取一些接口信息 (getifaddrs)
socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) = 4
bind(4, {sa_family=AF_NETLINK, pid=0, groups=00000000}, 12) = 0
getsockname(4, {sa_family=AF_NETLINK, pid=217030, groups=00000000}, [12]) = 0
sendto(4, "\24\0\0\0\26\0\1\3\312W`a\0\0\0\0\0\0\0\0", 20, 0, {sa_family=AF_NETLINK, pid=0, groups=00000000}, 12) = 20
recvmsg(4, {msg_name(12)={sa_family=AF_NETLINK, pid=0, groups=00000000}, msg_iov(1)=[{"L...
recvmsg(4, {msg_name(12)={sa_family=AF_NETLINK, pid=0, groups=00000000}, msg_iov(1)=[{"H
recvmsg(4, {msg_name(12)={sa_family=AF_NETLINK, pid=0, groups=00000000}, msg_iov(1)=[{"...

# 尝试连接 DNS 返回的每个地址,判断是否可用。这里用的是 UDP,所以主要是判断路由是否可达。
socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, IPPROTO_IP) = 4
connect(4, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.192.128")}, 16) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(58008), sin_addr=inet_addr("192.168.192.121")}, [16]) = 0
connect(4, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(4, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.192.127")}, 16) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(44743), sin_addr=inet_addr("192.168.192.121")}, [16]) = 0
connect(4, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(4, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("192.168.192.129")}, 16) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(35095), sin_addr=inet_addr("192.168.192.121")}, [16]) = 0

# 开始连接 192.168.192.127。注意到,从上面到这里,没有任何系统调用,所以这里是一段完全由代码和内存数据决定的逻辑。
socket(AF_INET, SOCK_DGRAM, IPPROTO_IP) = 4
connect(4, {sa_family=AF_INET, sin_port=htons(1025), sin_addr=inet_addr("192.168.192.127")}, 16) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(34211), sin_addr=inet_addr("192.168.192.121")}, [16]) = 0
setsockopt(3, SOL_RAW, ICMP_FILTER, ~(1<<ICMP_ECHOREPLY|1<<ICMP_DEST_UNREACH|1<<ICMP_SOURCE_QUENCH|1<<ICMP_REDIRECT|1<<ICMP_TIME_EXCEEDED|1<<ICMP_PARAMETERPROB), 4) = 0
setsockopt(3, SOL_IP, IP_RECVERR, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_SNDBUF, [324], 4) = 0
setsockopt(3, SOL_SOCKET, SO_RCVBUF, [65536], 4) = 0
getsockopt(3, SOL_SOCKET, SO_RCVBUF, [131072], [4]) = 0
PING test.dom (192.168.192.127) 56(84) bytes of data.

在上面的代码中,我注释了关键的部分。最后,我们可以发现,导致问题的代码是在尝试 connect 之后,以及应用开始使用 IP 地址之前的部分,也就是说,getaddrinfo 导致的问题。但是,为什么 getaddrinfo 这个函数会有这个问题?理论上来说,一个广泛使用的库函数,应该是很稳定的。此时,我注意到了上面的 /etc/gai.conf 这个文件。

getaddrinfo 与 RFC 3484

通过阅读 man gai.conf,我了解到 getaddrinfo 根据 RFC 3484 实现了目的地址排序,再通过阅读 RFC 的相关内容,我了解到,这个排序会涉及到 10 条规则 (RFC 3484, Chapter 6 Destination Address Selection)。通过反复研究这 10 条规则,以及进行一些测试,我判断比较可能是规则 9 导致的问题:

Rule 9: Use longest matching prefix. When DA and DB belong to the same address family (both are IPv6 or both are IPv4): If CommonPrefixLen(DA, Source(DA)) > CommonPrefixLen(DB, Source(DB)), then prefer DA. Similarly, if CommonPrefixLen(DA, Source(DA)) < CommonPrefixLen(DB, Source(DB)), then prefer DB.

那么,RFC 3484 是做什么的呢?这个其实在引入 IPv6 之后,对于网络中一个节点,如何选择源地址与目的地址做出了规定。getaddrinfo 因为涉及到网络地址的选择,所以实现了这个标准。你可能有疑问,为什么一个 IPv6 的标准会影响到 IPv4 的网络,这个主要是因为网络总是要过度的,所以在指定标准的过程中就都进行了考虑。这个标准对于 IPv4 源地址的选择没有做规定,这个取决于操作系统的实现,主要还是路由来决定选择哪个源地址。但是规定了目标地址的选择,比如遵守上面提到的 10 条规则。

为什么我会判断是 Rule 9 导致的问题,主要是结合一下几个方面:

  1. 客户端和服务器的地址在同一个网段,不会收到路由决策的干扰,且全部处于可用状态。
  2. 操作系统不存在 /etc/gai.conf 文件,所以不会有 label 和优先级的问题。
  3. 因为地址都是 IPv4 的私有网段,所以 scope 也都是规定好的,也就没有任何差异。

阅读 glibc 代码并且进行 gdb

当然,上面只是推测,还需要证据。接下来,我们要结合代码来找证据,当然,因为时间有限,不太可能仔细研究代码,所以我一般结合代码和调试信息来定位问题。

glibc 的代码: https://sourceware.org/git/glibc.git

我们是 CentOS 7.6 的系统,可以在系统上安装 debuginfo 来进行调试:

# 会自动安装 glibc 的 debuginfo。
# debuginfo-install iputils-20160308-10.el7.x86_64

然后使用 gdb 来辅助代码阅读:

# gdb --args ping -c 1 test.dom
(gdb) b getaddrinfo
Breakpoint 1 at 0x2210
(gdb) run
Starting program: /usr/bin/ping -c 1 test.gfs

Breakpoint 1, __GI_getaddrinfo (name=name@entry=0x7fffffffe666 "test.gfs", service=service@entry=0x0, hints=hints@entry=0x7fffffffe270, pai=pai@entry=0x7fffffffe248)
    at ../sysdeps/posix/getaddrinfo.c:2208
2208    {
Missing separate debuginfos, use: debuginfo-install libattr-2.4.46-13.el7.x86_64 zlib-1.2.7-18.el7.x86_64
(gdb) b rfc3484_sort
Breakpoint 2 at 0x7ffff6d3df70: file ../sysdeps/posix/getaddrinfo.c, line 1440.
(gdb) c

这个时候,我们就进入到了 glibc 这个库中的 rfc3484_sort 这个函数,函数名字取得很好,最终的问题也是由这里导致的。接下来是逐行分析这个函数的逻辑,过程就不细说了,我们来看结论。

rfc3484_sort Longest Matching Prefix 实现

这个函数的注释很清晰,标明了哪个部分是对应到标准的哪个 rule。通过 gdb 的逐步调试,我们发现,果然是 Rule 9 导致的问题。我们只关心 IPv4 的部分,见下面的代码(代码原始缩进就没对齐):

// 这个函数是用在快排中的 cmp 函数,用来比较两个地址的优先级。
// 函数里的 a1 和 a2 两个变量,会在快排过程中,对应到 DNS 返回的两个地址,比如 192.168.192.127 和 192.168.192.128。
// 函数运行到这里时,源地址已经选择完毕了,就是根据路由选出来的网卡地址,在这个场景中,就是 192.168.192.121。

  /* Rule 9: Use longest matching prefix.  */
  if (a1->got_source_addr
      && a1->dest_addr->ai_family == a2->dest_addr->ai_family)
    {
      int bit1 = 0;
      int bit2 = 0;

      if (a1->dest_addr->ai_family == PF_INET)
	{
	  assert (a1->source_addr.sin6_family == PF_INET);
	  assert (a2->source_addr.sin6_family == PF_INET);

	  /* Outside of subnets, as defined by the network masks,
	     common address prefixes for IPv4 addresses make no sense.
	     So, define a non-zero value only if source and
	     destination address are on the same subnet.  */
	  struct sockaddr_in *in1_dst
	    = (struct sockaddr_in *) a1->dest_addr->ai_addr;
	  in_addr_t in1_dst_addr = ntohl (in1_dst->sin_addr.s_addr);
	  struct sockaddr_in *in1_src
	    = (struct sockaddr_in *) &a1->source_addr;
	  in_addr_t in1_src_addr = ntohl (in1_src->sin_addr.s_addr);
	  in_addr_t netmask1 = 0xffffffffu << (32 - a1->prefixlen);

	  // in1_src_addr 就是选择到的源地址,在这个场景里,就是 192.168.192.121
	  // netmask1 和 24 位掩码对应: 0xffffff00
	  // in1_dst_addr 就是参与比较的某个 DNS 响应中的地址。
	  if ((in1_src_addr & netmask1) == (in1_dst_addr & netmask1))
	    // 因为我们的客户端和服务端在同一个子网,所以这个条件会成立。
	    // fls 函数,从左到右找到第一个 1 的位置,最左边是位置 0,最右边是位置 31.
		// 将源地址和目标地址进行 XOR,然后找到第一个 1 的位置。
	    bit1 = fls (in1_dst_addr ^ in1_src_addr);

	  struct sockaddr_in *in2_dst
	    = (struct sockaddr_in *) a2->dest_addr->ai_addr;
	  in_addr_t in2_dst_addr = ntohl (in2_dst->sin_addr.s_addr);
	  struct sockaddr_in *in2_src
	    = (struct sockaddr_in *) &a2->source_addr;
	  in_addr_t in2_src_addr = ntohl (in2_src->sin_addr.s_addr);
	  in_addr_t netmask2 = 0xffffffffu << (32 - a2->prefixlen);

	  if ((in2_src_addr & netmask2) == (in2_dst_addr & netmask2))
	    bit2 = fls (in2_dst_addr ^ in2_src_addr);
	}
      else if (a1->dest_addr->ai_family == PF_INET6)
	{
	  ...
	}

	// 第一个 1 的位置越靠右边,值越小。注意,如果值相等,就不改变位置。
		if (bit1 > bit2)
	return -1;
      if (bit1 < bit2)
	return 1;
    }

以我们的场景来说:

192.168.192.127 和 192.168.192.128 进行比较:

  • HEX(192.168.192.121) = 0xc0a8c079
  • HEX(192.168.192.127) = 0xc0a8c07f
  • HEX(192.168.192.128) = 0xc0a8c080
  • netmask1 = netmask2 = 0xffffff00

bit1 = fls(in1_dst_addr ^ in1_src_addr) = fls(0xc0a8c07f ^ 0xc0a8c079) = 29

bit2 = fls(in2_dst_addr ^ in2_src_addr) = fls(0xc0a8c079 ^ 0xc0a8c080) = 24

因为 bit1 > bit2,所以 192.168.192.127 排在 192.168.192.128 前面。同样,你可以算出 192.168.192.129fls(...) 值为 24,所以它也排在 192.168.192.127 后面。于是,只要服务端返回是这三个地址,192.168.192.127 永远排在第一个。

构造 DNS 轮询不失效的地址

把上面的 192.168.192.127 换成 192.168.192.130,此时,你就会发现,128, 129, 130 这三个地址算出来的 fls(...) 值都是 24,所以 getaddrinfo 不会改变 DNS 响应返回的地址的顺序,DNS 轮询“神奇“的生效了

再看另外一个例子:

  • 客户端是 10.253.1.14
  • 三个服务器是 10.253.1.43, 10.253.1.44, 10.253.1.45
  • DNS 服务器是 10.253.1.46

使用这些地址,你会算出来三个服务器地址的 fls(...) 都为 26,所以 DNS 轮询又“神奇”的生效了。

Update

  1. 2021-10-09: s/192.168.192.197/192.168.192.127/g

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK