扩展Linux服务:在接受连接之前

2020-07-04 09:38:52

更多帖子在编写接受TCP连接的服务时,我们倾向于认为我们的工作是从服务接受新的客户端连接开始,到完成请求并关闭套接字时结束。对于大规模的服务,操作可能会以如此高的速度发生,以至于Linux内核的一些默认资源限制可能会打破这种抽象,并开始对该连接生命周期之外的传入连接造成影响。本文关注的是在将客户端套接字交给应用程序之前存在的一些标准资源限制-所有这些限制都是在我作为GitHub的一部分调查生产系统上的错误的过程中出现的(在某些情况下,在不同的应用程序中多次出现)。

在其最基本的形式(忽略非阻塞变体)中,侦听TCP连接需要调用Listen()以实际开始允许传入连接,然后重复调用Accept()以获取下一个挂起的连接并返回用于该特定客户端的文件描述符。在C中,此模式类似于:

INT SERVER_FD=SOCKET(AF_INET,SOCK_STREAM,0);BIND(SERVER_FD,/*.*/);//开始监听连接侦听(SERVER_FD,512);同时(运行){//阻塞直到连接到达,然后接受INT CLIENT_FD=Accept(SERVER_FD,NULL,NULL);//.。处理client_fd.}Close(Server_Fd);

这通常隐藏在更深的抽象层后面,我们倾向于隐藏接受连接的所有实现细节,并将其视为我们拾取然后并行处理的新连接流。但是,当构建以一定规模运行的系统时,这种抽象往往会失效,因为在建立连接但Accept()尚未返回的期间引入了资源限制。在此之前,这些客户端连接被视为服务器/侦听套接字的一部分,而不是公开给应用程序的独立资源。

这篇博客文章中提到的示例将在实验室中重现,可以从theojulienne/blog-lab-scaling-Accept-clone这个存储库启动实验室,然后在真实系统中查看这些示例:

Linux内核维护两个连接队列,用于维护应用程序尚未接受()的积压连接:

当接收到SYN分组以发起到侦听套接字的新连接时,SYN-ACK被发送到客户端,并且半完成的连接状态被存储在“SYN Backlog”或“Request Socket Queue”中。这表示尚未完全验证为主机之间具有双向通信的连接,因为我们尚未验证远程终端是否已收到来自我们的数据包(SYN可能来自另一台欺骗源IP的主机)。

一旦客户端使用ACK响应服务器的SYN-ACK,连接就完成了完整的TCP 3次握手,服务器知道已经建立了双向通信。此时,客户端连接已准备好在将来调用Accept()时提供给应用程序,并被添加到适当命名的“Accept Queue”中。

SYN Backlog中的连接相对于服务器和客户端之间的往返时间保留一段时间。如果Backlog中有N个插槽,则每个平均RTT在此Backlog中最多可以有N个连接,之后Backlog会溢出。

在Linux上,这实际上不会导致缺省连接失败,而是会导致发送SYN cookie。这是因为当仅收到SYN数据包时,服务器尚未验证客户端是否如其所说的那样,因此它们可能会欺骗来自不同IP的数据包。这是一种常见的拒绝服务攻击,称为SYN泛洪。因为这种情况非常常见,所以当SYN backlog中没有空间容纳新连接时,Linux内核通过发送SYN cookie来缓解SYN洪流。

这意味着即使在没有DoS攻击的正常情况下SYN backlog溢出,内核也会允许连接在握手过程中继续前进,并且在ACK完成握手之前不会为连接存储任何资源。但是,在正常情况下发送SYN Cookie确实表明连接速率可能太高,超出了默认限制-SYN Cookie实际上仅用于缓解SYN泛洪。

是否启用SYN cookie是通过sysctl net.ipv4.tcp_syncookies控制的,默认情况下设置为1,表示在SYN Backlog溢出时应发送SYN cookie,但也可以设置为0以完全禁用或设置为2以强制100%发送SYN cookie。

当根据需要启用SYN cookie时(默认设置),当SYN backlog中的挂起连接数超过为套接字配置的接受队列backlog大小时,就会触发SYN cookie-这里的逻辑是这样的。完全禁用SYN cookie时,net.ipv4.tcp_max_syn_backlog将单独配置SYN backlog中允许的连接数-本例的逻辑如下。

在Backlog溢出和发送SYN Cookie的默认配置中,内核递增TCPReqQFullDoCookies计数器并将此行记录到内核日志中,即使只是因为合法连接进入得太快,内核日志通常也会将其混淆为实际SYN泛滥的指示器:

如果明确禁用SYN cookie,则内核会递增TCPReqQFullDrop计数器,并改为记录以下内容:

如果您在内部服务到服务连接中看到此消息,则很可能您正在处理的是伸缩性问题或雷鸣般的羊群问题,而不是实际的SYN洪流或故意的不良行为。

