Skip to content

关于在繁忙的 Linux 服务器上如何应对 TCP TIME-WAIT 状态

TL;DR: 不要启用 net.ipv4.tcp_tw_recycle —— 从 Linux 4.12 开始,这项系统参数已经不存在了。大多数情况下,TIME-WAIT 套接字是无害的。可以跳到总结部分查看推荐的解决方案。

Linux 内核文档对于 net.ipv4.tcp_tw_recyclenet.ipv4.tcp_tw_reuse 的作用没有提供太多解释说明。这种缺乏文档支持的情况导致许多调优指南建议将这两个设置都设为 1(即启用),以减少处于 TIME-WAIT 状态的连接数量。然而,正如 tcp(7) 手册页所述,net.ipv4.tcp_tw_recycle 选项对于面向公众的服务器来说是相当有问题的,因为它无法处理来自同一 NAT 设备后面的两个不同计算机的连接,这是一个难以检测的问题,随时可能给你带来麻烦。

启用 TIME-WAIT 套接字的快速回收不推荐,因为这会在与 NAT 设备协同工作时导致问题。

我将在这里提供一个更详细的解释,说明如何正确处理 TIME-WAIT 状态。此外,请记住我们正在查看的是 Linux 的 TCP 堆栈。这与 Netfilter 连接跟踪完全无关,后者可能会以其他方式进行调整。

关于 TIME-WAIT 状态

让我们回顾一下 TIME-WAIT 状态。请参见下方的 TCP 状态图:

TCP 状态图

TCP 状态图

只有首先关闭连接的一端会进入 TIME-WAIT 状态。另一端通常会遵循一条路径,使其能够快速关闭连接。你可以使用 ss -tan 查看当前连接状态:

bash
$ ss -tan | head -5
LISTEN     0  511             *:80              *:*
SYN-RECV   0  0     192.0.2.145:80    203.0.113.5:35449
SYN-RECV   0  0     192.0.2.145:80   203.0.113.27:53599
ESTAB      0  0     192.0.2.145:80   203.0.113.27:33605
TIME-WAIT  0  0     192.0.2.145:80   203.0.113.47:50685

目的

TIME-WAIT 状态有两个目的:

  1. 最常见的目的是防止因为网络问题而延迟到达的段从一个连接被迫接受到依赖相同四元组(源地址、源端口、目标地址、目标端口)的后续连接中。虽然序列号需要在一定范围内才能被接受,这稍微缩小了问题发生的概率,但它仍然存在,尤其是在具有大接收窗口的快速连接上。RFC 1337 文档详细解释了当 TIME-WAIT 状态不足时会发生什么。以下是如果 TIME-WAIT 状态未缩短时可以避免的情况示例:

由于缩短的 TIME-WAIT 状态,延迟的 TCP 段已被接受到不相关的连接中。

由于缩短的 TIME-WAIT 状态,延迟的 TCP 段已被接受到不相关的连接中。

  1. 另一个目的是确保远程端已关闭连接。当最后一个 ACK 丢失时,远程端会保持在 LAST-ACK 状态。没有 TIME-WAIT 状态,连接可能会在远程端仍认为先前连接有效时重新打开。当它接收到 SYN 段(并且序列号匹配)时,它将回答一个 RST,因为它不期望这样的段。新连接将因错误而中止:

如果远程端由于最后一个 ACK 丢失而保持在 LAST-ACK 状态,则使用相同四元组打开新连接将不起作用。

如果远程端由于最后一个 ACK 丢失而保持在 LAST-ACK 状态,则使用相同四元组打开新连接将不起作用。

RFC 793 要求 TIME-WAIT 状态持续两倍的 MSL 时间。在 Linux 上,此持续时间是不可调的,在 include/net/tcp.h 中定义为一分钟:

c
#define TCP_TIMEWAIT_LEN (60*HZ) /* 等待销毁 TIME-WAIT 状态的时间,大约 60 秒 */

曾经有将其转变为可调值的提议,但由于 TIME-WAIT 状态是件好事而被拒绝。

问题

现在,让我们看看为什么这种状态在处理大量连接的服务器上会令人烦恼。问题有三个方面:

  • 连接表中的插槽阻止同类新连接
  • 内核中套接字结构占用着内存
  • 以及额外的CPU使用

ss -tan state time-wait | wc -l 的结果本身并不是问题!

连接表插槽

TIME-WAIT状态下的连接会在连接表中保留一分钟。这意味着不能存在具有相同四元组(源地址、源端口、目标地址、目标端口)的另一个连接。

对于一个网络服务器,目标地址和目标端口可能是固定的。如果你的网络服务器位于L7负载均衡器之后,源地址也将是固定的。在Linux上,客户端端口默认分配在约30,000个端口的范围内(可以通过调整net.ipv4.ip_local_port_range来改变)。这意味着每分钟只能在网络服务器和负载均衡器之间建立30,000个连接,大约每秒500个连接。

