问题背景

在上一篇文章《使用 RawSocket 做 TCP 端口探活》中,介绍了一种使用 raw socket 来实现服务健康检查的方法。据此开发的集群健康检查工具 nurse 在我们的系统上兢兢业业工作半年多以后,突然变得不稳定了。起初以为是网络不稳定,一直没管,但问题一直持续,看来并非是网络问题,抽空追查了一下,因为网络编程相关知识不够熟悉,一直以为是 raw socket 会丢包(我并不是唯一遇到这个问题的人),花了一下午,绕了一个大圈,最终才确认是 SO_RCVBUF 的问题,现将追查过程记录在此,避免遗忘 。

单独提一下 nurse 的大致工作情况,方便了解接下来的问题:

  1. 加载探测目标的文件,初始化发送探测包的线程池,初始化接收回包的 raw socket,初始化 epoll event loop
  2. 开始探测循环:

    1. 初始化探测结果集合
    2. 遍历探测目标,使用线程池并行发送 tcp syn 包进行半连接探测
    3. event loop 收到 epollin 的事件后,使用 recvfrom 从 raw socket 读取回包,记录探测结果
    4. 遍历完之后汇总结果,使用通知线程发送消息(如果有的话),继续下一次探测循环

追查过程

起初探测目标一直在两三百个左右,工作正常没有问题。一次系统扩容后,探测目标增加到了480个,由于探测配置和扩容是自动同步的,刚扩完就发现健康检查报告不稳定,断断续续的报一批服务失活和恢复,问题持续了一段时间,觉得不太正常便开始追查。

一开始怀疑是网络问题,因为判断失活就是因为没有收到 ack 回包,于是使用 tcpdump 抓包看了一下,tcpdump 的条件和生成 lsf code的条件相同tcpdump -i eth0 'tcp and tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and tcp[8:4] = 888889',因为seq 序列号是自己指定的,通过这个能准确抓到回包,结果如下图,回包确实有480个,而且很稳定,直接排除了网络问题。

A627F297-49CD-40D8-B050-B35E7791C365.png

期间也重新回顾了 tcp 三次握手以及半连接相关的概念,其中阿里中间件团队博客上的《关于 TCP 半连接和全连接队列》这篇文章介绍的比较详细,感兴趣可以去看一下。

既然回包没有问题,那问题应该还是在处理的代码上了,加上了一些调试日志,显示虽然发送了480个探测包出去,但是 epoll 只响应了310多个回包的事件,还有100多个响应丢了。这就奇怪了,按理这个数量也不算多大的压力,为何 raw socket会丢包?

回顾相关的代码,整理了一下工作逻辑:tcpdump 命令确认了收包480个,排除了探测发包部分,问题是出在 epoll 事件循环里面;而事件循环相对逻辑简单,就是响应 epollin 事件,recv 收包,然后拆包获取源地址和端口,判断存活状态。tcpdump 结果看回包没问题,拆包有问题的可能性也很小,要不也不会这时候出错。于是排查的点集中在了 epoll 事件循环和 recv 这个部分。

首先看 recv 这部分的代码:

std::string capture(int recv_fd, const struct sockaddr_in &lo) {
    char recv_buf[RECV_BUF_SIZE];
    memset(recv_buf, 0, RECV_BUF_SIZE);

    struct sockaddr saddr;
    socklen_t saddr_size = sizeof(saddr);
    if (recvfrom(recv_fd, recv_buf, RECV_BUF_SIZE, 0, &saddr, &saddr_size) < 0) {
        fprintf(stderr, "WARNING: Revf from socket %d failed\n", recv_fd);
        return "";
    }

    // 忽略以下拆包的代码
    // ...
}

只有 recvfrom 这个操作了,难道是这儿的问题?前文中也提到了,因为 raw socket 的性质,回包都是通过同一个 raw socket 接收的,会不会是同时有多个 packet 到达,recv 会合并读取这些个包,而解析时候只当作一个解析,所以丢失结果了?仔细阅读了 recvfrom 的文档,google 了很多相关资料,排除了这种可能性。Stackoverflow 上的这个问题和我的疑虑类似,回答中也明确说了,不会合并,不同的数据包同时到达时仍然是不同的数据包,需要多次调用 recvfrom 来获取数据。

其实看到这个问题和里面的回答,如果对网络编程熟悉一些,应该可以找到问题了,但正式因为不熟悉,所以没有意识到里面提到的 buffer 是指的排队的 buferr,错误理解成代码中的 recv_buf,错失了找到问题的一次机会。