内核提到的那些“SNMP计数器”在网络名称空间的nstat中可用:

要查看SYN Backlog在实验中的行为,我们可以使用以下内容模拟本地连接上的延迟:

VRANGRANT@BLOG-LAB-Scaling-Accept:~$sudo/vagrant/reset-lab.shvagrant@blog-lab-scaling-accept:~$sudo TC qdisk add dev lo root netem delay 200msvolant@Blog-lab-scaling-Accept:~$ping 127.0.0.1 PING 127.0.0.1(127.0.0.1)56(84)字节数据。127.0.0.1中的64字节:icmp_seq=1ttl=64 time=127.0.0.1中的401 ms64字节:icmp_seq=2 ttl=64 time=。

这意味着我们现在可以模拟SYN backlog溢出。要使其更容易重现,请通过减少net.core.Somaxconn(因为启用了SYN cookie)将SYN backlog的大小从默认值128减少:

vovant@blog-lab-scaling-Accept:~$sudo sysctl net.core.SomaxConnnet.core.Somaxconn=128van ant@blog-lab-scaling-Accept:~$sudo sysctl-w net.core.Somaxconn=10net.core.Somaxconn=10volant@blog-lab-scaling-Accept:~$sudo systemctl start nginxvaant@blog-lab-scaling-Accept:~$$sudo sysctl reart nginxvtl@blog-lab-scaling-Accept:~$。

此时,如果超过10个连接在400ms内到达(在SYN-ACK和ACK握手完成之前),则SYN Backlog中将有10个连接,这是配置的最大值。这将触发要发送的SYN cookie,从而触发上面的消息和计数器。让我们测试一下这是否有效,运行一个模拟来打开N个并发连接:

Vagant@Blog-lab-scaling-Accept:~$sudo dmesg-c[2571.784749]tcp:REQUEST_SOCK_tcp:端口80上可能存在SYN泛洪。发送饼干。检查SNMP计数器。Vagant@blog-lab-scaling-Accept:~$sudo nstat|grep ReqQTcpExtTCPReqQFullDoCookies 10 0.0volant@blog-lab-scaling-Accept:~$。

尝试使用sudo tc qdisk del dev lo root禁用模拟延迟,然后重新运行相同的测试,现在连接运行得足够快,不会发送SYN cookie。

这还演示了往返时间影响可以突入侦听套接字的连接量的方式-test_send_current_connections.py实际上会尝试启动完整的连接,并且另一端的nginx会以最快的速度随时调用Accept(),但是因为一次启动20个连接,所以在任何握手可以继续之前就有20个SYN数据包到达,SYN Backlog溢出。您可以想象,在现实世界中,由于其中一些值通常缺省为128,并且用户经常远离服务器(在世界的另一端),在没有真正的SYN泛滥的情况下很容易意外触发此场景。

一旦收到并验证了ACK数据包,应用程序就可以处理新的客户端连接了。此连接被移入接受队列,等待应用程序调用Accept()并接收它。

与SYN Backlog不同,Accept队列没有关于何时溢出的备份计划。回到最初调用listen()时,提供了一个backlog参数,它指示有多少连接可能已经完成了3次握手并在内核中等待应用程序接受。

这是第一个常见问题:如果提供给listen()的待办事项数量不足以包含任何数量的连接,这些连接可以在2个Accept()调用之间合理地完成握手,那么连接将被丢弃,应用程序通常甚至不会注意到-Accept()的下一次调用将成功,而不会有任何丢失连接的迹象!当在Accept()调用之间可能会发生一些合理的工作量时,或者当传入连接往往同时到达时(例如,使用cron在多个服务器上运行的作业,或者大量的重新连接尝试),就特别可能发生这种情况。

但是,即使您指定了足够高的backlog值来侦听(),还有另一个位置会默默地限制此值。Somaxconn sysctl为任何套接字的积压指定了网络系统范围的最大值。当提供了更大的backlog值时,内核会默默地将其限制为net.core.Somaxconn的值。这是下一个最常见的问题:net.core.Somaxconn和listen()的backlog参数都需要适当调整,以便实际调整backlog。

更复杂的是,net.core.Somaxconn sysctl不是系统全局的,而是Linux网络名称空间的全局的。对于新的网络名称空间(如大多数启动的Docker容器使用的名称空间),默认网络名称空间的值不会继承,而是设置为内置的内核默认值:

VRAGRANT@BLOG-LAB-Scaling-Accept:~$sudo/vagrant/reset-lab.shvagrant@blog-lab-scaling-accept:~$sudo sysctl net.core.SomaxConnnet.core.Somaxconn=128van ant@Blog-Lab-Scaling-Accept:~$sudo docker run-it ubuntu sysctl net.core.core.Somaxconn=128volant@Blog-lab-scaling-Accept:~$sudo sysctl-w net.core.Somaxconn=128volant@Blog-lab-scaling-Accept:~$sudo sysctl-w net.core.Somaxconn=。.Somaxconn=128van ant@blog-lab-scaling-Accept:~$。

