暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

牛刀小试-SYSTEMTAP解析TCP REUSEPORT问题

服务金领的IT民工 2021-02-23
989

Linux kernel 3.9带来了SO_REUSEPORT特性。这个新增选项允许多个socket监听在同个IP地址和端口的组合,由内核负载均衡这些连接进来的socket。那带来什么好处呢?

众所周知,在引入该特性之前,服务器模型通常如下图:


首先需要单线程listen()在一个端口上,然后由多个工作进程/线程去accept()在同一个服务器socket上。显而易见,单线程listener,在处理高速率海量连接时,会成为性能瓶颈;多线程访问同一个socket也可能会产生锁竞争。

有了SO_REUSEPORT特性,可以在每个工作线程/进程建立一个socket,同时监听在同个IP和端口的组合。当连接进来时,由内核决定分配给哪个socket,也就是哪个线程/进程。由于每个线程/进程拥有独立的socket,这可以降低锁竞争,在多核系统上提高性能。模型参见下图:

使用SO_REUSEPORT特性可以简化TCP服务器的线程模型,使得架构简单、性能较高。在应用过程中,发现在某台机器上,频繁连接TCP服务器容易出现连接失败,而连接数却远未达到上限值。分析日志,没有错误出现,说明连接事件还没到应用层就出错了。应该是三次握手没有完成,通过服务端抓包发现收到ACK后马上就发出RST,为什么呢?猜测是SO_REUSEPORT特性的hash函数不稳定,但是如何证明呢?

首先需要确认发出RST包的原因。

我们知道,TCP是通过函数tcp_v4_send_reset发出RST的,搜索源码,最可能出错的函数是tcp_v4_do_rcv,一共有3处可能触发,第一处TCP_ESTABLISHED状态的可以排除,剩下的两处tcp_child_process和tcp_rcv_state_process如果返回非零值都有可能,那是哪一个出错了呢?查看源码可知,tcp_child_process也是调用tcp_rcv_state_process处理,所以只需要关注tcp_rcv_state_process出错的情况即可。熟悉协议栈的应该清楚,tcp_rcv_state_process就是个状态机,几乎处理所有的接收状态转换,相当复杂。最好能抓到入参来证实一下,我们等下一起处理它。

哪里负责把包分配给具体的sock对象的呢?tcp_v4_rcv函数中的__inet_lookup_skb。对于三次握手没完成时的分配,由下图的函数处理。

struct sock *__inet_lookup_listener(struct net *net,
                    struct inet_hashinfo *hashinfo,
                    const __be32 saddr, __be16 sport,
                    const __be32 daddrconst unsigned short hnum,
                    const int dif)
{

    struct sock *sk, *result;
    struct hlist_nulls_node *node;
    unsigned int hash = inet_lhashfn(net, hnum);
    struct inet_listen_hashbucket *ilb = &hashinfo->listening_hash[hash];
    int score, hiscore, matches = 0, reuseport = 0;
    u32 phash = 0;

    rcu_read_lock();
begin:
    result = NULL;
    hiscore = 0;
    sk_nulls_for_each_rcu(sk, node, &ilb->head) {
        score = compute_score(sk, net, hnum, daddr, dif);
        if (score > hiscore) {
            result = sk;
            hiscore = score;
            reuseport = sk->sk_reuseport;
            if (reuseport) {
                phash = inet_ehashfn(net, daddr, hnum,
                             saddr, sport);
                matches = 1;
            }
        } else if (score == hiscore && reuseport) {
            matches++;
            if (((u64)phash * matches) >> 32 == 0)
                result = sk;
            phash = next_pseudo_random32(phash);
        }
    }
    /*
     * if the nulls value we got at the end of this lookup is
     * not the expected one, we must restart lookup.
     * We probably met an item that was moved to another chain.
     */

    if (get_nulls_value(node) != hash + LISTENING_NULLS_BASE)
        goto begin;
    if (result) {
        if (unlikely(!atomic_inc_not_zero(&result->sk_refcnt)))
            result = NULL;
        else if (unlikely(compute_score(result, net, hnum, daddr,
                  dif) < hiscore)) {
            sock_put(result);
            goto begin;
        }
    }
    rcu_read_unlock();
    return result;
}

