大家好,我是小林。
上周有个读者在面试微信的时候,被问到既然打开 net.ipv4.tcp_tw_reuse 参数可以快速复用处于 TIME_WAIT 状态的 TCP 连接,那为什么 Linux 默认是关闭状态呢?
好家伙,真的问好细节!
当时看到读者这个问题的时候,我也是一脸懵逼的,经过我的一番思考后,终于知道怎么回答这题了。
其实这题在变相问「如果 TIME_WAIT 状态持续时间过短或者没有,会有什么问题?」
因为开启 tcp_tw_reuse 参数可以快速复用处于 TIME_WAIT 状态的 TCP 连接时,相当于缩短了 TIME_WAIT 状态的持续时间。
可能有的同学会问说,使用 tcp_tw_reuse 快速复用处于 TIME_WAIT 状态的 TCP 连接时,是需要保证 net.ipv4.tcp_timestamps 参数是开启的(默认是开启的),而 tcp_timestamps 参数可以避免旧连接的延迟报文,这不是解决了没有 TIME_WAIT 状态时的问题了吗?
是解决部分问题,但是不能完全解决,接下来,我跟大家聊聊这个问题。
TCP 四次挥手过程,如下图:
FIN
标志位被置为 1
的报文,也即 FIN
报文,之后客户端进入 FIN_WAIT_1
状态。ACK
应答报文,接着服务端进入 CLOSED_WAIT
状态。ACK
应答报文后,之后进入 FIN_WAIT_2
状态。FIN
报文,之后服务端进入 LAST_ACK
状态。FIN
报文后,回一个 ACK
应答报文,之后进入 TIME_WAIT
状态ACK
应答报文后,就进入了 CLOSE
状态,至此服务端已经完成连接的关闭。2MSL
一段时间后,自动进入 CLOSE
状态,至此客户端也完成连接的关闭。你可以看到,两个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
可以看到,TIME_WAIT 是「主动关闭方」断开连接时的最后一个状态,该状态会持续 *2MSL(Maximum Segment Lifetime)* 时长,之后进入CLOSED 状态。
MSL 指的是 TCP 协议中任何报文在网络上最大的生存时间,任何超过这个时间的数据都将被丢弃。虽然 RFC 793 规定 MSL 为 2 分钟,但是在实际实现的时候会有所不同,比如 Linux 默认为 30 秒,那么 2MSL 就是 60 秒。
MSL 是由网络层的 IP 包中的 TTL 来保证的,TTL 是 IP 头部的一个字段,用于设置一个数据报可经过的路由器的数量上限。报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃。
MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
设计 TIME_WAIT 状态,主要有两个原因:
为了能更好的理解这个原因,我们先来了解序列号(SEQ)和初始序列号(ISN)。
给大家抓了一个包,下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。
通过前面我们知道,序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
SEQ = 301
报文,被网络延迟了。SEQ = 301
这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL
时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。
假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSED 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。
服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。
为了防止这种情况出现,客户端必须等待足够长的时间确保对端收到 ACK,如果对端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。
但是你可能会说重新发送的 ACK 还是有可能丢失啊,没错,但 TCP 已经等待了那么长的时间了,已经算仁至义尽了。
在 Linux 操作系统下,TIME_WAIT 状态的持续时间是 60 秒,这意味着这 60 秒内,客户端一直会占用着这个端口。要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过如下参数设置指定范围:
net.ipv4.ip_local_port_range
那么,如果如果主动关闭连接方的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
不过,Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的:
要使得上面这两个参数生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1)。
开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS),我们先来介绍这个功能。
序列号是一个 32 位的无符号整型,上限值是 4GB,超过 4GB 后就需要将序列号回绕进行重用。这在以前网速慢的年代不会造成什么问题,但在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。
为了解决这个问题,就需要有 TCP 时间戳。
试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP 报文分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。
32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻B有一个报文丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。
使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。
防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
通过前面这么多铺垫,终于可以说这个问题了。
开启 tcp_tw_reuse 会有什么风险呢?我觉得会有 2 个问题。
我们知道开启 tcp_tw_reuse 的同时,也需要开启 tcp_timestamps,意味着可以用时间戳的方式有效的判断回绕序列号的历史报文。
但是在看我看了防回绕序列号算法的源码后,发现对于 RST 报文的时间戳即使过期了,只要 RST 报文的序列号在对方的接收窗口内,也是能被接受的。
下面 tcp_validate_incoming 函数就是验证接收到的 TCP 报文是否合格的函数,其中第一步就会进行 PAWS 检查,由 tcp_paws_discard 函数负责。
static bool tcp_validate_incoming(struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, int syn_inerr)
{
struct tcp_sock *tp = tcp_sk(sk);
/* RFC1323: H1. Apply PAWS check first. */
if (tcp_fast_parse_options(sock_net(sk), skb, th, tp) &&
tp->rx_opt.saw_tstamp &&
tcp_paws_discard(sk, skb)) {
if (!th->rst) {
....
goto discard;
}
/* Reset is accepted even if it did not pass PAWS. */
}
当 tcp_paws_discard 返回 true,就代表报文是一个历史报文,于是就要丢弃这个报文。但是在丢掉这个报文的时候,会先判断是不是 RST 报文,如果不是 RST 报文,才会将报文丢掉。也就是说,即使 RST 报文是一个历史报文,并不会被丢弃。
假设有这样的场景,如下图:
上面这个场景就是开启 tcp_tw_reuse 风险,因为快速复用 TIME_WAIT 状态的端口,导致新连接可能被回绕序列号的 RST 报文断开了,而如果不跳过 TIME_WAIT 状态,而是停留 2MSL 时长,那么这个 RST 报文就不会出现下一个新的连接。
可能大家会有这样的疑问,为什么 PAWS 检查要放过过期的 RST 报文。我翻了 RFC 1323 ,里面有一句提到:
It is recommended that RST segments NOT carry timestamps, and that RST segments be acceptable regardless of their timestamp. Old duplicate RST segments should be exceedingly unlikely, and their cleanup function should take precedence over timestamps.
大概的意思:建议 RST 段不携带时间戳,并且无论其时间戳如何,RST 段都是可接受的。老的重复的 RST 段应该是极不可能的,并且它们的清除功能应优先于时间戳。
RFC 1323 提到说收历史的 RST 报文是极不可能,之所以有这样的想法是因为 TIME_WAIT 状态持续的 2MSL 时间,足以让连接中的报文在网络中自然消失,所以认为按正常操作来说是不会发生的,因此认为清除连接优先于时间戳。
而我前面提到的案例,是因为开启了 tcp_tw_reuse 状态,跳过了 TIME_WAIT 状态,才发生的事情。
有同学会说,都经过一个 HTTP 请求了,延迟的 RST 报文竟然还会存活?
一个 HTTP 请求其实很快的,比如我下面这个抓包,只需要 0.2 秒就完成了,远小于 MSL,所以延迟的 RST 报文存活是有可能的。
开启 tcp_tw_reuse 来快速复用 TIME_WAIT 状态的连接,如果第四次挥手的 ACK 报文丢失了,有可能会导致被动关闭连接的一方不能被正常的关闭,如下图:
tcp_tw_reuse 的作用是让客户端快速复用处于 TIME_WAIT 状态的端口,相当于跳过了 TIME_WAIT 状态,这可能会出现这样的两个问题:
虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。
《UNIX网络编程》一书中却说道:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。