如果TIME-WAIT套接字在客户端侧,这种情况很容易检测。调用connect()将返回EADDRNOTAVAIL,应用程序会记录一些关于该错误的信息。在服务器端,这就更复杂了,因为没有日志和计数器可以依赖。若有疑问,你应该尝试找出一些合理的方法来列出使用的四元组数量:

bash
$ ss -tan 'sport = :80' | awk '{print $(NF)" "$(NF-1)}' | sed 's/:[^ ]*//g' | sort | uniq -c
    696 10.24.2.30 10.33.1.64
   1881 10.24.2.30 10.33.1.65
   5314 10.24.2.30 10.33.1.66
   5293 10.24.2.30 10.33.1.67
   3387 10.24.2.30 10.33.1.68
   2663 10.24.2.30 10.33.1.69
   1129 10.24.2.30 10.33.1.70
  10536 10.24.2.30 10.33.1.73

解决方案是更多的四元组。可以通过几种方式实现(按设置难易程度排序):

  • 通过设置net.ipv4.ip_local_port_range到更宽的范围来使用更多的客户端端口
  • 通过要求网络服务器监听几个额外的端口(81, 82, 83, …)来使用更多的服务器端口
  • 通过在负载均衡器上配置额外的IP并以轮循方式使用它们来使用更多的客户端IP
  • 或者通过在网络服务器上配置额外的IP来使用更多的服务器IP

最后的解决方案是调整net.ipv4.tcp_tw_reusenet.ipv4.tcp_tw_recycle。暂时不要这样做,我们将在后面讨论这些设置。

内存

在处理许多连接时,留下一个套接字多开一分钟可能会耗费服务器一些内存。例如,如果你想每秒处理约10,000个新连接,你将有约600,000个套接字处于TIME-WAIT状态。这代表多少内存呢?其实并不多!

首先,从应用程序的角度来看,TIME-WAIT套接字不消耗任何内存:套接字已经关闭。在内核中,TIME-WAIT套接字存在于三个结构中(用于三个不同目的):

  1. 一个名为“TCP已建立哈希表”的连接哈希表(尽管包含其他状态的连接)用于定位现有连接,例如在接收到新段时。

这个哈希表的每个桶都包含处于TIME-WAIT状态的连接列表和常规活动连接列表。哈希表的大小取决于系统内存,并在启动时打印:

bash
$ dmesg | grep "TCP established hash table"
[    0.169348] TCP established hash table entries: 65536 (order: 8, 1048576 bytes)

可以通过在内核命令行上指定thash_entries参数来覆盖它。

处于TIME-WAIT状态的连接列表中的每个元素都是一个struct tcp_timewait_sock,而其他状态的类型是struct tcp_sock

c
struct tcp_timewait_sock {
    struct inet_timewait_sock tw_sk;
    u32    tw_rcv_nxt;
    u32    tw_snd_nxt;
    u32    tw_rcv_wnd;
    u32    tw_ts_offset;
    u32    tw_ts_recent;
    long   tw_ts_recent_stamp;
};

struct inet_timewait_sock {
    struct sock_common  __tw_common;

    int                     tw_timeout;
    volatile unsigned char  tw_substate;
    unsigned char           tw_rcv_wscale;
    __be16 tw_sport;
    unsigned int tw_ipv6only     : 1,
                 tw_transparent  : 1,
                 tw_pad          : 6,
                 tw_tos          : 8,
                 tw_ipv6_offset  : 16;
    unsigned long            tw_ttd;
    struct inet_bind_bucket *tw_tb;
    struct hlist_node        tw_death_node;
};
  1. 一个称为“死亡行”的连接列表集用于使处于TIME-WAIT状态的连接过期。它们按到期时间剩余多少排序。

它使用与连接哈希表中的条目相同的内存空间。这是struct inet_timewait_sockstruct hlist_node tw_death_node成员。

  1. 一个绑定端口的哈希表,保存本地绑定的端口和相关参数,用于确定是否可以安全地监听给定端口或在动态绑定的情况下找到空闲端口。这个哈希表的大小与连接哈希表的大小相同:
bash
$ dmesg | grep "TCP bind hash table"
[    0.169962] TCP bind hash table entries: 65536 (order: 8, 1048576 bytes)

每个元素都是一个struct inet_bind_socket。每个本地绑定端口都有一个元素。到网络服务器的TIME-WAIT连接本地绑定到端口80,并与其兄弟TIME-WAIT连接共享同一个条目。另一方面,到远程服务的连接本地绑定到某个随机端口,并不共享其条目。

