← 返回新闻列表

深度解析TCP TIME_WAIT状态及探活工具实践

本文深入探讨了TCP协议中的TIME_WAIT状态,该状态对于网络连接的完整性至关重要,但同时也可能导致端口资源耗尽等问题。作者结合开源探活工具EaseProbe的实际应用场景,详细分析了TIME_WAIT的产生原因、带来的挑战,并提供了多种解决方案,尤其是在客户端应用中有效规避此问题的策略。

文 / 编辑部 · 2022/07/19 · 阅读约 10 分钟

分享:
深度解析TCP TIME_WAIT状态及探活工具实践

文章将详细阐述TCP协议中普遍存在的TIME_WAIT状态。尽管该问题广为人知,但笔者在一次独特场景下成功解决了相关困扰,并借此机会全面解析TIME_WAIT状态的方方面面。此案例与笔者开源的健康检查工具EaseProbe紧密相关,故将从该工具遇到的具体问题入手,逐步展开对TIME_WAIT机制的深入探讨。

EaseProbe是一款轻量且独立的健康检查工具,可对HTTP、TCP、Shell、SSH、TLS、主机及各类中间件服务进行活跃度探测,并将通知发送至Slack、Telegram、Discord、Email、Team等主流即时通讯平台,同时支持企业微信、钉钉、飞书等国内常用工具,广受用户好评。

该探活工具的运作模式要求每次探测都从零开始建立完整的网络连接,这意味着需要依次执行DNS查询、TCP连接建立、数据通信,最终再关闭连接。出于对网络链路整体状况的全面监测需求,EaseProbe不会启用TCP的KeepAlive机制来重用连接,以确保每次探测都能捕捉到整个链路的最新状态。

然而,这种频繁新建和关闭连接的操作,根据TCP协议的状态机,必然在探测端产生大量的TIME_WAIT状态连接。根据TCP定义,TIME_WAIT状态需持续等待两倍的最大报文段生命周期(2MSL)后才能被系统回收。在此期间,这些连接会占用系统资源,主要包括文件描述符(可通过调整配置缓解)和端口号(无法调整)。作为发起请求的客户端,理论上每个IP地址可用端口号上限约为64K,但实际系统中默认可用的通常在30K左右(例如,范围从32,768到60,999)。过多的TIME_WAIT连接可能导致TCP连接建立失败,甚至因资源耗尽引发程序或系统异常。

试想,如果以10秒周期探测1万个节点,而TIME_WAIT超时设定为120秒,那么仅在60秒后,等待超时的TIME_WAIT连接就可能耗尽某个IP地址上的所有可用端口,即使勉强维持,系统性能也会受到影响。值得注意的是,这不仅仅是TCP连接的问题,HTTP协议也涉及类似情况。例如,我们探测的是域名,需要查询DNS服务,而DNS服务通常运行在单一服务器上;此外,HTTPS协议常用于API探测,而API往往通过网关代理,导致连接集中到同一网关。即便能够建立出站连接,本地程序也可能因端口耗尽而无法绑定新端口。因此,现实情况并非像理论那样,只要四元组不冲突,端口就不会耗尽。

那么,TCP为何要在TIME_WAIT状态下等待2MSL时长?

笔者早前撰写过宏观探讨TCP的文章,其中“上篇”已提及此问题。TCP断开连接的交互过程如下。当主动断开连接一方进入TIME_WAIT状态后,它不再需要等待对端的ACK确认,而是直接进入超时等待。这是因为在网络通信中,若要确保发送的数据已被对方接收,需对方发送ACK确认。然而,问题在于对方如何知道其发出的ACK已被接收?如果需要进一步ACK,将陷入无限确认的循环,形成著名的“两将军问题”——两个将军需要在不稳定的信道上协商攻击时间,一方通知另一方“明早8点进攻”,但如何确认对方收到?对方回复“收到,明早8点开干”,但又如何确认这个回复已被第一方收到?这种无限确认导致此问题无完美解决方案。对此,我们在《分布式事务》一文中也有提及。

因此,我们只能通过设置一个最大等待时间来解决两个核心问题:

1. 防止延迟数据段被后续使用相同四元组(源地址、源端口、目标地址、目标端口)的新连接错误接收。尽管可以通过序列号在一定范围内接受数据段,但对于高吞吐量应用,尤其是在具有大接收窗口的快速连接环境下,问题发生的概率依然存在。RFC 1337详细阐述了TIME_WAIT状态不足可能带来的后果,例如缩短TIME_WAIT状态可能导致后续TCP段被不相关的连接接受。

2. 确保远端连接已完全关闭。如果最后一个ACK包丢失,对端将停留在LAST-ACK状态。在没有TIME_WAIT状态的情况下,新连接可能被重新打开,而远端仍认为旧连接有效。此时,若收到SYN段(且序列号匹配),它将返回RST响应,因为不期望此段,从而导致新连接因错误中断。如果远端因最后一个ACK丢失而停留于LAST-ACK状态,则使用相同四元组打开新连接将无法成功。

TIME_WAIT状态的超时时间设定如下:

在macOS系统中,这个时间是15秒,可通过`sysctl net.inet.tcp | grep net.inet.tcp.msl`命令查看。

在Linux系统中,这个时间是60秒,可通过`cat /proc/sys/net/ipv4/tcp_fin_timeout`命令查看。

针对TIME_WAIT过多的问题,通常有以下几种解决方案:

1. 缩短超时时间:适当减小超时值,可加速端口回收。但过短的设置在高流量场景下仍可能导致TIME_WAIT耗尽。

2. 启用`tcp_tw_reuse`:RFC 1323提出了一系列TCP扩展以提升高带宽路径性能,其中包括带有两个四字节时间戳字段的新TCP选项。第一个字段是当前发送选项的TCP时间戳,第二个是从远程主机接收到的最新时间戳。如果新时间戳严格大于前一个连接记录的最新时间戳,Linux将允许在TIME_WAIT状态下重用现有连接用于出站连接。需注意,此参数对入站连接无效。

3. 启用`tcp_tw_recycle`:此参数同样依赖于时间戳选项,并影响入站和出站连接。然而,它可能对NAT环境造成影响,这意味着在同一个公共IP地址后方的所有设备在一分钟内可能无法连接,因为它们不共享相同的时间戳时钟。无疑,禁用此选项是更好的选择,因为它可能导致难以检测和诊断的问题。需要强调的是,自Linux 4.10版本起始,Linux为每个连接随机化时间戳偏移量,导致此选项完全失效;Linux 4.12版本已将其移除。

对于服务器而言,上述三种方法均无法有效解决TIME_WAIT过多的问题。真正的解决之道在于——避免服务器主动断开连接。通过设置KeepAlive,让客户端主动断开连接,服务器端将只会产生CLOSE_WAIT状态。

然而,对于像EaseProbe这类需要建立出站连接的探活工具,启用`tcp_tw_reuse`可以重用TIME_WAIT连接,但这仍不能彻底解决TIME_WAIT过多的问题。

几天后,笔者突然回想起《UNIX网络编程》中提及的一个Socket参数:`SO_LINGER`。在笔者过去的编程生涯中从未实际使用过此设置。此参数主要用于延迟关闭,即在应用程序调用`close()`函数时,如果仍有数据未发送完毕,会等待一段延迟时间以便数据传输完成。但若将延迟时间设为0,Socket将直接丢弃数据,并向对方发送一个RST包来终止连接。由于通过RST包终止,便不会产生TIME_WAIT状态。

`SO_LINGER(0)`绝对不应在服务器端使用,否则客户端将频繁收到“connection reset by peer”的TCP连接错误。但对于EaseProbe这类客户端探活工具,这简直是完美的解决方案。当EaseProbe完成探测后,直接重置连接,既不影响功能,也不影响服务器,更不会产生烦人的TIME_WAIT问题。

在Go语言中实现此功能,`net.TCPConn`结构体提供了一个`SetLinger()`方法。使用起来非常简单:

```go conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout())

if tcpCon, ok := conn.(*net.TCPConn); ok {

tcpCon.SetLinger(0)

} ```

你需要将`net.Conn`类型转换为`net.TCPConn`类型,然后即可调用该方法。

然而,对于Go标准库中的HTTP对象来说,操作会稍微复杂。Go的HTTP库将底层连接对象封装为私有变量,外部无法直接获取。一篇名为《How to Set Go net/http Socket Options – setsockopt() example》的文章给出了以下解决方案:

```go dialer := &net.Dialer{

Control: func(network, address string, conn syscall.RawConn) error {

var operr error

if err := conn.Control(func(fd uintptr) {

operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1)

}); err != nil {

return err

}

return operr

},

}

client := &http.Client{

Transport: &http.Transport{

DialContext: dialer.DialContext,

},

} ```

上述方法涉及底层的`setsockopt`系统调用。尽管如此,笔者仍倾向于使用`TCPConn.SetLinger(0)`的方式。通过阅读Go HTTP包的源代码并进行摸索,最终采用了以下方法:

```go client := &http.Client{

Timeout: h.Timeout(),

Transport: &http.Transport{

TLSClientConfig: tls,

DisableKeepAlives: true,

DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {

d := net.Dialer{Timeout: h.Timeout()}

conn, err := d.DialContext(ctx, network, addr)

if err != nil {

return nil, err

}

tcpConn, ok := conn.(*net.TCPConn)

if ok {

tcpConn.SetLinger(0)

return tcpConn, nil

}

return conn, nil

},

},

} ```

随后,笔者选取了全球排名前一百万的域名数据,并在AWS上部署了一台服务器,生成了针对排名前一万和两万网站的探活脚本,以5秒、10秒、30秒、60秒的间隔进行探测。期间,甚至导致Cloudflare的1.1.1.1 DNS服务一度将笔者拉黑。最终测试结果非常理想,成功消除了TIME_WAIT连接。相关的测试方法、数据和报告均有详细记录。

以下是几点总结:

1. TIME_WAIT是TCP协议完整性的重要保障手段。尽管可能带来一些副作用,但其设计至关重要,不应轻易妥协。

2. 绝不应使用`tcp_tw_recycle`参数,其破坏力巨大。

3. 服务器端永远不要使用`SO_LINGER(0)`;`tcp_tw_reuse`对服务端意义不大,因为它仅对出站流量有效。

4. 服务器端最好不要主动断开连接,应设置KeepAlive以重用连接,让客户端主动断开。

5. 客户端可以使用`tcp_tw_reuse`和`SO_LINGER(0)`。

强烈推荐阅读文章《Coping with the TCP TIME-WAIT state on busy Linux servers》,以获取更深入的理解。

广告位 · 文末横幅