网络抓包实战 05——深入浅出连接关闭

连接建立后,会占用一定的系统资源,而服务器的资源是有限的。因此,当连接服务完成后,需要及时去关闭连接,以释放资源。
连接关闭是相对比较复杂的一个过程,大致原因如下:
1. TCP 连接是全双工的
2. 老的重复数据包可能会干扰连接关闭过程
3. 需要考虑如何抵御网络攻击
4. 需要考虑高吞吐量的需求
5. 需要减少网络资源的消耗
TCP 本身是全双工通信,两端都可以自主选择关闭连接。客户端关闭连接代表客户端不再发送数据,服务器端关闭连接代表服务器端不再发送数据。客户端关闭连接,不代表服务器端也需要立即关闭连接。
由于网络存在着延迟,并且路由器可能会阻塞数据包的转发,导致一些老的数据包在互联网游荡。如果连接被快速关闭,这些老的数据包最终到达目的地后,可能会造成 TCP 错误的行为,因此需要保持 TCP 状态一段时间,以便优雅地去处理这些数据包。
操作系统 TCP 为了抵御潜在的网络攻击,有时会破坏 RFC 规则,自主选择某种超时关闭机制。这种机制在规避网络攻击的同时,也增加了解决问题的复杂性。
服务器端往往需要大量 TCP 连接,而 TCP 能够提供的资源是有限的,为了能够快速利用各种资源,服务器端可能会破坏一些规则,以达到高吞吐量的需求。
程序的不合理使用或者连接长久空闲,可能会导致网络资源消耗巨大,例如某些云平台为了降低自身资源消耗,会采取一定的措施来杀连接,给用户带来困扰。
连接关闭可以分为正常连接关闭和异常连接关闭,我们先讲述正常连接关闭是如何关闭的。
1. 正常关闭连接
正常关闭分为四次挥手和三次挥手,四次挥手是需要 4 个数据包交互才能完成连接关闭,而三次挥手是需要 3 个数据包交互才能完成连接关闭。
1.1. 四次挥手

上图展示了四次挥手过程,客户端(client)首先关闭连接,发送 FIN seq=x,ack=y 数据包到服务器端。服务器端 TCP 接收到 FIN 数据包,把状态置为 CLOSE_WAIT,发送 ACK seq=y,ack=x+1 给客户端,并通知上层应用客户端已经关闭连接。客户端接收到服务器端的 ACK 数据包,把 TCP 状态置为 FIN_WAIT2,等待服务器端的 FIN 数据包过来。服务器端上层应用程序接收到 TCP 的关闭连接通知后,调用 close()函数委托服务器 TCP 去负责连接关闭。服务器 TCP 把连接状态置为 LAST_ACK,并发送 FIN seq=y,ack=x+1 到客户端。客户端 TCP 接收到服务器端发过来的 Fin 数据包,发送 ACK seq=x+1,ack=y+1 给服务器端, 把 TCP 状态置为 TIME_WAIT(维持 2 倍报文最大生存时间,即 2MSL,Linux 维持 60 秒)。服务器端 TCP 接收到客户端过来的 ACK 确认数据包,把 TCP 状态置为 CLOSED 状态,这时此连接在服务器端彻底关闭。等待一段时间后,客户端 TCP 把 TIME_WAIT 状态置为 CLOSED 状态,这时连接彻底关闭。
下图展示了服务器端先关闭连接的过程。

下图从抓包文件里展示了服务器端先关闭连接的四次挥手过程。

1.2 三次挥手
三次挥手相比四次挥手,少了一次挥手。
下图展示了服务器端把第二次挥手和第三次挥手进行了合并。

下图展示了客户端合并了第二次和第三次挥手过程。

下图展示了抓包文件里的三次挥手过程。