这就是第三个常见问题:在具有自己的网络名称空间的容器中运行(其中大多数名称空间是由Kubernetes/Docker启动的),即使系统正确调整了net.core.Somaxconn,该值也会被忽略,因此容器还必须调整net.core.Somaxconn以匹配在其中运行的应用程序。

如果这些情况中的任何一种发生并造成影响,则它们在ListenOverflow计数器中可见:

要查看Accept队列在实验中的行为,我们可以运行一个不善于接受连接的服务器(它休眠很多),积压为10:

VRAGRANT@BLOG-LAB-Scaling-Accept:~$sudo/vagrant/reset-lab.shvagrant@blog-lab-scaling-accept:~$sudo sysctl-wnet.core.Somaxconn=1024net.core.Somaxconn=1024volant@Blog-lab-scaling-Accept:~$python/vavant/laggy_server.py 10收听待办事项10。

在另一个窗口中,将一系列连接发送到滞后服务器,然后在几秒钟后退出:

vovant@blog-lab-scaling-Accept:~$python/vavant/test_send_current_connections.py 127.0.0.1:8080 20正在等待,按Ctrl+C退出。(请稍等几秒钟)^cvolant@blog-lab-scaling-Accept:~$sudo nstat|grep ListenOverflowsTcpExtListenOverflow 44 0.0van ant@blog-lab-scaling-Accept:~$。

此时,我们可以看到listen()的backlog参数有多重要。接下来,让我们回顾一下Somaxconn是如何通过设置一个具有更长积压的服务器来实现这一点的,我们预计该服务器会被封顶:

Vagant@Blog-Lab-Scaling-Accept:~$sudo/vagrant/reset-lab.shvagrant@blog-lab-scaling-accept:~$sudo sysctl-wnet.core.Somaxconn=10net.core.Somaxconn=10volant@Blog-lab-scaling-Accept:~$python/vavant/laggy_server.py 1024收听待办事项1024。

一切看起来都很好-请注意我们是如何有效地调用LISTEN(1024)的,并且没有出错。运行与我们提供较小backlog值时相同的命令,当内核静默截断backlog时,我们可以观察到相同的问题:

vovant@blog-lab-scaling-Accept:~$python/volant/test_send_current_connections.py 127.0.0.1:8080 20正在等待,按Ctrl+C退出。(请稍等几秒钟)^cvolant@blog-lab-scaling-Accept:~$sudo nstat|grep ListenOTcpExtListenOverflow 77 0.0van ant@blog-lab-scaling-Accept:~$。

TcpExtTCPReqQFullDoCookies-检测SYN cookie用于缓解SYN积压空间不足的位置。

TcpExtTCPReqQFullDrop-检测由于SYN cookie被禁用且SYN Backlog已满而丢弃SYN的位置。

TcpExtListenOverflow-检测TCP连接何时完成3次握手但接受队列已满,或者何时在接受队列已满时收到SYN。

因为这些计数器都是网络命名空间的一部分,并且在命名空间中是全局的,所以有关哪个套接字或应用程序导致问题的信息将不可见,如果进程位于容器中,则在基本系统计数器(在默认网络命名空间中)中将不可见。正因为如此,每个网络名称空间都需要检查/监视这些计数器,然后需要做额外的工作来追溯到应用程序,在SYN backlog变体的情况下可能会使用内核日志行作为提示。

上面计数器提供的度量提供了积压溢出和丢失连接的最小情况,但理想情况下,我们能够在系统范围内检查这种情况,查看任何网络名称空间,并能够将其链接回应用程序,甚至套接字/端口。

这可以使用使用BCC的kProbe和eBPF跟踪,方法是挂钩处理这些故障情况的内核函数并检查该时间点的上下文。这允许我们从生产中的系统中提取实时数据,而不像计数器那样充其量是系统上某个地方存在的潜在问题的模糊指示器。

我们知道,只要Accept Backlog溢出,ListenOverflow计数器就会递增-我们可以从那里开始,然后重新构建跟踪程序。

在Linux源代码中搜索ListenOverflow会显示此计数器在内部称为什么-LINUX_MIB_LISTENOVERFLOWS。搜索内核源代码树会显示内核中递增该计数器的所有位置-在本例中,最佳候选位置是net/ipv4/tcp_input.c中的tcp_conn_request和net/ipv4/tcp_ipv4.c中的tcp_v4_syn_recv_sock。它们在连接生命周期中处理的点略有不同:

tcp_conn_request处理针对侦听套接字启动新连接的SYN数据包,并将其放入SYN Backlog中。