recv 看起来没有问题,难道是 epoll 的事件循环有问题?毕竟打日志调试的时候发现,确实少了100多个事件。相关的主要代码如下:

    // 在限定时间范围内接收返回结果,对于收到结果的 target,判断是否恢复
    while (true) {
        int event_cnt = epoll_wait(epoll_fd, &event, 1, 150);
        if (event_cnt < 0) {
            fprintf(stderr, "ERROR: Epoll failed with errno: %d\n", errno);
            exit(3);
        }
        for (int i = 0; i < event_cnt; ++i) {
            std::string str_host = capture(event.data.fd, local);
            if (str_host.empty()) continue; 

            if (health_states[str_host].st_change_on_success()) {
                std::string content = std::string("服务: ") + detect_flag[str_host] + "  地址: " + str_host + "\n";
                recover_hosts.emplace_back(content);
            }

            detect_flag.erase(str_host);
        }
        if (get_cur_ms() - start_ms >= 900) break;
    }

这一块儿,调试的时候也有一些疑问,就是每次 epoll 都只返回1个事件,然后几百次就要几百个循环去做系统调用,肯定会慢一些。慢的话,是否有可能处理的晚的包就超时被丢掉了?

先看 epoll 每次都返回1个事件的问题。epoll_wait的第2、3个参数返回的事件结构体数组以及最多返回事件的数量,由于之前理解不透彻,分别填的是单个 event 结构体的地址和1,改成数组和10之后测试,返回的时间仍然是1,疑惑半天,StackOverflow 上的这个问题给出了回答:epoll_wait返回的事件数量指的是有 I/O 活动的文件描述符的数量,而由于我们只监听了一个 raw socket,所以不管达到多少个事件,每次返回数量总是1。既然如此,那我们就改写一下代码,对于每次 epollin 的事件,我们就循环读,直到没有数据为止,这样也可以减少 epoll_wait的系统调用,改动如下:

    for (int i = 0; i < event_cnt; ++i) {
        while (true) {
            // capture中的 recv_from,需要加上 MSG_DONTWAIT 选项变为非阻塞
            // 读不到数据的时候就会立即退出,返回空
            // 然后这里遇到空则退出循环
            std::string str_host = capture(event.data.fd, local);
            if (str_host.empty()) break;  

            if (health_states[str_host].st_change_on_success()) {
                std::string content = std::string("服务: ") + detect_flag[str_host] + "  地址: " + str_host + "\n";
                recover_hosts.emplace_back(content);
            }

            detect_flag.erase(str_host);
        }
    }

当然也看了不少 ET 和 LT 的对比,在这里没有明显区别,LT 处理相对简单,就不拓展问题了。改完之后,发现处理的事件仍然还是少,问题依旧存在。

不过在这个过程中,阅读的大量资料,让我对这个模型的轮廓逐渐熟悉,大体猜测到,问题可能真的是因为回包被丢掉了,而丢掉的原因,很有可能是因为处理的慢,而排队长度又有限,导致有一部分包直接被丢弃了。正如 tcp dump结果里写的那样,*** packets droped by kernel。解决方法,要么是处理的足够快,要么就是加大队列长度(最简单的做法,by redhat documentation - SOCKET QUEUE)。而这里的队列,就是SO_RCVBUF指定的了,所以,修改这个值,问题应该就能解决,创建 raw socket 的代码修改如下:

    // Because using lsf we need to create an ETH_PACKET capture socket
    int recv_socket = 0;
    if ((recv_socket = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) < 0) {
        fprintf(stderr, "ERROR: Create recv socket failed\n");
        return -1;
    }

    // Set large SO_RCVBUF, need to change /proc/sys/net/core/rmem_max
    unsigned int optVal = 624640;
    unsigned int optLen = sizeof(optVal);
    if (setsockopt(recv_socket, SOL_SOCKET, SO_RCVBUF, &optVal, optLen) < 0) {
        fprintf(stderr, "ERROR: Cant't set recv buf for recv socket\n");
        return -1;
    }
    optVal = 0;
    getsockopt(recv_socket, SOL_SOCKET, SO_RCVBUF, &optVal, &optLen);
    fprintf(stderr, "DEBUG: initial socket receive buf %d\n", optVal);

但是,需要注意的是,SO_RCVBUF的取值还受到系统上限的限制,在 linux 手册中有介绍,最大值上限由/proc/sys/net/core/rmem_max决定,最终取值是max(代码中设定的值,2*rmem_max取值),也就是最大也只能是上限的两倍,如果仍然不够,就需要修改/proc/sys/net/core/rmem_max的取值

7C7A7150-EB3F-4A7D-A0D9-42146A7D3EBA.png

修改完buffer 限制,一切恢复正常,问题解决。果然只看书是没用的,需要在实践中去理解消化。