可以看出,这个实现并不高效,需要遍历全部的sock对象,这个效率问题先放到一边。31-32行也比较诡异,只是计算出一个临时结果,而且是用hash值和位置作为参数,一直计算出最后一个满足条件的sock对象。假设这中间sock对象有什么变动,得到的结果肯定不一致。

那如何来证明一下呢?这里就需要祭出内核调试神奇SystemTap了,限于篇幅,这里就不详细介绍了,感兴趣的同学可以自行搜索学习。

编写脚本如下图:

probe kernel.function("tcp_rcv_state_process") {
    printf("%s(%d)%s(socket=%x,sk_state=%x,refcnt=%d,src=%x,dst=%x,seq=%d,ackseq=%d,ack=%x,syn=%x\nth=%s\nsk_cmn=%s)\n\n",
        execname(), tid(), probefunc(), $sk,
        $sk->__sk_common->skc_state, $sk->__sk_common->skc_refcnt->counter,
        $th->source, $th->dest, $th->seq, $th->ack_seq, $th->ack, $th->syn,   $th$$, $sk->__sk_common$$);
}

probe kernel.function("tcp_v4_send_reset") {
    printf("%s(%d)%s\n", execname(), tid(), probefunc());
    print_backtrace();
    printf("\n");
}

probe kernel.function("__inet_lookup_listener") {
    printf("%s(%d)%s(sport=%x,saddr=%x,dport=%x,daddr=%x)\n",
    execname(), tid(), probefunc(),
    $sport, $saddr, $hnum, $daddr);
    print_backtrace();
    printf("\n");
}

probe kernel.statement("__inet_lookup_listener@net/ipv4/inet_hashtables.c:231") {
    printf("%s(%d)%s(src=%x,dst=%x),match=%d,score=%d,phash=%d,sk=%x,ret=%x\n\n",
    execname(), tid(), pp(), $port, $hnum, $matches, $hiscore, $phash, $sk, $result);
}

执行一下,查看结果(以下截图只截取关键部分)。

可以看到第一次收到对端端口41c2的SYN包时hash到的sock对象地址尾6位为738f00。

trade_server(152064)__inet_lookup_listener(sport=41c2,saddr=70d00aa,dport=15b4,daddr=40d00aa)
 0xffffffff8156f900 : __inet_lookup_listener+0x0/0x2f0 [kernel]
 0xffffffff8163f509 : kretprobe_trampoline+0x0/0x57 [kernel]
 0xffff88046f203af0

trade_server(152064)kernel.statement("__inet_lookup_listener@net/ipv4/inet_hashtables.c:207")(src=41c2,dst=15b4),match=2,score=6,phash=1053427458,sk=ffff880257a99e00,ret=ffff880257a9f080
......
trade_server(152064)kernel.statement("__inet_lookup_listener@net/ipv4/inet_hashtables.c:207")(src=41c2,dst=15b4),match=64,score=6,phash=1277123216,sk=ffff88064a73d280,ret=ffff88064a738f00