tcp_v4_syn_recv_sock处理完成连接的ACK数据包并将其添加到接受队列。

作为一种安全机制,如果接受队列已满,则tcp_conn_request会丢弃连接,即使它还没有添加到队列中。如果接受队列已满,tcp_v4_syn_recv_sock也会丢弃连接,这是正确的,因为它会添加到队列中。如果SYN数据包已被接受并添加到SYN Backlog中,而接受队列有可用空间,但在ACK到达时已满,则在收到ACK时将在tcp_v4_syn_recv_sock中进行丢弃。如果在接受队列已满时新SYN到达,则TCP_CONN_REQUEST将改为丢弃。对于VM中的本地测试,TCP_CONN_REQUEST更容易测试,因为它不需要在SYN和ACK之间仔细计时即可重现。

在每个函数中递增ListenOverflow计数器的代码路径如下所示:

int TCP_CONN_REQUEST(struct request_sock_ops*rsk_ops,const struct tcp_request_sock_ops*af_ops,struct sock*sk,struct SK_buff*SKB){/*.*/if(SK_ACCEPTQ_IS_FULL(SK)){NET_INC_STATS(SOCK_NET(SK),Linux_MIB_LISTENOVERFLOWS);GOTO DROP;}/*.*/。}struct sock*tcp_v4_syn_recv_sock(const struct sock*sk,struct SK_buff*skb,struct request_sock*req,struct dst_entry*dst,struct request_sock*req_unhash,bool*own_req){/*.*/if(sk_ceptq_is_full(Sk))go to exit_overflow;/*.*/EXIT_OVERFLOW:NET_INC_STATS(SOCK_NET(SK),Linux_MIB_LISTENOVERFLOWS);/*.*/}。

当跟踪像这样内联的函数时,挂接条件的最简单方法是附加到调用函数(在本例中是tcp_conn_request和tcp_v4_syn_recv_sock)的开头,然后在我们的eBPF代码中执行相同的条件检查。我们可以使用kProbe来实现这一点:

/*通用处理程序*/静态内联void handle_sk_Potential_overflow(struct pt_regs*ctx,int syn_recv,const struct sock*sk){/*需要使用bpf_probe_read读取这些内容以确保读取安全*/u32 sk_ack_backlog=0;u32 sk_max_ack_backlog=0;bpf_probe_read(&;sk_ack_backlog,sizeof(Sk_Ack_Backlog))。sk_ack_backlog);bpf_probe_read(&;sk_max_ack_backlog,sizeof(Sk_Max_Ack_Backlog),(void*)&;sk->;sk_max_ack_backlog);if(sk_ack_backlog>;sk_max_ack_backlog){/*处理条件*/}}/*当SYN到达时*/struct tcp_request_sock_ops;void kprobe__tcp_conn_request(struct pt_regs*ctx,struct request_sock_ops*rsk_ops,const struct tcp_request_sock_ops*af_ops,const struct sock*sk,struct SK_buff*skb){handt_sk_Potential_overflow(ctx,0,sk);}/*当SYN_RECV套接字的ACK到达时*/void kprobe__tcp_v4_syn_recv_sock(struct pt_regs*ctx,const struct sock*sk)/*不需要剩余的未使用参数*/{Handle_SK_Potential_overflow(ctx,1,sk);}

在这一点上,我们在相同的条件下有一个钩子,它将触发递增IPv4上的TCP中的ListenOverflow计数器。可以对其进行充实,以读取有关套接字的详细信息,并将其返回给用户空间跟踪程序。

实验室中包含一个这样的程序,让我们重新启动我们的滞后服务器:

结果将是显示积压溢出配置最大值的位置的跟踪,以及进程的详细信息以及它是在SYN期间还是在ACK期间发生的:

Vagant@Blog-Lab-Scaling-Accept:~$sudo python/vagrant/trace_backlog_overflow.pyCONTAINER/HOST PID Process NETNSID BIND PKT BL MAXblog-Lab-Scaling-acc 14742 python/vranant/lagg 4026531992 127.0.0.1:8080SYN 11 10blog-lab-scaling-acc 14742 python/vavant/lagg 4026531992 127.0.0.1:8080SYN 11 10blog-lab-scaling-acc 14742 python/vavant/lagg 4026531992 127.0.0.1:8080SYN 11。127.0.0.1:8080SYN 11 10博客-实验室-缩放-Acc 14742 Python/流浪/LAG 4026531992 127.0.0.1:8080SYN 11 10博客-实验室缩放-Acc 14742 Python/流浪/LAG 4026531992 127.0.0.1:8080SYN 11 10博客-实验室-缩放-Acc 14742 Python/流浪/LAG 4026531992 127.0.0.1:8080 SYN 11 10BLO。

..