我们只关心struct tcp_timewait_sockstruct inet_bind_socket占用的空间。每个处于TIME-WAIT状态的连接,无论是入站还是出站,都有一个struct tcp_timewait_sock。每个出站连接都有一个专用的struct inet_bind_socket,而入站连接没有。

一个struct tcp_timewait_sock只有168字节,而一个struct inet_bind_socket是48字节:

bash
$ sudo apt-get install linux-image-$(uname -r)-dbg
[…]
$ gdb /usr/lib/debug/boot/vmlinux-$(uname -r)
(gdb) print sizeof(struct tcp_timewait_sock)
$1 = 168
(gdb) print sizeof(struct tcp_sock)
$2 = 1776
(gdb) print sizeof(struct inet_bind_bucket)
$3 = 48

如果你有大约40,000个入站连接处于TIME-WAIT状态,它应该消耗不到10 MiB的内存。如果你有大约40,000个出站连接处于TIME-WAIT状态,则需要额外考虑2.5 MiB的内存。让我们通过查看slabtop的输出来检查这一点。以下是在一个有大约50,000个处于TIME-WAIT状态的连接的服务器上的结果,其中45,000个是出站连接:

bash
$ sudo slabtop -o | grep -E '(^  OBJS|tw_sock_TCP|tcp_bind_bucket)'
  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
 50955  49725  97%    0.25K   3397       15     13588K tw_sock_TCP
 44840  36556  81%    0.06K    760       59      3040K tcp_bind_bucket

这里没有什么需要改变的:TIME-WAIT连接使用的内存确实很小。如果你的服务器需要每秒处理数千个新连接,你需要更多的内存来有效地向客户端推送数据。TIME-WAIT连接的开销可以忽略不计。

CPU

在 CPU 方面,寻找空闲的本地端口可能会比较耗费资源。这个工作由 inet_csk_get_port() 函数完成,该函数使用锁并迭代本地绑定的端口,直到找到一个空闲端口。如果你有大量处于 TIME-WAIT 状态的出站连接(例如与 memcached 服务器的临时连接),那么在这个哈希表中有大量条目通常不是问题:这些连接通常共享相同的配置文件,函数会在顺序迭代时快速找到一个空闲端口。

其他解决方案

如果在阅读了上一节后,你仍然认为 TIME-WAIT 连接存在问题,有三种额外的解决方案可以解决它们:

  • 禁用套接字滞留;
  • net.ipv4.tcp_tw_reuse
  • 以及 net.ipv4.tcp_tw_recycle

套接字滞留

当调用 close() 时,内核缓冲区中的任何剩余数据将会在后台发送,套接字最终会过渡到 TIME-WAIT 状态。应用程序可以立即继续工作,并假定所有数据最终会安全地传递。

然而,应用程序可以选择禁用这种称为套接字滞留的行为。有两种方式:

  1. 在第一种方式中,任何剩余数据将被丢弃,而不是使用正常的四包连接终止序列关闭连接,连接将通过 RST 关闭(因此,对端将检测到错误)并立即销毁。在这种情况下没有 TIME-WAIT 状态。

  2. 在第二种方式中,套接字发送缓冲区中有任何剩余数据,进程在调用 close() 时将会休眠,直到所有数据被发送并被对端确认,或者配置的滞留计时器到期。通过将套接字设置为非阻塞,可以使进程不休眠。在这种情况下,同样的过程在后台进行。它允许在配置的超时时间内发送剩余数据,但如果数据成功发送,则运行正常的关闭序列并进入 TIME-WAIT 状态。在另一种情况下,你将通过 RST 关闭连接并丢弃剩余数据。

在这两种情况下,禁用套接字滞留不是一个万能的解决方案。一些应用程序,如 HAProxyNginx,可以在从上层协议的角度来看是安全的情况下使用它。有充分的理由不无条件地禁用它。

net.ipv4.tcp_tw_reuse

TIME-WAIT 状态防止延迟的段在不相关的连接中被接受。然而,在某些条件下,可以假设新连接的段不会被误解为旧连接的段。

RFC 1323 提出了一组 TCP 扩展,以提高高带宽路径上的性能。除了其他内容外,它定义了一个新的 TCP 选项,携带两个四字节的 时间戳字段。第一个是发送选项的 TCP 的时间戳时钟的当前值,而第二个是从远程主机接收到的最新时间戳。

通过启用 net.ipv4.tcp_tw_reuse,Linux 将重用处于 TIME-WAIT 状态的现有连接用于新的 出站连接,如果新的时间戳严格大于之前连接记录的最新时间戳:处于 TIME-WAIT 状态的出站连接可以在仅仅一秒后重用。

这如何安全?TIME-WAIT 状态的第一个目的是避免重复段在不相关的连接中被接受。由于使用了时间戳,这些重复段将带有过时的时间戳,因此会被丢弃。

