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].
-
HTTP Keep-Alive:在 Http 层通过 Connection: keep-alive 的Header设置, 表示客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive功能避免了建立或者重新建立连接。WireShare 截图为:
-
从上面的描述可知, 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)的时间计时
经过检查,客户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-alive 和 Keep-Alive:timeout=超时值 的方式来满足要求,在性能和稳定性之间达到平衡。具体的使用方式参见 HttpClientBuilder.setKeepAliveStrategy 接口。
实测代码参见: keepalive-test。写的非常简陋,只是演示了基本的使用方式。通过设置客户端的 SLEEP_TIME 和 SERVER 端返回的 Keep-Alive:timeout 为不同的值, 然后通过 "watch -d -n 3 “netstat -anoplt | grep 8080” 命令即可查看连接的利用情况(释放或重用)。
6. 后记
经过仔细的分析和调查,确认了我们这次问题的根因以及相对来说“最佳”的解决方式。但是,还是存在一些不确定的东西,需要后续继续调查确认。
- 按vertx默认的配置参数来看, 不会主动断开无数据的客户端连接,这样的话似乎很容易被 DOS 攻击 – 初步调查,似乎可以设置 TCPIdleTime 来避免这种问题;