在Cloudflare,我们有很多在“野外”互联网上操作服务器的经验。但我们一直在提高我们对这种“妖术”的掌握。在这篇博客中,我们触及了互联网协议的多个黑暗角落:比如理解FIN-WAIT-2或接收缓冲区调优。
然而,有一个话题没有得到足够的重视——SYN洪水。我们使用Linux系统,事实证明Linux中的SYN包处理非常复杂。在这篇文章中,我们将对这个问题做一些阐述。
有关两个队列的故事
首先,我们必须要了解的是,每个绑定套接字在“监听”TCP状态下有两个独立的队列:
SYN队列
Accept(接受)队列
在文献中,这些队列通常被赋予其他名称,如“reqsk_queue”、“ACK backlog”、“listen backlog”,甚至“TCP backlog”,但是为了避免混淆,我将坚持使用上面的名称。
SYN队列
SYN队列存储入站SYN数据包[1](具体是:struct inet_request_sock
)。它负责发送SYN + ACK数据包,并在超时时重试。在Linux上,重试次数配置为:
$ sysctl net.ipv4.tcp_synack_retries
net.ipv4.tcp_synack_retries = 5
tcp_synack_retries - INTEGER
Number of times SYNACKs for a passive TCP connection attempt
will be retransmitted. Should not be higher than 255. Default
value is 5, which corresponds to 31 seconds till the last
retransmission with the current initial RTO of 1second. With
this the final timeout for a passive TCP connection will
happen after 63 seconds.
在传输SYN+ACK之后,SYN队列需要等待来自客户端的ACK包——这是三次握手中的最后一个数据包。所有接收到的ACK包必须首先与完全建立的连接表进行匹配,然后才与相关SYN队列中的数据匹配。在SYN队列匹配中,内核从SYN队列中删除该项,愉快地创建一个完全成熟的连接(具体地说是:struct inet_sock),并将其添加到Accept队列中。
Accept队列
Accept队列包含完全建立的连接:准备由应用程序获取。当进程调用accept()时,套接字被从队列中删除并传递给应用程序。
这是Linux上SYN数据包处理的一个相当简化的视图。使用TCP_DEFER_ACCEPT
[2] 和TCP_FASTOPEN
之类的套接字toggle,事情的运作
会稍有不同。
队列大小限制
Accept和SYN队列的最大允许长度来自应用程序传递给listen(2)系统调用的backlog参数。例如,这里将Accept和SYN队列大小设置为1024:
listen(sfd, 1024)
注意:在4.3之前的内核中,SYN队列长度的计数方式有所不同。
该SYN队列上限以前是由net.ipv4.tcp_max_syn_backlog toggle配置的,但现在不再是这种情况了。如今,net.core.somaxconn为两个队列大小设置上限。在我们的服务器上,我们将其设置为16k:
$ sysctl net.core.somaxconn
net.core.somaxconn = 16384
完美的backlog值
了解了这些之后,我们可能会问这样一个问题:什么是理想的backlog参数值?
答案是:视情况而定。对于大多数普通TCP服务器而言,这并不重要。例如,在1.11版之前,众所周知Golang并不支持自定义backlog值。尽管增加这个值是有正当理由的:
当传入连接的速率非常大时,即使使用高性能应用程序,入站SYN队列也可能需要更多的插槽。
backlog值控制SYN队列大小。这实际上可以被理解为“飞行中的ACK包”。到客户端的平均往返时间越大,使用的插槽就越多。在许多客户端远离服务器(几百毫秒之外)的情况下,增加backlog值是有意义的。
TCP_DEFER_ACCEPT选项会使套接字保持在SYN-RECV状态的时间更长,并导致队列进一步受限。
过度调整backlog同样也是不好的:
SYN队列中的每个插槽都占用一些内存。在SYN洪水时,浪费资源来存储攻击数据包是没有意义的。SYN队列中的每个struct inet_request_sock条目都在4.14内核上占用256个字节的内存。
要查看Linux上的SYN队列,我们可以使用ss命令并查找SYN-RECV套接字。例如,在Cloudflare的一台服务器上,我们可以看到tcp / 80的SYN队列使用了119个插槽,而tcp / 443则使用了78个插槽。
$ ss -n state syn-recv sport = :80 | wc -l
119
$ ss -n state syn-recv sport = :443 | wc -l
78
类似数据的显示也可以借助我们的overenginered SystemTap脚本:resq.stp。
缓慢应用
如果应用程序不能足够快地跟上调用accept()的速度,会发生什么?
这时魔法发生了!当接受队列已满(大小为backlog+1)时:
SYN队列的入站SYN数据包将被丢弃。
SYN队列的入站ACK数据包将被丢弃。
TcpExtListenOverflows / LINUX_MIB_LISTENOVERFLOWS计数增加。
TcpExtListenDrops / LINUX_MIB_LISTENDROPS计数增加。
丢弃入站数据包有一个强有力的理由:这是一个回推机制。另一方迟早会重新发送SYN或ACK包,这是希望,慢速的应用程序将得以恢复。
对于几乎所有的服务器来说,这都是一种可取的行为。为了完整起见:我们可以使用全局net.ipv4.tcp_abort_on_overflow toggle进行调整,但最好还是不用它。
如果您的服务器需要处理大量的入站连接,并且难以处理accept()吞吐量,请考虑阅读我们的Nginx调整/ Epoll工作分发文章以及显示有用的SystemTap脚本的后续文章。
您可以通过查看nstat计数器来跟踪Accept队列的溢出状态:
$ nstat -az TcpExtListenDrops
TcpExtListenDrops 49199 0.0
这是一个全局计数器。这并不理想——有时我们看到它在增长,而所有的应用程序看起来都很健康!第一步始终应该使用ss命令打印Accept队列大小:
$ ss -plnt sport = :6443|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 1024 *:6443 *:*
该列中的Recv-Q显示Accept队列中的套接字数,并Send-Q显示backlog参数。在这种情况下,我们没有看到待处理的套接字accept(),但ListenDrops计数器仍在增加。
结果我们的应用程序在一小段时间内卡在了。这足以让Accept队列在很短的时间内溢出。过了一会儿,它又恢复了。这种情况很难用ss调试,所以我们编写了一个acceptq.stp SystemTap脚本来帮助我们。它挂载到内核并打印要丢弃的SYN数据包:
$ sudo stap -v acceptq.stp
time (us) acceptq qmax local addr remote_addr
1495634198449075 1025 1024 0.0.0.0:6443 10.0.1.92:28585
1495634198449253 1025 1024 0.0.0.0:6443 10.0.1.92:50500
1495634198450062 1025 1024 0.0.0.0:6443 10.0.1.92:65434
...
在这里,您可以精确地看到哪些SYN数据包受到了ListenDrops的影响。使用此脚本,我们很容易就可以了解哪个应用程序断开了连接。
SYN洪水
如果Accept队列有可能会溢出,那么SYN队列必然也存在溢出的可能。在这种情况下会发生什么?
这就是SYN洪水攻击的全部目的。过去,用伪造的SYN数据包充满SYN队列是一个真正的问题。在1996年之前,只要填满SYN队列,就可以用很少的带宽成功地拒绝几乎所有TCP服务器的服务。
解决之道是SYN Cookies。SYN Cookies是一种允许无状态生成SYN + ACK的结构,实际上不需要保存入站SYN并浪费系统内存。SYN Cookies不会破坏合法流量。当另一方真实存在时,它将以有效的ACK数据包进行响应,其中包括反射的序列号,该序列号可以通过密码验证。
默认情况下,SYN Cookie仅在需要时启用——用于SYN队列已满的套接字。Linux更新了SYN Cookie上的几个计数器。发送SYN Cookie时:
TcpExtTCPReqQFullDoCookies / LINUX_MIB_TCPREQQFULLDOCOOKIES递增。
TcpExtSyncookiesSent / LINUX_MIB_SYNCOOKIESSEN递增。
Linux以前会递增TcpExtListenDrops,但从内核4.7开始不再如此。
当一个入站ACK进入SYN队列时,SYN Cookie被占用:
密码验证成功,则TcpExtSyncookiesRecv / LINUX_MIB_SYNCOOKIESRECV递增。
密码验证失败,则TcpExtSyncookiesFailed / LINUX_MIB_SYNCOOKIESFAILED递增。
sysctl net.ipv4.tcp_syncookies可以禁用SYN Cookies或强制启用它们。默认就好,无需更改。
SYN Cookies和TCP时间戳
SYN Cookies这一“魔法”是可行的,但它也不是没有缺点的。主要的问题是,可以保存在SYN Cookie中的数据非常少。具体来说,在ACK中只返回序列号的32位。这些位元的用法如下:
+----------+--------+-------------------+
| 6 bits | 2 bits | 24 bits |
| t mod 32 | MSS | hash(ip, port, t) |
+----------+--------+-------------------+
由于MSS设置仅被截断为4个不同的值,因此Linux不知道对方的任何可选TCP参数。有关时间戳,ECN,选择性ACK或窗口缩放的信息会丢失,并可能导致TCP会话性能下降。
幸运的是,Linux可以解决。如果启用了TCP时间戳,则内核可以在“时间戳”字段中重新使用另一个32位插槽。它包含:
+-----------+-------+-------+--------+
| 26 bits | 1 bit | 1 bit | 4 bits |
| Timestamp | ECN | SACK | WScale |
+-----------+-------+-------+--------+
默认情况下,应启用TCP时间戳,从而验证查看sysctl:
$ sysctl net.ipv4.tcp_timestamps
net.ipv4.tcp_timestamps = 1
历史上有很多关于TCP时间戳有用性的讨论。
在过去,时间戳会泄露服务器运行时间(这是否重要又是另一个讨论了)。这在8个月前就被修复了。
TCP时间戳占用大量的带宽——每个数据包12字节。
它们可以为数据包校验增添额外的随机性,这可以帮助解决某些损坏的硬件。
如上所述,如果启用了SYN Cookies,则TCP时间戳可以提高TCP连接的性能。
目前在Cloudflare,我们禁用了TCP时间戳。
最后,使用SYN Cookie时,一些很酷的特性将不起作用,比如TCP_SAVED_SYN、TCP_DEFER_ACCEPT或TCP_FAST_OPEN。
Cloudflare规模的SYN洪水
SYN Cookie是一项伟大的发明,它解决了SYN小洪水的问题。但在Cloudflare,我们尽可能避免使用它们。虽然每秒发送几千个可加密验证的SYN+ACK包是可行的,但我们看到的是每秒超过2亿个包的攻击。在这种规模下,我们的SYN+ACK响应就相当于在互联网上乱扔垃圾,不会带来任何好处。
相反,我们尝试在防火墙层上丢弃恶意的SYN数据包。我们使用编译到BPF(柏克莱封包过滤器)的p0f SYN指纹。您可以阅读这篇介绍p0f BPF编译器的博文,了解更多信息。为了检测和部署缓解措施,我们开发了一个自动化系统,称为“Gatebot(网关机器人)”。这里有我们对它的描述:认识Gatebot——让我们能够安然入睡的机器人。
不断变化的景观
有关该主题的数据稍有些过时,想要了解更多,请阅读Andreas Veithen在2015年给出的出色解释和Gerald W. Gordon在2013年发布的综合论文。
Linux SYN数据包处理技术的景观在不断发展。直到最近,由于内核中的老式锁,SYN Cookies仍然很慢。这个问题在4.4中已得到解决,现在您可以依赖内核每秒发送数百万个SYN Cookie,这实际上解决了大多数用户的SYN洪水问题。通过适当的调优,我们甚至可以在不影响合法连接性能的情况下减轻最烦人的SYN洪水。
应用程序的性能也得到了极大的关注。最近的一些想法(如SO_ATTACH_REUSEPORT_EBPF)在网络堆栈中引入了一个全新的可编程层。
在这个原本停滞不前的操作系统世界里,看到创新和新鲜的想法涌入网络堆栈,真是太好了。
感谢Binh Le为这篇文章提供的帮助。
处理Linux和NGINX的内部是不是很有趣呢?快来加入我们在伦敦,奥斯汀,旧金山的世界著名团队以及在波兰华沙的精英办公室吧。
我在简化,从技术上讲,SYN队列存储的是尚未建立的连接,而不是SYN包本身。尽管使用TCP_SAVE_SYN就已经足够了。↩︎
如果您不熟悉TCP_DEFER_ACCEPT,那么一定要看看FreeBSD的版本——accept过滤器。 ↩︎