trade_server(152064)tcp_rcv_state_process(socket=ffff88064a738f00,sk_state=a,refcnt=2,src=41c2,dst=b415,seq=86357923,ackseq=0,ack=0,syn=1
th={.source=16834, .dest=46101, .seq=86357923, .ack_seq=0, .res1=0, .doff=10, .fin=0, .syn=1, .rst=0, .psh=0, .ack=0, .urg=0, .ece=0, .cwr=0, .window=4210, .check=50586, .urg_ptr=0}
sk_cmn={<union>={.skc_addrpair=291890280993390592, <class>={.skc_daddr=0, .skc_rcv_saddr=67961002}}, <union>={.skc_hash=0, .skc_ul6hashes=[0, ...]}, <union>={.skc_portpair=364118016, <class>={.skc_dport=0, .skc_num=5556}}, .skc_family=2, .skc_state='\n', .skc_reuse=1, .skc_reuseport=1, .skc_bound_dev_if=0, <union>={.skc_bind_node={.next=0xffff88064a739698, .pprev=0xffff88064a738798}, .skc_portaddr_node={.next=0xffff88064a739698, .pprev=0xffff88064a738798}}, .skc_prot=0xffffffff81a2ab60, .skc_net=0xffffffff81a25e0)

而第二次收到对端端口41c2的ACK包时hash的sock对象地址尾6位为739680。tcp_rcv_state_process中判断状态时,由于sock处于listen状态,收到ACK状态出错,进而调用tcp_v4_send_reset,关闭了这条连接。

trade_server(152064)__inet_lookup_listener(sport=41c2,saddr=70d00aa,dport=15b4,daddr=40d00aa)
 0xffffffff8156f900 : __inet_lookup_listener+0x0/0x2f0 [kernel]
 0xffffffff8163f509 : kretprobe_trampoline+0x0/0x57 [kernel]
 0xffff88046f203b38

trade_server(152064)kernel.statement("__inet_lookup_listener@net/ipv4/inet_hashtables.c:207")(src=41c2,dst=15b4),match=2,score=6,phash=1053427458,sk=ffff880257a99e00,ret=ffff880257a9f080
......
trade_server(152064)kernel.statement("__inet_lookup_listener@net/ipv4/inet_hashtables.c:207")(src=41c2,dst=15b4),match=63,score=6,phash=2669166773,sk=ffff88064a73d280,ret=ffff88064a739680

trade_server(152064)tcp_rcv_state_process(socket=ffff88064a739680,sk_state=a,refcnt=2,src=41c2,dst=b415,seq=103135139,ackseq=3398180374,ack=1,syn=0
th={.source=16834, .dest=46101, .seq=103135139, .ack_seq=3398180374, .res1=0, .doff=8, .fin=0, .syn=0, .rst=0, .psh=1, .ack=1, .urg=0, .ece=0, .cwr=0, .window=58624, .check=34902, .urg_ptr=0}
sk_cmn={<union>={.skc_addrpair=291890280993390592, <class>={.skc_daddr=0, .skc_rcv_saddr=67961002}}, <union>={.skc_hash=0, .skc_ul6hashes=[0, ...]}, <union>={.skc_portpair=364118016, <class>={.skc_dport=0, .skc_num=5556}}, .skc_family=2, .skc_state='\n', .skc_reuse=1, .skc_reuseport=1, .skc_bound_dev_if=0, <union>={.skc_bind_node={.next=0xffff88064a739e18, .pprev=0xffff88064a738f18}, .skc_portaddr_node={.next=0xffff88064a739e18, .pprev=0xffff88064a738f18}}, .skc_prot=0xffffffff81a2ab60, .skc_net=0xffffffff81a25e0)

trade_server(152064)tcp_v4_send_reset
 0xffffffff8158a1d0 : tcp_v4_send_reset+0x0/0x410 [kernel]

而在这两个包之间,确实插入了其他对端端口的连接请求,把中间的sock对象占用了,引起了sock列表的变化。限于篇幅,这里就不贴图了。至于为什么这台机器出错频率比较高,就暂时不深究了。可能是组成的五元组计算出来的hash正好更容易触发序列的变化吧。

一个好的客户端实现发现连接失败,正常情况下都需要进行重连,可以暂时解决偶然连接失败的问题。

那内核开发者是怎么处理这个问题的呢?由于每次遍历完整的链表太低效,在4.5版本中(该版本只修改了UDP),内核引入了reuseport groups的概念,在找到一个合格的sock后,再查找它所在的组,优化了查找效率。并在4.6版本中引入到TCP。但是,hash仍然不是稳定的,内核并没有提供一个更好的算法,只是支持了BPF,允许从用户态注入自己的逻辑,从而实现基于用户策略的负载均衡。

参考资料:

1)Socket Sharding in NGINX Release 1.9.1

https://www.nginx.com/blog/socket-sharding-nginx-release-1-9-1

2)Linux 4.6内核对TCP REUSEPORT的优化

https://blog.csdn.net/dog250/article/details/51510823


文章转载自服务金领的IT民工,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论