getaddrinfo引发的血案

2016年3月24号本是平凡的一天,却因线上服务访问不均衡而无法撸管。

1) 起因

事情是这样开始的,屏幕上的 QQ 闪动着运维大侠发来的未读消息。对于这种情况,因为大概知道是线上有什么风吹草动。自然反射的虎躯一震,菊花一紧, 熟练的松开了放在重要部位的右手,无奈的关闭了正在播放的岛国动作大片,这可能是每个程序员都会遇到的最无奈的时刻。

快速点开运维大侠发来的图片,发现线上四个 Redis 从库,访问量很不均匀啊。第一个实例的访问量基本是其他实例的两倍以上,如图。

image

因为明确知道线上 php 访问使用的是长连接,所以第一反应就是是不是连接数不均衡导致? 然后让运维大侠统计了一下四个实例的连接数,发现确实请求量跟连接数是成线性正相关的。

2) 连接数为何不均匀?

因为这四个实例是通过域名来访问,理论上连接数应该是要比较均匀(DNS ip 轮循)。 接着当然是统计多出来的连接数是来自哪些 ip。使用 lsof 或者 redis 的 client list 都可以得到。

为什么要统计 ip 呢

  1. 确定是不是有业务有固定 ip 直连导致
  2. 确定是不是所有业务机器连接资源不均匀还是个别机器

统计完发现,第一个实例比其他实例多出来的 ip, 是来自部分业务机器。

192.168.7.213
192.168.7.218
192.168.7.217
192.168.7.216

而且发现这几个 ip 只连第一个实例。 这说明连接数不均匀是这几个 ip 固定连接第一个实例导致的。

后面就围绕 DNS 和 php 连接机制展开排查

3) 排查

3.1) DNS 返回问题?

第一件事情先排除是不是 DNS 返回结果不正确导致。使用 nslookup 查看,发现结果列表每次都会进行 ip 轮循,所以排除 DNS 返回不正确导致。那么就回到是不是 php 使用姿势的问题。

3.2) gethostbyname 导致的?

既然 DNS 返回结果没有问题,那么很可能就是 php 内部通过域名转换 ip 时固定返回某一个 ip。因为没有去看 phpredis 的实现,所以先假设是通过 gethostbyname 来获取ip, 这个很容易验证。 通过执行下面代码,发现结果是会进行 ip 轮循。

php -r 'var_dump(gethostbyname(""s2000.redis.com.cn.xxx"))'

到这里因为 gethostbyname 没有问题,那么就猜测是不是使用其他的方式获取ip?

因为业务方在 phpredis 连接资源时,是直接使用域名。而 phpredis 会使用 php 源码的 php_stream_xport_create 进行建立连接。而这个函数在建立连接,是通过 php_network_getaddresses 来获取到 ip 列表, 然后拿到列表的第一个进行连接。

这个跟观察到的只会连接到同一个ip的规律是一致的,所以我们只要来看他是通过什么方法获取 ip 列表。

3.3) getaddrinfo 的锅?

我们看到 php_network_getaddresses 实现是这样的(见php-src/main/network.c):

 PHPAPI int php_network_getaddresses(const char *host, int socktype, struct sockaddr ***sal, char **error_string TSRMLS_DC)
 ...
  #if HAVE_GETADDRINFO
    if ((n = getaddrinfo(host, NULL, &hints, &res))) {  
        ...
    }
  #else
    host_info = gethostbyname(host); 
    ...
  #endif

使用 HAVE_GETADDRINFO 来决定使用 getaddrinfo 还是 gethostbyname 来获取ip, 这个选项是在 configure 阶段,通过判断操作系统是否支持 getaddrinfo 来自动开启。因为我们线上机器都支持这个函数,而我们也验证过 gethostbyname 返回结果是正常的。

到了这里就是 getaddrinfo 背上了这个锅

这个验证也很简单,直接调用 getaddrinfo 看返回结果, 代码如下:

    n = getaddrinfo("s2000.zw.rediscounter.m.com", NULL, &hints, &res);
    if (!res) return;

    do {
        if (res->ai_family == AF_INET)  {
            sinp = (struct sockaddr_in *)res->ai_addr;
            addr = inet_ntop(AF_INET, &(sinp->sin_addr), abuf,INET_ADDRSTRLEN);
            printf("%s\n", addr);
        }
    } while((res = res->ai_next) != NULL);

然后拿到有问题的机器跑了一下,果然取到的 ip 列表是固定的,然而在那批没问题的机器上拿到 ip 列表是会进行轮循。所以已经确定是 getaddrinfo 函数导致。


4) 尝试解决

4.1) glibc 版本问题?

现在的问题就是为什么同一函数在不同机器上面表现不一样,所以先检查内核版本和 glibc 版本是否一致。发现有问题的这批机器 glibc 版本比较低,所以有理由怀疑是否为老版本的 bug (后面万万没想到是否 feature..)。 所以就打算先升级一台有问题的机器来看看,然后再来仔细对比实现。

不幸的是升级后发现问题仍然存在。

image

4.2) rfc3484_sort 惹的祸

实在没办法只能去翻 glibc 的代码, 发现这个函数确实是会对返回的 ip 列表进行排序,具体实现见 glibc-2.20/sysdeps/posix/getaddrinfo.c

qsort_r (order, nresults, sizeof (order[0]), rfc3484_sort, &src);

排序算使用的是 rfc3484_sort, 内部比较方法应该是根据 rfc3484 的特定规则做比较,直接看会浪费时间,直接搜索找到对应 rfc 说明( http://www.ietf.org/rfc/rfc3484.txt)。

这个函数是为了解决 ipv6 多播地址绑定到单网卡的问题,函数会从 rules 1-10 逐条比较。rules 10 是保持 DNS 返回顺序,也就是我们希望的。 篇幅问题这里不在细说规则,返回结果因为算法固定,所以顺序也不会改变。

5) 真正解决

  1. 业务连接资源使用 gethostbyname 获取ip, 而不是使用 php 源码实现的规则。
  2. 关闭 getaddrinfo 排序算法

因为第一种方案需要业务方修改代码(尽管只有一两行), 所以我们选择第二种方案。因为从代码层面很难看出来哪个参数可以关闭。后面搜索找到相应的讨论(问题缩小就能快速找到解决方法),快速的解决方法是关闭 ipv6, 当然前提是线上服务不能有使用到这个特性。

https://groups.google.com/forum/#!topic/consul-tool/AGgPjrrkw3g

到这里就回想到,为什么部分机器有问题而一部分没有问题,立即对比 ipv6 的配置。发现有问题的机器没有关闭 ipv6,而没有问题的机器是关闭的。通过跟运维也了解到,线上应该默认会关闭 ipv6, 而这批老机器漏了关闭。

6) 验证

直接关闭这批机器的 ipv6 配置:

root$ echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6

观察这批机器的 redis 资源连接,开始有连到其他 redis 资源。因为线上 php-fpm 使用长连接,所以需要等到进程重启才会重新连接,所以需要等一段时间才会完全均匀。

过了一会,多个 Redis 访问量和连接数终于恢复均匀,问题初步解决。

image

7)END

现在虽然通过关闭 ipv6 解决这个问题,但后续可能也会使用到 ipv6 而再次踩坑。建议后续 php 连接资源采用 gethostbyname 手动获取ip。

另外还有一个细节还没完全深入的是 ipv6 选项如何影响 rfc3483_sort 算法的,需要进一步跟进,彻底搞定。