第二次和第三次挥手合并的好处是节省了一个数据包,采用三次挥手必须满足的一个条件是上层应用能够及时去关闭连接。如果上层应用不能及时关闭连接,就很难有三次挥手过程,因为 TCP 无法自己发送 FIN 数据包。FIN 数据包是应用层执行 close 操作后,委托给 TCP 去发送的。
2、异常关闭连接
四次挥手过程,如果某一个过程出现异常怎么办?永久处于那个 TCP 状态会引起资源的泄漏,而不永久关闭就可能违反 TCP 规范,这是一个两难的问题。
由于这方面的坑特别多,我们本节简单讲述异常关闭连接,后续章节会详细讲述更多的案例。
2.1 reset 关闭连接

上图展示了遇到 reset 数据包来异常终止连接的一些常见状态:
SYN_RCVD
SYN_SENT
ESTABLISHED
FIN_WAIT_1
FIN_WAIT_2
CLOSE_WAIT
处于这些状态,如果接收到 reset 数据包,会瞬间把 TCP 状态置为 CLOSED 状态,从而无法跟踪和查看 TCP 状态。
TCP reset 数据包是非常有争议的数据包,这种数据包瞬间清零的做法,在防火墙、负载均衡器和路由器等设备程序中广泛存在,甚至在 windows 系统也时常用于关闭连接。

上图 windows 系统先发送 FIN 数据包到服务器,在服务器端还没有发送 FIN 数据包给客户端时(服务器相应的 TCP 连接处于 CLOSE_WAIT),客户端又发送 reset 数据包给服务器来清理连接资源。

上图客户端先发送了 FIN 数据包,代表客户端不再发送数据到服务器端,服务器端 TCP 进行了 ack 确认,这时客户端处于 FIN_WAIT2 状态,服务器端 TCP 处于 CLOSE_WAIT 状态。服务器端 TCP 还在继续传递数据给客户端,最终客户端发送了 reset 数据包拒绝了接收数据,同时把服务器端的 TCP 状态置为了 CLOSED 状态。

上图端口 9998 是服务器端应用端口,42017 端口是客户端端口,在 reset 数据包之前,双方连接处于 ESTABLISHED 状态,而后 reset 数据包把客户端连接状态瞬间置为 CLOSED 状态。
上述连接通过 reset 瞬间关闭的做法,在实际中很普遍,而这些做法多多少少违反了 RFC 规范,有时会给用户带来烦恼。
2.2 超时清理连接状态
连接关闭通常是由上层应用驱动的,应用程序如果不关闭连接或不及时关闭连接,会浪费资源,可能最终导致服务不可用。
我们通过一个实验来查看 Linux 系统是如何通过超时来关闭连接的。
通过 MySQL 客户端终端登入 MySQL,将 global interactive_timeout 和 global wait_timeout 均设置为 120 秒,如下图:

设置后退出 MySQL 客户端终端。
利用 MySQL 客户端终端再次登入,查看变量是否生效,之后一直不操作,如下图:

我们通过 netstat 来查看连接状态,连接已经是 ESTABLISHED 状态。

由于 MySQL 客户端终端长期不操作,MySQL 会关闭连接,发送 FIN 数据包给客户端,而客户端由于不操作,不会去执行关闭操作,所以会一直处于 CLOSE_WAIT 状态,如下图:

在服务器端,我们查看相应状态已经为 FIN_WAIT2 状态。

过了 1 分钟,服务器端相应 TCP 状态消失,因为服务器端 tcp_fin_timeout 默认只有 60 秒。

抓包结果如下:

服务器发送 FIN 数据包给客户端后,客户端 TCP 进行了确认,但后续并没有发送 FIN 数据包给服务器端,所以状态一直处于 CLOSE_WAIT。
这时客户端连接,如何关闭呢?要么服务器发送 reset 数据包(上面抓包文件显示没有),要么 MySQL 客户端终端自己执行关闭操作,否则连接一直处于 CLOSE_WAIT 状态。
这里讲述了 Linux 由于超时清理了服务器端的连接状态,而客户端连接一直僵持在 CLOSE_WAIT 状态。
Linux 不仅采用超时机制,还会采用 keepalive 机制来清理 TCP 状态("遇到大量 FIN_WAIT1,怎么破"章节会有介绍),甚至还会因为内存紧张杀连接,如下图。