第二个目的是确保远程端不会因为最后一个 ACK 丢失而处于 LAST-ACK 状态。远程端将重新传输 FIN 段直到:

  1. 它放弃(并拆除连接),
  2. 或者它收到等待的 ACK(并拆除连接),
  3. 或者它收到 RST(并拆除连接)。

如果 FIN 段及时收到,本地端套接字仍将处于 TIME-WAIT 状态,并将发送预期的 ACK 段。

一旦新的连接替换了 TIME-WAIT 条目,新连接的 SYN 段将被忽略(由于时间戳),不会通过 RST 回应,而只会重新传输 FIN 段。然后 FIN 段将通过 RST 回应(因为本地连接处于 SYN-SENT 状态),这将允许从 LAST-ACK 状态过渡。初始 SYN 段最终会因为没有回应而在一秒后重新发送,连接将会建立而没有明显错误,除了轻微的延迟:

如果远程端因为最后一个 ACK 丢失而保持在 LAST-ACK 状态,当本地端过渡到 SYN-SENT 状态时,远程连接将被重置。

如果远程端因为最后一个 ACK 丢失而保持在 LAST-ACK 状态,当本地端过渡到 SYN-SENT 状态时,远程连接将被重置。

应该注意的是,当连接被重用时,TWRecycled 计数器会增加(尽管它的名字)。

net.ipv4.tcp_tw_recycle

这个机制也依赖于时间戳选项,但影响传入和传出连接。当服务器通常先关闭连接时,这很方便。

TIME-WAIT 状态计划更快地过期:它将在重新传输超时(RTO)间隔后被移除,该间隔是根据 RTT 及其变化计算的。你可以使用 ss 命令找到活跃连接的适当值:

bash
$ ss --info  sport = :2112 dport = :4057
State      Recv-Q Send-Q    Local Address:Port        Peer Address:Port
ESTAB      0      1831936   10.47.0.113:2112          10.65.1.42:4057
         cubic wscale:7,7 rto:564 rtt:352.5/4 ato:40 cwnd:386 ssthresh:200 send 4.5Mbps rcv_space:5792

为了保持 TIME-WAIT 状态提供的相同保证,同时减少过期计时器,当连接进入 TIME-WAIT 状态时,最新的时间戳会被记录在一个专用结构中,其中包含以前已知目的地的各种指标。然后,Linux 将丢弃来自远程主机的任何段,其时间戳不严格大于最新记录的时间戳,除非 TIME-WAIT 状态已经过期:

c
if (tmp_opt.saw_tstamp &&
    tcp_death_row.sysctl_tw_recycle &&
    (dst = inet_csk_route_req(sk, &fl4, req, want_cookie)) != NULL &&
    fl4.daddr == saddr &&
    (peer = rt_get_peer((struct rtable *)dst, fl4.daddr)) != NULL) {
        inet_peer_refcheck(peer);
        if ((u32)get_seconds() - peer->tcp_ts_stamp < TCP_PAWS_MSL &&
            (s32)(peer->tcp_ts - req->ts_recent) >
                                        TCP_PAWS_WINDOW) {
                NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);
                goto drop_and_release;
        }
}

当远程主机是 NAT 设备时,时间戳的条件将禁止除一个主机以外的所有主机在一分钟内连接,因为它们不共享相同的时间戳时钟。在不确定的情况下,最好禁用此选项,因为它会导致难以检测难以诊断的问题。

从 Linux 4.10(提交 95a22caee396)开始,Linux 将为每个连接随机化时间戳偏移,使得此选项完全失效,无论是否使用 NAT。从 Linux 4.12 中已完全移除。

LAST-ACK 状态的处理方式与 net.ipv4.tcp_tw_reuse 相同。

总结

通用解决方案是通过使用更多的服务器端口来增加可能的四元组数量。这将使你不会耗尽可能的连接与 TIME-WAIT 条目。

服务器端,由于其副作用,绝不要启用 net.ipv4.tcp_tw_recycle。对于传入连接,启用 net.ipv4.tcp_tw_reuse 是无用的。

客户端,启用 net.ipv4.tcp_tw_reuse 是另一种几乎安全的解决方案。除了 net.ipv4.tcp_tw_reuse 之外启用 net.ipv4.tcp_tw_recycle 大多是无用的。

此外,在设计协议时,不要让客户端先关闭。客户端不必处理 TIME-WAIT 状态,将责任推给服务器,服务器更适合处理这个问题。

最后引用 W. Richard StevensUnix Network Programming 中的一句话:

TIME-WAIT 状态是我们的朋友,它在那里帮助我们(即,让旧的重复段在网络中过期)。我们应该理解它,而不是试图避免这个状态。