【译】处理繁忙Linux服务器上的TIME-WAIT
译文声明:
本文是翻译文章,译文仅供参考。翻译水平有限,如有疑问,请阅读原文。
原文标题:Coping with the TCP TIME-WAIT state on busy Linux servers
原文地址:https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
最近被一个服务器产生大量time-wait连接的问题所困扰,网上也找了一些从应用和内核层面进行优化的文章,但感觉都不得要领,在仔细阅读了这篇文章之后,有一种豁然开朗的感觉。
在看文章之前,先回顾一下tcp各个状态的含义以及状态转换图。
一个连接在整个生命周期中会经历一系列状态,这些状态包括: LISTEN, SYN-SENT, SYNRECEIVED, ESTABLISHED, FIN-WAIT-1, FIN-WAIT-2, CLOSE-WAIT, CLOSING, LAST-ACK, TIME-WAIT 和 CLOSED。 CLOSED 其实是一种虚构的状态,它代表已经没有TCP连接了。 各个状态的含义如下:
LISTEN represents waiting for a connection request from any remote TCP and port.
SYN-SENT represents waiting for a matching connection request after having sent a connection request.
SYN-RECEIVED represents waiting for a confirming connection request acknowledgment after having both
received and sent a connection request.
ESTABLISHED represents an open connection, data received can be delivered to the user. The normal state
for the data transfer phase of the connection.
FIN-WAIT-1 represents waiting for a connection termination request from the remote TCP, or an
acknowledgment of the connection termination request previously sent.
FIN-WAIT-2 represents waiting for a connection termination request from the remote TCP.
CLOSE-WAIT represents waiting for a connection termination request from the local user.
CLOSING represents waiting for a connection termination request acknowledgment from the remote
TCP.
LAST-ACK represents waiting for an acknowledgment of the connection termination request previously
sent to the remote TCP (which includes an acknowledgment of its connection termination
request).
TIME-WAIT represents waiting for enough time to pass to be sure the remote TCP received the
acknowledgment of its connection termination request.
CLOSED represents no connection state at all.
A TCP connection progresses from one state to another in response to events. The events are the user calls, OPEN, SEND, RECEIVE, CLOSE, ABORT, and STATUS; the incoming segments, particularly those containing the SYN, ACK, RST and FIN flags; and timeouts.
以下是译文。
不要启用 net.ipv4.tcp_tw_recycle
,自Linux 4.12起,这个参数甚至不存在。
Linux内核文档对 net.ipv4.tcp_tw_recycle
和 net.ipv4.tcp_tw_reuse
的解释不是很有帮助,文档的缺少为许多调优指南开辟了市场,这些文档建议将这两个值都设置为1,以减少TIME-WAIT状态下的条目数。但是,正如tcp(7)手册所述,net.ipv4.tcp_tw_recycle
选项对于面向公众的服务器来说是个问题,因为它无法处理来自同一NAT设备后面的两台不同计算机的连接,这是一个难题:启用TIME-WAIT套接字的快速回收。 不建议启用此选项,因为在使用NAT时会导致问题。
我将在此文中提供有关如何正确处理TIME-WAIT状态的更详细的阐述说明。另外,请注意,我们正在研究的是Linux的TCP堆栈, 这与 Netfilter 连接跟踪完全无关,后者可以通过其他方式进行调整。[1]
关于 TIME-WAIT 状态
我们回过头来仔细看下什么是 TIME-WAIT 状态,请参阅如下的 TCP 状态图:[2]
TCP state diagram
只有首先关闭连接的一端才会进入 TIME-WAIT 状态,另一端通常允许其快速断开连接(The other end will follow a path which usually permits it to quickly get rid of the connection)。
你可以使用 ss -tan
命令来查看当前连接状态:
1 | ss -tan | head -5 |
目的
TIME-WAIT 状态的存在有两个目的:
最著名的一个目的是,阻止延迟的数据片段(segment)被紧接着的另外一个连接接收,这个连接也是使用同一个四元组(quadruplet: source address, source port, destination address, destination port),数据分段的序列号(sequence number)也需要在一定范围内才会被接受。这使问题缩小了一点,但仍然存在,尤其是在具有大接收窗口的快速连接上。RFC 1337详细说明了TIME-WAIT状态缺失或者时间不足时会发生什么。[3]以下是在不缩短TIME-WAIT状态时间时可以避免出现问题的示例:
Due to a shortened TIME-WAIT state, a delayed TCP segment has been accepted in an unrelated connection.另一个目的是确保远端已关闭连接。 当最后一个ACK丢失时,远端将保持LAST-ACK状态。[4] 如果没有TIME-WAIT状态,则在远端仍认为先前的连接有效时,本地可以重新打开连接。 当远端收到一个SYN片段(且序列号匹配)时,它将作出RST应答,因为这个数据片段不是它期望的,新连接将因错误而中止:
If the remote end stays in LAST-ACK state because the last ACK was lost, opening a new connection with the same quadruplet will not work.
RFC 793规定 TIME-WAIT 状态至少要持续两倍的MSL(Maximum Segment Lifetime)时间。在Linux服务器上,这个时间被定义在include/net/tcp.h里,设置为1分钟,并且不可调(tunable):
1 | #define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT |
已经有人提出将其转换为可调值的提议,但由于TIME-WAIT状态被认为是一件好事而被拒绝。
问题
现在让我们看看为什么这种状态在处理大量连接的服务器上会令人讨厌。 这个问题有三个方面:
- 连接表中占用了插槽,导致阻止了相同类型的新连接
- 内核中套接字结构占用的内存
- 额外的cpu使用
ss -tan state time-wait | wc -l
的结果本身不是问题!
连接表(Connection table slot)
处于TIME-WAIT状态的连接在连接表中保留一分钟。这意味着,不可能存在具有相同四联体的另一个连接(源地址,源端口,目标地址,目标端口)。
对于Web服务器,目标地址和目标端口可能是恒定的。如果你的Web服务器在L7负载均衡器后面,则源地址也将是保持不变。在Linux服务器上,默认情况下,客户端端口分配在大约30,000个端口的端口范围内(可以通过调整net.ipv4.ip_local_port_range进行更改)。这意味着每分钟在Web服务器和负载平衡器之间只能建立30,000个连接,因此每秒大约有500个并发连接。
如果 TIME-WAIT 套接字位于客户端,则很容易检测到这种情况。对 connect() 的调用将返回 EADDRNOTAVAIL,应用程序将记录一些与此有关的错误消息。在服务器端,这更加复杂,因为没有日志,也没有计数器可依赖。毫无疑问,应该尝试一些巧妙的方式来列出已使用的四联体的数量:
1 | ss -tan 'sport = :80' | awk '{print $(NF)" "$(NF-1)}' | \ |
这个问题的解决方案可以通过如下的方式提供更多的四元组(按照配置难度的顺序)[5]:
- 通过为
net.ipv4.ip_local_port_range
设置一个更大的端口范围提供更多的客户端端口(more client ports); - web服务器监听额外的端口(81, 82, 83,…)提供更多的服务端端口(more server ports);
- 在负载均衡器上配置额外的IP,使用轮询方式,提供更多的客户端IP(more client IP);[6]
- 在web服务器上配置额外的IP,提供更多的服务端IP(more server IP)。[7]
最后一种解决方案是调整 net.ipv4.tcp_tw_reuse
和 net.ipv4.tcp_tw_recycle
。暂时不要这样做,我们稍后将介绍这些配置。
内存
处理许多连接时,将套接字多打开一分钟可能会消耗服务器一些内存。例如,如果想要每秒处理约10,000个新连接,则在TIME-WAIT状态下将有约600,000个套接字。 它代表多少内存? 不会太多!
首先,从应用程序的角度来看,TIME-WAIT套接字不占用任何内存:该套接字已关闭。在内核中,TIME-WAIT套接字以三种结构(出于三种不同目的)存在:
- 连接的哈希表(hash table of connections),称为”TCP建立的哈希表”(TCP established hash table,尽管包含其他连接状态),用于查找现有连接,例如在接收新数据段时。
该哈希表的每个存储桶都包含处于TIME-WAIT状态的连接列表和常规活动连接列表。 哈希表的大小取决于系统内存,并在启动时会打印出来:
1 | dmesg | grep "TCP established hash table" |
通过在内核命令行上使用thash_entries参数指定条目数,可以覆盖这个值。
处于TIME-WAIT状态的连接列表中的每个元素都是一个tcp_timewait_sock结构体,而其他状态的类型是tcp_sock结构体:[8]
1 | struct tcp_timewait_sock { |
- 一组连接列表(称为”死亡行”)用于计算连接处于TIME-WAIT状态过期时间,它们通过到期前剩余时间进行排序。
它使用与连接哈希表中条目相同的内存空间。This is the struct hlist_node tw_death_node member of struct inet_timewait_sock.[9]
- 绑定端口的哈希表,包含本地绑定端口和关联的参数,用于确定在动态绑定的情况下侦听给定端口或找到可用端口是否安全。 该哈希表的大小与连接的哈希表的大小相同:每个元素都是一个inet_bind_socket结构。 每个本地绑定端口都有一个元素。 与Web服务器的TIME-WAIT连接在本地绑定到端口80,并且与其兄弟TIME-WAIT连接共享相同的条目。 另一方面,与远程服务的连接在本地绑定到某个随机端口,并且不共享其条目。
1
2dmesg | grep "TCP bind hash table"
[ 0.169962] TCP bind hash table entries: 65536 (order: 8, 1048576 bytes)
我们只关心struct tcp_timewait_sock和struct inet_bind_socket占用的空间。 处于TIME-WAIT状态(入站或出站)的每个连接都有一个struct tcp_timewait_sock。 每个出站连接都有一个专用的struct inet_bind_socket,入站连接没有。
struct tcp_timewait_sock只有168个字节,而struct inet_bind_socket只有48个字节:
1 | sudo apt-get install linux-image-$(uname -r)-dbg |
如果有大约40,000个入站连接处于TIME-WAIT状态下,则它应该占用的内存少于10 MiB。如果有大约40,000个出站连接处于TIME-WAIT状态下,则需要考虑2.5 MiB的额外内存。让我们通过查看slabtop
的输出来进行检查。这是大约具有50,000个处于TIME-WAIT状态下连接的服务器上的结果,其中45,000个是出站连接:
1 | sudo slabtop -o | grep -E '(^ OBJS|tw_sock_TCP|tcp_bind_bucket)' |
这里没有任何变化:TIME-WAIT连接使用的内存非常小。如果服务器需要每秒处理数千个新连接,则需要更多的内存才能有效地将数据推送到客户端。 TIME-WAIT连接的开销可以忽略不计。
CPU
在CPU方面,搜索可用的本地端口可能会有点昂贵。 该工作由inet_csk_get_port()函数完成,该函数使用锁定并在本地绑定的端口上进行迭代,直到找到可用端口为止。如果有许多处于TIME-WAIT状态的出站连接(例如到内存缓存服务器的临时连接),则此哈希表中的大量条目通常不是问题:这些连接通常共享相同的配置文件,该函数将在迭代它们快速找到可用端口。
其他解决方案
如果在阅读了上一节之后仍然对TIME-WAIT连接有疑问,那么可以通过以下其他三种方案来解决:
- disable socket lingering
- net.ipv4.tcp_tw_reuse
- net.ipv4.tcp_tw_recycle
Socket lingering
在程序调用close()时,内核缓冲区中的所有剩余数据将在后台被发送,并且套接字最终将转换为TIME-WAIT状态。 该应用程序可以立即继续工作,并认定所有数据最终都将被安全地传递出去。
但是,应用程序可以通过套接字延迟(socket lingering)选择禁用此种行为, 有两种常见情况:
- 在第一种情况中,所有剩余数据都将被丢弃,并不是使用正常的四次握手(原文为four-packet)来中止连接,而是使用RST来立即关闭连接(因此,对端将会检测到错误)。在这种情况下,没有TIME-WAIT状态。
- 在第二种情况下,如果套接字发送缓冲区中仍然有数据,程序将通过调用close()时进入睡眠(sleep)状态,直到所有的数据都发送出去并且得到对端的确认信息,或者配置的延迟计时器到期。通过将套接字设置为非阻塞,进程也可能不会休眠。在这种情况下,进程在后台运行, 它继续发送剩余的数据,直到配置的超时时间。如果数据在超时时间都发送成功,则会进行正常的关闭(四次握手),并且会出现TIME-WAIT状态,否则,程序将直接通过RST关闭连接,剩余的数据将被丢弃。
在这两种情况下,禁用套接字延迟都不是一种万能的解决方案。 从上层协议的角度来看,当可以安全使用时,像Haproxy或者Nginx这类应用程序可能会使用它,但有充分的理由来阐述不能无条件地禁用它。
net.ipv4.tcp_tw_reuse
TIME-WAIT状态可防止不相关的连接接受延迟的数据段。但是,在某些情况下,可以认为新连接的数据段不会被旧连接的数据段混淆。
RFC1323提出了一组TCP扩展,以提高高带宽的性能。 它定义了一个新的TCP选项,包含两个四个字节的时间戳字段(timestamp fields)。 第一个是TCP发送时时间戳的当前值,而第二个是从远程主机接收到的最新时间戳。
通过启用net.ipv4.tcp_tw_reuse,如果新连接的时间戳严格大于为先前连接记录的最新时间戳,Linux将可以重新利用TIME-WAIT状态的连接,建立新的对外连接:仅仅在1秒之后,出站的处于TIME-WAIT状态的连接就可以被重新使用。
安全性如何?TIME-WAIT状态的首要目的是避免无关的连接接收重复的段。由于使用了时间戳,这样的重复段将带有过时的时间戳,因此将被丢弃。
第二个目的是确保由于丢失了最后一个ACK,远端不会处于LAST-ACK状态。 远端将重传FIN,直到:
- it gives up (and tear down the connection)
- it receives the ACK it is waiting (and tear down the connection)
- it receives a RST (and tear down the connection)
如果及时接收到FIN,则本地端套接字仍将处于TIME-WAIT状态,并且将发送预期的ACK段。
一旦新的连接替换了TIME-WAIT条目,新连接的SYN将被忽略(由于timestamps),并且不会响应RST,而只有FIN重新传输才能响应。然后,远端发送的FIN将被本地响应RST(因为本地连接处于SYN-SENT状态),这样将是远端结束LAST-ACK状态。因为远端没有响应,最初的SYN最终将被重发(一秒钟后),最近建立连接,从表面看,除了一点延迟,没有明显的错误:
If the remote end stays in LAST-ACK state because the last ACK was lost, the remote connection will be reset when the local end transition to the SYN-SENT state.
It should be noted that when a connection is reused, the TWRecycled counter is increased (despite its name).
net.ipv4.tcp_tw_recycle
此机制也依赖于timestamp选项,但同时影响入站和出站连接,通常在服务器通常首先关闭连接时是有用的。[10]
TIME-WAIT状态将更快地到期接收:它将根据RTT及其方差计算的重传超时(RTO)间隔后将其删除。 您可以使用ss命令查看活动连接的具体值:
1 | ss --info sport = :2112 dport = :4057 |
当降低TIME-WAIT过期时间时,为了保持相同,可以保证提供了TIME-WAIT状态,同时减少了到期计时器,当连接进入TIME-WAIT状态时,会在专用结构中记住最新的时间戳,该结构包含用于先前已知目的地的各种度量。 然后,Linux将删除远程主机中时间戳严格不大于最近记录的时间戳的任何段,除非TIME-WAIT状态已过期:
1 | if (tmp_opt.saw_tstamp && |
当远程主机实际上是NAT设备时,时间戳条件将阻止除NAT设备后面某台主机之外的所有其他主机在一分钟内进行连接,因为它们不共享相同的时间戳时钟。毫无疑问,最好禁用此选项,因为它会导致难以检测和难以诊断问题。
从Linux 4.10(提交95a22caee396)开始,Linux将随机化每个连接的时间戳偏移,无论是否使用NAT,此选项都会使网络产生中断。 它已从Linux 4.12完全删除。
LAST-ACK状态的处理方式与net.ipv4.tcp_tw_recycle完全相同。
总结
通用解决方案是增加可能的四连体数量,例如通过使用更多的服务器端口,这样不会因为TIME-WAIT条目过多导致超过可用的连接数。
在服务器端,请不要启用net.ipv4.tcp_tw_recycle,除非确定永远不会有NAT设备。 启用net.ipv4.tcp_tw_reuse对于入站连接无效。
在客户端,启用net.ipv4.tcp_tw_reuse是另一个几乎安全的解决方案。 启用net.ipv4.tcp_tw_reuse之后,再启用net.ipv4.tcp_tw_recycle通常作用也不大。
此外,在设计协议时,请勿让客户先关闭。 客户无需处理TIME-WAIT状态,将此问题交给服务器端处理更合适。
最后是W. Richard Stevens在Unix Network Programming中的一句话:
“The TIME_WAIT state is our friend and is there to help us (i.e., to let old duplicate segments expire in the network). Instead of trying to avoid the state, we should understand it.”
参考
Notably, fiddling with net.netfilter.nf_conntrack_tcp_timeout_time_wait won’t change anything on how the TCP stack will handle the TIME-WAIT state.
This diagram is licensed under the LaTeX Project Public License 1.3. The original file is available on this page.
The first workaround proposed in RFC 1337 is to ignore RST segments in the TIME-WAIT state. This behaviour is controlled by net.ipv4.rfc1337 which is not enabled by default on Linux because this is not a complete solution to the problem described in the RFC.
While in the LAST-ACK state, a connection will retransmit the last FIN segment until it gets the expected ACK segment. Therefore, it is unlikely we stay long in this state.
On the client side, older kernels also have to find a free local tuple (source address and source port) for each outgoing connection. Increasing the number of server ports or IP won’t help in this case. Linux 3.2 is recent enough to be able to share the same local tuple for different destinations. Thanks to Willy Tarreau for his insight on this aspect.
To avoid EADDRINUSE errors, the load balancer needs to use the SO_REUSEADDR option before calling bind(), then connect().
This last solution may seem a bit dumb since you could just use more ports but some servers are not able to be configured this way. The before last solution can also be quite cumbersome to setup, depending on the load-balancing software, but uses less IP than the last solution.
The use of a dedicated memory structure for sockets in the TIME-WAIT is here since Linux 2.6.14. The struct sock_common structure is a bit more verbose and I won’t copy it here.
Since Linux 4.1, the way TIME-WAIT sockets are tracked has been modified to increase performance and parallelism. The death row is now just a hash table.
When the server closes the connection first, it gets the TIME-WAIT state while the client will consider the corresponding quadruplet free and hence may reuse it for a new connection.