3、连接关闭与应用的关系
3.1 正常关闭连接
正常关闭连接情况下,如果对端发送了 FIN 数据包过来,TCP 会通知上层应用有读事件,应用去读取数据。如果读取的长度为 0,意味着对端连接的关闭。这个时候可以选择继续传输数据,也可以选择关闭连接。大部分场景下,会选择关闭连接。
为防止对对端的数据还没有接收完就关闭连接,TCP 提供了选项 SO_LINGER 来延缓连接关闭。
3.2 异常关闭连接
下图展示了遇到 reset 数据包,Linux TCP 是如何工作的。

当 TCP 状态处于 SYN_SENT(图中 TCP_SYN_SENT),如果遇到 reset,则会向上层应用报 connection refused 错误(ECONNREFUSED);当 TCP 状态处于 CLOSE_WAIT 状态,TCP 会向上层报 pipe 错误(EPIPE);当 TCP 状态处于 CLOSED 状态,则不会做任何动作;其它情况下,TCP 会向上层应用报 connection reset 错误(ECONNRESET)。
下图中,当发送第一次握手数据包后,客户端连接 TCP 状态为 SYN_SENT,结果遇到了服务器的 reset 数据包,客户端 TCP 会向 telnet 上层应用报“Connection refused”错误。


4、经典案例
下图是用户遇到的一个问题,服务器(443 端口)先关闭连接,客户端 TCP 进行了 ack 确认,然后客户端继续传输 Encrypted Alert 数据包,接着客户端发送 FIN 数据包到服务器,但后续一直重传 Encrypted Alert 数据包。

看似连接关闭没有问题,为什么一直在重传呢?
我们看一下时间戳信息。

上图展示了在发送 Encrypted Alert 数据包时(第 147 个数据包),上一个 ack 确认数据包在 50 多分钟前发送。对服务器 FIN 数据包的确认,导致客户端处于 CLOSE_WAIT 状态,而服务器端处于 FIN_WAIT2 状态。操作系统为 Linux 系统,默认 tcp_fin_timeout 为 1 分钟,而时间过去了 50 多分钟,TCP 连接状态 FIN_WAIT2 已被 Linux 超时机制变成 CLOSED 状态,即连接在服务器端已经不存在。
TCP 连接,在客户端是 CLOSE_WAIT 状态,而在服务器已经不存在。在 17:31:37,客户端发送 Encrypted Alert 数据包过去,按道理服务器 TCP 会发送 reset 数据包回来(根据 reset 发送规则,后续课程会有介绍),但从抓包文件来看,却没有 reset 数据包。我们可以推断,Encrypted Alert 数据包和后面的 FIN 数据包,都被中途设备程序丢弃了,导致客户端 TCP 只能选择重传 Encrypted Alert 数据包。
这个案例中,重传问题的产生有如下三大原因:
1. 客户端没有及时去关闭连接
2. 服务器过早通过超时机制来清理 TCP 连接状态
3. 中途设备程序没有转发数据包到服务器端
如果服务器没有通过 tcp_fin_timeout 来清理 FIN_WAIT2 状态,会导致服务器端积累大量 FIN_WAIT2 状态,进而可能导致服务器资源耗光,服务不可用。如果通过 tcp_fin_timeout 来清理 FIN_WAIT2 状态,则会导致客户连接还健在,服务器端连接不存在的问题。中途设备程序可能干涉数据包的发送(例如,中途设备程序进行了 connection tracking,定期把不活跃的连接信息清除了,一旦路由的时候找不到数据包的相关连接信息,于是丢弃该数据包)。
评论