一次"Connection Reset"的根因和修改方式调查

1. 背景

在我们的一个项目中提供了REST接口,并通过 VIP(浮动IP) 后接四台Server的方式对外提供服务。客户端是一个自动化系统,会周期性地传递大量需要自动化运行的任务。在测试一段时间后,提出一个 Issue。其主要内容为: Client上运行周期性的测试任务(10分钟一次),运行一段时间后,其到Server的连接会随机性地产生 Connection Reset 异常
日志如下(已将敏感信息进行了替换):

2018-11-14 09:09:24.504 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset
...
2018-11-14 08:39:24.352 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset
...
2018-11-14 05:30:21.564 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset
...
2018-11-14 04:10:07.279 [WARN ] com.xxxx.XxxException: Failed to get Server's response: javax.ws.rs.ProcessingException: java.net.SocketException: Connection reset

2. 问题确认

  • 在Kerbose服务器上, 通过 dsh 运行netstat 确认,发现服务器集群上有大量从客户端来的连接(替换了Server和IP,并裁剪了部分数据,实际单客户端连接到服务器集群上的连接数为 80+)
$ dsh -M -g our-real-servers "netstat -anp | grep ESTABLISHED | grep :8080 | grep 192.168.224.108" 2>/dev/null

server01: tcp        0      0 192.168.105.67:8080      192.168.224.108:4792      ESTABLISHED 5765/java
server01: tcp        0      0 192.168.105.67:8080      192.168.224.108:57492     ESTABLISHED 5765/java
server01: tcp        0      0 192.168.105.67:8080      192.168.224.108:60752     ESTABLISHED 5765/java
server02: tcp        0      0 192.168.105.67:8080      192.168.224.108:22964     ESTABLISHED 15663/java
server02: tcp        0      0 192.168.105.67:8080      192.168.224.108:6395      ESTABLISHED 15663/java
server02: tcp        0      0 192.168.105.67:8080      192.168.224.108:10086     ESTABLISHED 15663/java
server03: tcp        0      0 192.168.105.67:8080      192.168.224.108:8398      ESTABLISHED 159482/java
server03: tcp        0      0 192.168.105.67:8080      192.168.224.108:63721     ESTABLISHED 159482/java
server04: tcp        0      0 192.168.105.67:8080      192.168.224.108:40387     ESTABLISHED 17637/java
server04: tcp        0      0 192.168.105.67:8080      192.168.224.108:13365     ESTABLISHED 17637/java
...
  • 和客户确认后知道其也有四台机器,使用 HttpClient 的连接池技术连接到VIP,连接池数量为 100.此时的网络拓扑为:
    网络拓扑

3. 初步调查

  • 经过Google和度娘搜索,发现很多使用 HttpClient 调用后台resetful服务时常会出现这个错误,解释一般都是服务器端因为某种原因(如网络中没有数据)关闭了Connection,但具体原因及确认方法多半语焉不详。
  • 至于解决方案,很多时候是要求客户端重试处理(如加入 retryHandler )或使用短链接(HTTP.CONN_CLOSE),或要求设置 IdelTime 等。
  • 和客户沟通后,客户认为他们正确的使用了连接池,怀疑是服务器在某些时候(比如空闲时)关闭了连接,希望我们检查服务器的超时策略,最大请求设置及服务器的KeepAlive设置等,当然他们也会加入重试逻辑来尽量从这种错误中恢复。
  • 我们的服务器使用了 vertx, 经过检查和测试,在相关位置使用的是缺省值:没有 keep-alive(如 HTTP/1.0) 的连接当响应结束的话,会自动关闭连接;但如果是 HTTP/1.1 或有 Keep-alive的话,就不会主动关闭了。而且也没有配置 TCPSSLOptions.idleTimeout 的值,保持默认(0),根本不会主动断开连接。
  • 此时和客户似乎陷入互不认可调查结论的境地,双方都认为自己的代码正确,那问题到底出在哪里?难道真得只能像网上所说,在不清楚根因的情况下使用 短链接(Connection:Close) ,每次都关闭连接,牺牲性能来换稳定性?

4. 深入调查

4.1. 调查目的

  • 确认整个系统中,究竟在哪个部分,在什么情况下会断开客户端的连接;
  • 在找到断开连接根因的情况下,确认最佳的修改方式

4.2. 背景知识介绍

4.2.1. TCP Keep-Alive Vs. HTTP Keep-Alive

很多人看到这个标题,可能会突然发现居然有两个 Keep-Alive 概念。这两个是完全不同的概念,在不同的层起作用,也受不同的参数影响。

  • TCP Keep-Alive: 在TCP层的Socket上设置,设置参数为: SO_KEEPALIVE. 参见 Socket.setKeepAlive,相关的参数还有 TCP_KEEPIDLE 等。在TCP连接建立之后, 如果网络上没有传递数据, TCP会自动发送一个数据为空的报文(侦测包)给对方,如果对方回应了这个报文,说明对方还在线,连接可以继续保持,如果对方没有报文返回,并且重试了多次之后则认为链接丢失,没有必要保持连接。. 在Wireshark 中抓包的话, 会显示 [TCP Keep-Alive] 和 [TCP Keep-Alive ACK].
    TCP Keep-Alive

  • HTTP Keep-Alive:在 Http 层通过 Connection: keep-alive 的Header设置, 表示客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive功能避免了建立或者重新建立连接。WireShare 截图为:
    Http Keep-Alive

  • 从上面的描述可知, TCP Keep-Alive 是为了保持连接不会因为没有数据而被关闭,是真正的“保活”,而HTTP Keep-Alive 是为了提高性能,而重用已经打开的连接。那么常见的 “Connection Reset” 问题的根因应该就和 TCP Keep-Alive相关,之后的分析我也会优先从此入手。此时可能有人已有疑问,网上常见的解决方式都是更改 Http 层的 Keep-Alive, 为什么我会说根因是 TCP 层的,具体原因之后会分析。

4.2.2. netstat 的 -o | --timers 选项

注意,这个选项似乎只有Linux上有,Win/Mac都没有,其作用是显示连接中网络时间相关的部分(Include information related to networking timers). 其中Timer列主要有以下几种类型:

  • off: 表示没有启用TCP层的 KeepAlive
  • keepalive : 启用了 TCP层的 KeepAlive,第一个参数就是倒计时,减到0时会发送 [TCP Keep-Alive] 包;
  • timewait: 对应等待(TIME_WAIT)时间计时
  • on: 重发(retransmission)的时间计时
    netstat -ant --timers经过检查,客户SDK连接我们的Servers时,都是off状态,即没有启用 TCP Keep-Alive 的。

4.3. 实测验证

4.3.1 测试方法

  • 考虑到 Connection Reset 的发生是Socket上很长时间没有数据传输,从而被服务器超时关闭,那就编写一个简单的程序来进行测试验证。
  • 代码功能为:创建一个简单的socket,连接到指定服务器,睡眠指定时间(0, 33, 65, 125, 185, 305, 605, 1790, 1805, 3605) 等来模拟在一段时间内不收发数据,然后再尝试读写,检查是否出现异常,期间通过 wireshark 进行抓包。
  • 考虑到 TCP KeepAlive 的问题,在启用 TCP KeepAlive 和不启用的情况下分别测试一次
  • 考虑到网络拓扑中有L4,可能会对连接产生影响。因此需要测试通过L4连接和直接连接Server两种方式,此时的拓扑如图所示:
    测试网络拓扑

4.3.2 测试代码(Python)

import unittest
import socket
from time import ctime,sleep

def keepaliveTestFunc(target, isKeepAlive):
   sleepTimes = [0, 33, 65, 125, 185, 305, 605, 1790, 1805, 3605]

   for index in range(len(sleepTimes)):
       sleeptime = sleepTimes[index]
       issuccessful = True
       try:
           sock = socket.socket()
           if isKeepAlive:   #如果要测试打开TCP KeepAlive 的情况, 则启用并设置相关参数
               sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 
               sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1 * 60)  # 如果60秒没有数据,则发送探测分组
               sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1 * 60)  #前后两次探测之间的时间间隔, 60秒
               sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) #关闭一个非活跃连接之前的最大重试次数

           print("[%s]: isKeepAlive=%s, Before connect %s:%d" % (ctime(), isKeepAlive,
                                                                 target["host"], target["port"]))
           sock.connect((target["host"], target["port"]))
           print("[%s]: isKeepAlive=%s, Before Sleep %s seconds for read from %s:%d"
                 % (ctime(), isKeepAlive, sleeptime, target["host"], target["port"]))
           sleep(sleeptime)  # 睡眠指定时间
           print("[%s]: isKeepAlive=%s, After Sleep %s seconds, try to get data from %s:%d"
                 % (ctime(), isKeepAlive, sleeptime, target["host"], target["port"]))

           #一个简单的读写请求,请求内容和方式不重要,重要的是在该socket上能有读写操作
           text = "GET / HTTP/1.1\r\n\r\n"
           sock.sendall(text)
           ret_bytes = sock.recv(1000)
           ret_str = str(ret_bytes)
           #print("[%s]: result=%s" % (ctime(), ret_str))
       except Exception, e:
           issuccessful = False  # 如果发生错误,会捕获异常并设置标志变量
           print str(e)
       finally:
           sock.close()
           # 最后打印本次测试的结果
           print("[%s]: isKeepAlive=%s, sleep %s seconds and try to read %s:%d, issuccessful=%s"
                 % (ctime(), isKeepAlive, sleeptime, target["host"], target["port"], issuccessful))
           print("")


class KeepAliveTester(unittest.TestCase):
   '''Python KeepAlive Tester'''

   def test_ServerKeepAlive(self):
       targets = [
           # VIP Address, 换成自己的VIP、L4、Nginx 等机器地址
           {"host": "192.168.105.67", "port": 8080},
           # server01(换成自己的测试服务器地址)
           {"host": "192.168.106.178", "port": 8080},
           #{"host": "www.baidu.com", "port": 80}
       ]

       isKeepAlives = [
           False,
           True
       ]

       for i in range(len(targets)):
           for j in range(len(isKeepAlives)):
               keepaliveTestFunc(targets[i], isKeepAlives[j])


def suite():
   suite = unittest.makeSuite(KeepAliveTester, 'test')
   return suite


if __name__ == "__main__":
   unittest.main()

4.3.3 测试结果

  • 从日志来看,有两次失败,在不启用TCP KeepAlive的情况下,通过VIP连接服务器时,超过1800秒后就会失败,错误码类型也是 “Connection reset”。
[Thu Nov 15 20:35:21 2018]: isKeepAlive=False, After Sleep 1805 seconds, try to get data from "VIP"
[Errno 104] Connection reset by peer
[Thu Nov 15 20:35:21 2018]: isKeepAlive=False, sleep 1805 seconds and try to read "VIP", issuccessful=False
...
[Fri Nov 16 00:59:32 2018]: isKeepAlive=False, After Sleep 1805 seconds, try to get data from "Server-01"
[Fri Nov 16 00:59:32 2018]: isKeepAlive=False, sleep 1805 seconds and try to read "Server-01", issuccessful=True
...
[Thu Nov 15 23:17:29 2018]: isKeepAlive=True, After Sleep 3605 seconds, try to get data from "VIP"
[Thu Nov 15 23:17:29 2018]: isKeepAlive=True, sleep 3605 seconds and try to read "VIP", issuccessful=True

4.3.4. 结论

  • L4(VIP) 在socket上没有任何数据(业务数据或KeepAlive包)的情况下,会关闭连接,这个时间阈值为 1800 秒;
  • 我们的 API Server 在相同的情况下不会关闭连接.
  • 如果启用 TCP 的 keepalive(TCP_KEEPIDLE=60, TCP_KEEPINTVL=60), 那么L4也不会再关闭连接了.
  • 由此可知,我们和Client对自己模块的分析都正确,问题出在中间的 L4 VIP 上。

5. 确认最佳的更改方式

确认到这个问题的根因以后,就需要考虑和业务匹配的更改方式了。在自己实现以前,先调查网上常见的更改方式,并比较其优缺点。

5.1. 网上常见更改方式的调查和比较

5.1.1. 使用长连接(Connection:keep-alive),并保持网络中持续有数据,从而始终激活连接

采用这种方式,一般有两种方法:

  • 启用TCP KeepAlive。在我测试问题根因时也采用了这种方式,但这种方式可能在内网中使用较多,在互联网上,由于受限于网络连接之间的路由、代理,以及服务器的限制(比如百度似乎也会检测HTTP层是否有数据,来断开连接),这种方式使用的不多。
  • 使用自定义的心跳协议,由于传输的数据是自定义的符合业务规范的数据,和普通数据一致,不受路由、代理等限制,而且心跳中可以携带业务数据,可以任意扩展,在Server和客户端之间的连接数不多(比如只有1到2个)的场景时使用较多。

5.1.2. 使用短连接(Connection:close),每次业务交互时都进行TCP的三次握手,处理结束后断开连接

采用这种方式,由于每次连接结束后都断开,各个节点再也没有机会因检测到“网络上没有数据超时”而断开连接,因此当然就不会出现 Connection Reset 的问题了,这是一种牺牲性能换稳定性的方式。但由于这种方式是 HTTP/1.0 的默认方式,而且每次连接的状态无关,不需要Server做特别的处理,也存在很大的应用场景。而且如果在内网中交互的话,三次握手的交互时间花费也很短,因此也常用。

5.2.本应用场景中的最佳更改方式:

在我们的应用场景中,Client需要连接到Server,持续性地传递大量的任务Job,频繁的连接肯定不好,因此Client使用HttpClient的连接池技术 PoolingHttpClientConnectionManager。但由于双方采用的参数没有调整到最佳,从而出现错误。因此,最佳的更改方式需要满足以下几点:

  • 客户使用连接池,从而能快速地传递数据
  • 需要使用“资源过期策略”,释放不需要的连接。一来避免Reset错误,二来可以节约服务器资源(毕竟我们的服务器不止服务一种客户)。

经过多次调查和分析(具体步骤不再详述),确认可以在 HTTP Header 中结合使用 Connection:keep-aliveKeep-Alive:timeout=超时值 的方式来满足要求,在性能和稳定性之间达到平衡。具体的使用方式参见 HttpClientBuilder.setKeepAliveStrategy 接口。

实测代码参见: keepalive-test。写的非常简陋,只是演示了基本的使用方式。通过设置客户端的 SLEEP_TIME 和 SERVER 端返回的 Keep-Alive:timeout 为不同的值, 然后通过 "watch -d -n 3 “netstat -anoplt | grep 8080” 命令即可查看连接的利用情况(释放或重用)。

6. 后记

经过仔细的分析和调查,确认了我们这次问题的根因以及相对来说“最佳”的解决方式。但是,还是存在一些不确定的东西,需要后续继续调查确认。

  • 按vertx默认的配置参数来看, 不会主动断开无数据的客户端连接,这样的话似乎很容易被 DOS 攻击 – 初步调查,似乎可以设置 TCPIdleTime 来避免这种问题;
展开阅读全文

没有更多推荐了,返回首页