我为Netty贡献源码|且看Netty如何应对TCP连接的正常关闭(四)
3. TCP 连接的异常关闭
image.png
在本文前边的内容中,我们介绍了 TCP 数据包中的 SYN 包,FIN 包,ACK 包的使用场景,它们都是通过 TCP 首部协议中的 8 位控制位来标识,不同的控制位代表不同的含义。
第二小节介绍的内容均属于 TCP 在正常情况下进行的连接的建立,发送数据,关闭连接。
而现实中情况往往是复杂的,TCP 连接不可能总是处于正常的状态,那么当 TCP 连接出现异常时,就需要有一种机制让我们来强制关闭连接,这个就是本小节要介绍的 RST 包用于异常情况下强制关闭 TCP 连接。
由于 RST 包是用来处理 TCP 连接的异常情况的,所以当本端发送一个 RST 包给对端之后,并不需要对端回复 ACK 确认包。
通讯方不管是发出或者是收到一个 RST 包 ,都会导致内存,端口等连接资源被释放,并且跳过正常的 TCP 四次挥手关闭流程直接强制关闭,Socket 缓冲区的数据来不及处理直接被丢弃。
当通讯端收到一个 RST 包后,如果仍然对 Socket 进行读取,那么就会抛出 connection has been reset by the peer 异常,如果仍然对 Socket 进行写入,就会抛出 broken pipe 异常。应用程序通过这样的方式来感知内核是否收到 RST 包。
发送 RST 强制关闭连接,这将导致之前已经发送但尚未送达的、或是已经进入对端 Socket 接收缓冲区但还未被对端应用程序处理的数据被无条件丢弃,导致对端应用程序可能会出现异常。
说了这么多,那么究竟会有哪些场景导致需要发送 RST 来强制关闭连接呢?下面笔者就来为大家一一梳理下:
3.1 TCP 连接队列已满
TCP连接过程.png
我们先根据上面这副图来看一下一个正常的 TCP 连接建立的过程:
1.客户端向服务端发送 SYN 包请求建立 TCP 连接。客户端连接状态变为 SYN_SENT 状态。
2.服务端收到 SYN 包之后,服务端连接状态变为 SYN_RECV 状态。随后会创建轻量级 request_sock 结构来表示连接信息(里面能唯一确定某个客户端发来的 SYN 的信息),并将这个 request_sock 结构放入 TCP 的半连接队列 SYN_Queue 中,TCP 内核协议栈发送 SYN+ACK 包给客户端。
3.客户端的 TCP 内核协议栈收到服务端发送过来的 SYN+ACK 后,随即回复 ACK 包给服务端。此时客户端连接状态变为 ESTANLISHED 状态。
4.服务端收到客户端的 ACK 包之后,从半连接队列中查找是否有代表该客户端连接的轻量级 request_sock 结构,如果有,连接状态变为 ESTABLISHED 状态,随后会从半连接队列 SYN-Queue 中将 request_socket 结构取出移动到全连接队列 ACCEPT-Queue 中。
5.用户进程的 accpet 系统调用根据监听 Socket 克隆出一个真正的连接 Socket 然后返回。
从 TCP 建立连接的过程我们看到,这里涉及到两个重要的队列,一个存放客户端 SYN 信息的半连接队列 SYN-Queue ,另一个是存放完成三次握手的客户端连接信息的全连接队列 ACCEPT-Queue 。
那么只要是队列它就会有长度的限制,就可能会满。那么在这两个连接队列已满的状况下,又会发生什么情况呢?
3.1.1 半连接队列 SYN-Queue 已满
SYN FLOOD.png
假设现在有大量的客户端在向服务端发送 SYN 包请求建立连接,但是这些客户端比较坏,在收到服务端的 SYN+ACK 包之后就是不回复 ACK 包给服务端,而服务端一直收不到客户端的 ACK 包,所以就会重传 SYN+ACK 包给客户端,重传次数由内核参数 tcp_synack_retries 限制,默认为 5 次。
$ cat /proc/sys/net/ipv4/tcp_synack_retries
5
这 5 次的重传时间间隔为 1s , 2s , 4s , 8s , 16s ,总共 31s ,而第 5 次重传的 SYN+ACK 包发出后还要等 32s 才能知道第 5 次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 63s ,TCP 才会把断开这个连接,并从半连接队列中移除对应的 request_sock 。
我们可以看到 TCP 内核协议栈需要等待 63s 的时间才能断开这个半连接,假设这 63s 内又有大量的客户端这样子搞事情,那么很快服务端的半连接队列 SYN-Queue 堆积的 request_sock 就会越来越多最终溢出。
当半连接队列溢出之后,再有正常的客户端连接进来之后,内核协议栈默认情况下就会直接丢弃 SYN 包,导致服务端无法处理正常客户端的请求,这就叫做 SYN Flood 攻击。
有一个内核参数 net.ipv4.tcp_syncookies 可以影响内核处理半连接队列溢出时的行为:
•net.ipv4.tcp_syncookies = 0 :服务端直接丢弃客户端发来的 SYN 包。
•net.ipv4.tcp_syncookies = 1 :如果此时全连接队列 ACEPT-Queue 也满了,并且 qlen_young 的值大于 1 ,那么直接丢弃 SYN 包,否则就生成 syncookie(一个特别的 sequence number )然后作为 SYN + ACK 包中的序列号返回给客户端。并输出 "possible SYN flooding on port . Sending cookies."。
qlen_young 表示目前半连接队列中,没有进行 SYN+ACK 包重传的连接数量。
随后客户端会在 ACK 包中将这个 syncookie 带上回复给服务端,服务端校验 syncookie ,并根据 syncookie 的信息构造 request_sock 结构放入全连接队列中。
从以上过程我们可以看出在开启 tcp_syncookies 的情况下,服务端利用 syncookie 可以绕过半连接队列从而完成建立连接的过程。我们可以利用这种方式来防御 SYN Flood 攻击。
但是 tcp_syncookies 不适合用在服务端负载很高的场景,因为在启用 tcp_syncookies 的时候,服务端在发送 SYN+ACK 包之前,会要求客户端在短时间内回复一个序号,这个序号包含客户端之前发送 SYN 包内的信息,比如 IP 和端口。
如果客户端回复的这个序号是正确的,那么服务端就认为这个客户端是正常的,随后就会发送携带 syncookie 的 SYN+ACK 包给客户端。如果客户端不回复这个序号或者序号不正确,那么服务端就认为这个客户端是不正常的,直接丢弃连接不理会。
从这个过程中,我们可以看出当启用 tcp_syncookies 的时候,这个建立连接的过程并不是一个正常的 TCP 三次握手的过程,因为服务端在发送 SYN+ACK 包之前还需要等待客户端回复一个序号,这就产生了一定的延迟,所以 tcp_syncookies 不适合用在服务端负载很高的场景,但是一般的负载情况还是比较有效防御 SYN Flood 攻击的方式。
除此之外,我们还可以调整以下内核参数来防御 SYN Flood 攻击
•增大半连接队列容量 tcp_max_syn_backlog 。设置比默认 256 更大的一个数值。
•减少 SYN+ACK 重试次数 tcp_synack_retries 。
3.1.2 全连接队列 ACCEPT-Queue 已满
当服务端的负载比较大并且从全连接队列中 accept 连接处理的比较慢,同时又有大量新的客户端连接上来的时候,就会导致 TCP 全连接队列溢出。
内核参数 net.ipv4.tcp_abort_on_overflow 会影响内核协议栈处理全连接队列溢出的行为。
•当客户端发来三次握手最后一个 ACK 包时,但此时服务端全连接队列已满:
当 tcp_abort_on_overflow = 0 时,服务端内核协议栈会将该连接标记为 acked 状态,但仍保留在 SYN-Queue 中,并开启 SYN+ACK 重传机制。当 SYN+ACK 包的重传次数超过 net.ipv4.tcp_synack_retries 设置的值时,再将该连接从 SYN queue 中删除。但是此时在客户端的视角来说,连接已经建立成功了。客户端并不知道此时 ACK 包已经被服务端所忽略,如果此时向服务端发送数据的话,服务端会回复 RST 给客户端。
全连接队列满0.png
•当 tcp_abort_on_overflow = 1 时, 服务端TCP 协议栈直接回复 RST 包,并直接从 SYN-Queue 中删除该连接信息。
全连接队列已满1.png
面对全连接队列溢出的情况,我们需要及时增大全连接队列的长度,而全连接队列的长度由两个参数控制:
•内核参数 net.core.somaxconn,默认 128 。
•listen 系统调用方法参数 backlog 。
int listen(int sockfd, int backlog)
在 Netty 中我们可以通过如下配置指定:
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 全连接队列长度)
全连接队列 ACCEPT-Queue 的长度由 min(backlog, somaxconn) 决定,所以当全连接队列满时,我们需要检查如下设置:
•调整内核参数 net.core.somaxconn。
•检查应用程序中的 backlog 参数。
•设置 tcp_abort_on_overflow = 1 。
3.2 连接未被监听的端口
连接未LISTEN端口RST.png
当客户端 Connect 一个未被监听的远端服务端口,则会收到对端发来的一个 RST 包。
客户端要连接的端口未被监听,有两种情况:
•该端口在服务端从来没有应用程序监听过。
•服务端监听该端口的应用程序崩溃挂掉了。
3.3 服务端程序崩溃
服务端程序崩溃RST.png
TCP 连接正常的状态下,无论是连接时发送的 SYN ,还是连接建立成功后发送的正常数据包,以及最后关闭连接时发送的 FIN ,都会收到对端的 ACK 确认。
当服务端因为某种原因导致崩溃之后,客户端再次向服务端发送数据,就会收到 RST 。
3.4 开启 SO_LINGER 选项设置 l_linger = 0
RST关闭连接.png
在前边《2.1.3 针对 SO_LINGER 选项的处理》小节我们介绍 SO_LINGER 选项的时候提到过,当我们将选项参数设置为 l_onoff = 1,l_linger = 0 时,当客户端调用 close 方法关闭连接的时候,这时内核协议栈会发出 RST 而不是 FIN 。跳过正常的四次挥手关闭流程直接强制关闭,Socket 缓冲区的数据来不及处理直接丢弃。
3.5 主动关闭方在关闭时 Socket 接收缓冲区还有未处理数据
客户端提前关闭Socket RST.png
•主动关闭方在调用 close() 系统调用关闭 Socket 时,内核会检查 Socket 接收缓冲区中是否还有数据未被读取处理,如果有,则直接清空 Socket 接收缓冲区中的未处理数据,并向对端发送 RST 。
•如果此时 Socket 接收缓冲区中没有未被处理的数据,内核才会走正常的关闭流程,尝试将 Socket 发送缓冲区中的数据发送出去,然后向对端发送 FIN ,走正常的四次挥手关闭流程。
3.6 主动关闭方 close 关闭但在 FIN_WAIT2 状态接收数据
close关闭但FIN_WAIT2接收数据 RST.png
TCP是一个面向连接的、可靠的、基于字节流的全双工传输层通信协议,既然它是全双工的,那就意味着TCP连接同时有一个读通道和写通道。
image.png
而调用 close() 来关闭连接,意味着会将读写通道同时关闭,之后不能再读取数据。
如果客户端调用 close() 方法关闭连接,而服务端在 CLOSE_WAIT 状态下继续向客户端发送数据,客户端在 FIN_WAIT2 状态下直接会丢弃数据,并发送 RST 给服务端,直接强制关闭连接,也是个暴脾气,哈哈。