应用处理http request不当导致的 TCP CLOSE-WAIT 大量堆积的问题
应用处理http request不当导致的 TCP CLOSE-WAIT 大量堆积的问题
情况是这样: 最近做过的一个安卓多渠道安装包在CDN场景下的差分打包、存储、分发
的项目,这个项目在测试阶段,并没有暴露出什么问题,但是当上线到生产环境进行回归测试时,在第三方CDN回源到我们的源站这一层面的文件拉取上,暴露了一个严重问题,即:如果第三方CDN进行非Range的HTTP GET请求,如果客户端网速较慢,或者安装包过大,会导致无法成功下载到最后一片拥有META-INF的文件片,从而导致用户下载到的文件损坏。
排查故障过程:
-
去灰度环境下载一个已经成功发布了的安装包,安装包为600MB+,下载到590+MB时,速度掉到0,卡顿一段时间后提示下载失败。但是如果下载的是一个大小为十几兆的安装包,就没有问题。
-
第一反应是切片算法存在问题,立即用range跨分片请求,下载到的文件是没有问题的,可以用zipinfo读取到压缩信息,排除是切片算法的问题;
-
想到可能是python requests函数中,指定的timeout过小,实际上,timeout分为两种,一个是connection_timeout, 另外一个是read_timeout,如果直接指定timeout=xxx,则connection_timeout和read_timeout都为指定的值,如果需要分别指定,则要采用元祖的形似,如:
timeout=(5, 120)
,由于在代码中统一指定了timeout为5,猜测可能触发read_timeout断连,将timeout参数指定为None后重试,依然存在问题; -
此时猜测可能是ns3到s3的请求发生超时,TCP连接直接被s3的网关给Reset了,利用
ss -nat
,发现了大量的TCP卡在CLOSE-WAIT
状态的连接,回想了下TCP的断连过程,即S3网关触发了TCP的timeout,主动发送了FIN请求断连,ns3收到该请求后,被提前将TCP状态置为CLOSE-WAIT
状态,并发送ACK给S3服务端,但是由于应用程序并不知道TCP层已经变为了CLOSE-WAIT
,等到需要去内核缓存区读取数据的时候,发现缓冲区中也确实存在数据,就将数据取走并发送给用户,但是当再次来取的时候,发现内核缓存区为空,然后就一直等待数据的到来,直到超时。 -
利用tcpdump抓包验证,由于下载文件会产生较多的数据包,因此只截取一小段:
果然,在传输过程中,可以看到,客户端一直在给服务端宣告自己的window为0,应用层在忙着处理事物,让服务端keepalive:
TCP Zero Window可参考如下: https://accedian.com/blog/tcp-receive-window-everything-need-know/ -
至此,问题的原因比较明显了,应该是代码中处理第二片的分段下载的逻辑问题,目前的逻辑是:当客户端发起非Range的HTTP GET时,解析HTTP请求后,后端会向S3发起两个HTTP Range GET请求,第一个请求的范围是 0 ~(split_point-1), 第二个请求的范围是 split_point ~ content_length,当文件较大或者客户端网速较慢时,第一片文件会占用大量的时间,而第二片的HTTP连接程序,一直没有进行accept()系统调用,导致在TCP连接上,Buffer空间一直被占用,直到对应的S3的服务端(Openresty)的keepalive_timeout被触发,服务端误以为是客户端卡死,主动发给了客户端一个RST报文断开连接。此时,当ns3将第一个请求给用户传输完毕准备去内核空间buffer中读取数据时,发现连接已经被reset了,只能将buffer内仅有的几百K字节的数据返回给用户,然后造成一个假死的状态,直到ns3的到用户的TCP连接超时断连。请求流程图如下:
-
事后写了个最小代码来重现这个问题:从ns3到s3请求文件时,如果拿着文件句柄,一直不释放,sleep很久后再从HTTP Stream中读取数据写入本地,代码如下:
#!/usr/bin/env python # -*- coding: utf-8 -*- import time import os import requests url = 'http://<xxxx>.s3.<xx>.com/<object_key>' headers = {} r = requests.get(url=url, headers=headers, stream=True, timeout=5) file_size = r.headers.get('Content-Length', None) # 这里sleep 100 秒,确保触发timeout for i in range(1, 100): time.sleep(1) print 'sleep %s' % i with open('<object_key>', 'wb') as f: for data in r.iter_content(chunk_size=4096): f.write(data) file_real_size = os.path.getsize('<object_key>') if file_real_size == file_size: print 'success' else: print 'failed'
再利用TCPDUMP抓包看到如下:
可以看到,这里实际上是触发了TCP的保活机制,四种定时器中的坚持定时器(persistent timer,当TCP服务器收到了客户端的0滑动窗口报文的时候,就启动一个定时器来计时,并在定时器溢出的时候向向客户端查询窗口是否已经增大,如果得到非零的窗口就重新开始发送数据,如果得到0窗口就再开一个新的定时器准备下一次查询。通过观察可以得知,TCP的坚持定时器使用1,2,4,8,16……64秒这样的普通指数退避序列来作为每一次的溢出时间。)由于s3 gw不断的收到窗口大小为零的报文,所以会keepalive。但是继续往后看,当客户端的TCP窗口被更新为正常大小时,但是,由于S3 GW(openresty)配置的
keepalive_timeout
为30s,应用层认为TCP的连接已经中断,这时突然之前的连接恢复了,经过协商后又重新在该端口上复用了这个连接,但是客户端得到的数据包已经是失序的了,无法从之前内核缓冲区中的拿到的数据包,与新拿到的数据包进行TCP数据包的重组,因此最终看到TCP断连。
PS: 以下是几个关于TCP keepalive的内核参数:
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes