IM长连接网关:深入探究 Connection reset by peer
在当初开发 Mercury 网关的过程中,遇到了不少问题,其中比较令人头疼的就是 connection reset by peer 异常了。每天网关都会出现不少这种异常,我们对这种异常做了cat 打点,可以看到每分钟都会出现一些。
出现 Connection reset by peer 表示当前服务器接受到了socket对端发送的TCP RST信号,即socket对端已经关闭了连接,通过RST信号希望接收方关闭连接。 下面我们就来深入探究一下 RST报文
什么是RST
TCP 报文头的格式如下:
序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:
- ACK:该位为
1
时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的SYN
包之外该位必须设置为1
。 - RST:该位为
1
时,表示 TCP 连接中出现异常必须强制断开连接。 - SYN:该位为
1
时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。 - FIN:该位为
1
时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换FIN
位为 1 的 TCP 段。
我们都知道 TCP 正常情况下是通过四次挥手来断开连接的,那是正常时候的优雅做法。
但异常情况下,收发双方都不一定正常,连挥手这件事本身都可能做不到,所以就需要一个机制去强行关闭连接。
RST 就是用于这种情况,一般用来异常地关闭一个连接。它是一个TCP包头中的标志位。
正常情况下,不管是发出,还是收到置了这个标志位的数据包,相应的内存、端口等连接资源都会被释放。从效果上来看就是TCP连接被关闭了。
而接收到 RST的一方,一般就会看到一个 Connection reset 的错误。
感知RST
内核跟应用层是分开的两层,网络通信功能在内核,我们的客户端或服务端属于应用层。应用层只能通过 send/recv
与内核交互,才能感知到内核是不是收到了 RST
。
当本端收到远端发来的RST
后,内核已经认为此链接已经关闭。
- 此时如果本端应用层尝试去执行 读数据操作,比如
recv
,应用层就会收到 Connection reset by peer 的报错,意思是远端已经关闭连接。 - 如果本端应用层尝试去执行写数据操作,比如
send
,那么应用层就会收到 Broken pipe 的报错,意思是发送通道已经坏了。
出现RST的场景
RST一般出现于异常情况,主要分为2类: 对端的端口不可用 和 socket提前关闭。
端口不可用
端口不可用分为两种情况。要么是这个端口从来就没有可用过,比如根本就没监听**(listen)**过;要么就是曾经可用,但现在不可用了,比如服务突然崩溃了。
当尝试和未开放的服务器端口建立tcp连接时,服务器tcp将会直接向客户端发送reset报文
服务端listen
方法会创建一个sock
放入到全局的哈希表
中。
此时客户端发起一个connect
请求到服务端。服务端在收到数据包之后,第一时间会根据IP和端口从哈希表里去获取sock
。
如果服务端执行过listen
,就能从全局哈希表
里拿到sock
。
但如果服务端没有执行过listen
,那哈希表
里也就不会有对应的sock
,结果当然是拿不到。此时,正常情况下服务端会发RST
给客户端。
但端口未监听并不一定会发RST
发RST的前提是正常情况下,如果在发送端到接收端传输过程中,数据发生任何改动,比如被第三方篡改,那么接收方能检测到校验和有差错,此时TCP段会被直接丢弃。如果校验和没问题,那才会发RST。
校验和可以验证数据从端到端的传输中是否出现异常。由发送端计算,然后由接收端验证。计算范围覆盖数据包里的TCP首部和TCP数据。
所以,只有在数据包没问题的情况下,比如校验和没问题,才会发RST包给对端。
为什么数据包异常的情况下,不发RST呢?主要是一个数据包连校验都不能通过,那这个包,多半有问题。
有可能是在发送的过程中被篡改了,又或者,可能只是一个胡乱伪造的数据包。
五层网络,不管是哪一层,只要遇到了这种数据,推荐的做法都是默默扔掉,而不是去回复一个消息告诉对方数据有问题。
如果对方用的是TCP,是可靠传输协议,发现很久没有ACK
响应,自己就会重传。
如果对方用的是UDP,说明发送端已经接受了“不可靠会丢包”的事实,那丢了就丢了。
TCP监听了但是崩溃了
端口不可用的场景里,除了端口未监听以外,还有一种情况:
双方之前已经正常建立了通信通道,也可能进行过了交互,当某一方在交互的过程中发生了异常,如崩溃等,此时客户端还像往常一样正常发送消息,服务器内核协议栈收到消息后,则会回一个RST通知对方将连接关闭;
这种情况跟端口未监听本质上类似,在服务端的应用程序崩溃后,原来监听的端口资源就被释放了,从效果上来看,类似于处于CLOSED
状态。
此时服务端又收到了客户端发来的消息,内核协议栈会根据IP端口,从全局哈希表里查找sock
,结果当然是拿不到对应的sock
数据,于是走了跟上面端口未监听时一样的逻辑,回了个RST
。客户端在收到RST后也释放了sock资源,从效果上来看,就是连接断了。
socket提前关闭
这种情况分为本端提前关闭,和远端提前关闭。
本端提前关闭
如果本端socket
接收缓冲区还有数据未读,此时提前close socket。那么本端会先把接收缓冲区的数据清空,然后给远端发一个RST。
远端提前关闭
远端已经close
了socket
,此时本端还尝试发数据给远端。那么远端就会回一个RST。
对于 Mercury 网关来说,出现的 Connection reset by peer 异常主要都是由于这个原因导致的。
对端没收到RST
TCP是可靠传输,意味着本端发一个数据,对端在收到这个数据后就会回一个ACK
,意思是"我已经收到这个包了"。 而RST,不需要ACK确认包。
因为RST
本来就是设计来处理异常情况的,既然都已经在异常情况下了,所以没有必要对这种情况也回复ACK。
RST丢了,问题不大。比方说上图服务端,发了RST之后,服务端就认为连接不可用了。
如果客户端之前发送了数据,一直没等到这个数据的确认ACK,就会重发,重发的时候,自然就会触发一个新的RST包。
而如果客户端之前没有发数据,但服务端的RST丢了,TCP有个keepalive机制,会定期发送探活包,这种数据包到了服务端,也会重新触发一个RST。而且一般对于网关来说,都会在应用层实现主动探测机制,像 Mercury 网关就实现了主动探测功能,详细可以看一下之前写的《IM长连接网关:连接探测》一文。
收到RST不一定会断开连接
收到RST包,第一步会通过tcp_sequence
先看下这个seq是否合法,其实主要是看下这个seq是否在合法接收窗口范围内。如果不在范围内,这个RST包就会被丢弃。
这里黄色的部分,就是指接收窗口,只要RST包的seq不在这个窗口范围内,那就会被丢弃。
正常情况下客户端服务端双方可以通过RST来断开连接。假设不做seq校验,如果这时候有不怀好意的第三方介入,构造了一个RST包,且在TCP和IP等报头都填上客户端的信息,发到服务端,那么服务端就会断开这个连接。同理也可以伪造服务端的包发给客户端。这就叫RST攻击。
受到RST攻击时,从现象上看,客户端老感觉服务端崩了,这非常影响用户体验。
实际消息发送过程中,接收窗口是不断移动的,seq也是在飞快的变动中,此时第三方是比较难构造出合法seq的RST包的,那么通过这个seq校验,就可以拦下了很多不合法的消息。
Netty 处理RST
在 netty_unix_errors.c 文件中注册了一些 error 错误处理
netty 会把native 层错误包装成 java 层的异常抛出来
通过上面的分析,大家应该也能知道了 Mercury网关出现的 Connection reset by peer 异常,其实是客户端由于网络或者设备系统原因(比如切到后台一定时间进程被杀掉等)导致socket 异常关闭了,等到服务端再次发送消息时就会收到 RST 报文,netty 会把这个错误抛给业务层,并关闭连接。
对于这种异常,其实对服务端来说是无能为力的,所以我借鉴了 Netty 里面 SslHandler 的实现,在 ChannelHandler 的 exceptionCaught 方法中过滤 connection reset 或者 broken pipe 等异常,做了 cat 打点以做观察,并没有其他处理。
private static final Pattern IGNORABLE_ERROR_MESSAGE = Pattern.compile( "^.*(?:connection.*(?:reset|closed|abort|broken)|broken.*pipe).*$", Pattern.CASE_INSENSITIVE);
io.netty.handler.ssl.SslHandler#exceptionCaught