zoukankan      html  css  js  c++  java
  • 应用处理http request不当导致的 TCP CLOSE-WAIT 大量堆积的问题

    应用处理http request不当导致的 TCP CLOSE-WAIT 大量堆积的问题

    情况是这样: 最近做过的一个安卓多渠道安装包在CDN场景下的差分打包、存储、分发的项目,这个项目在测试阶段,并没有暴露出什么问题,但是当上线到生产环境进行回归测试时,在第三方CDN回源到我们的源站这一层面的文件拉取上,暴露了一个严重问题,即:如果第三方CDN进行非Range的HTTP GET请求,如果客户端网速较慢,或者安装包过大,会导致无法成功下载到最后一片拥有META-INF的文件片,从而导致用户下载到的文件损坏。

    排查故障过程:

    1. 去灰度环境下载一个已经成功发布了的安装包,安装包为600MB+,下载到590+MB时,速度掉到0,卡顿一段时间后提示下载失败。但是如果下载的是一个大小为十几兆的安装包,就没有问题。

    2. 第一反应是切片算法存在问题,立即用range跨分片请求,下载到的文件是没有问题的,可以用zipinfo读取到压缩信息,排除是切片算法的问题;

    3. 想到可能是python requests函数中,指定的timeout过小,实际上,timeout分为两种,一个是connection_timeout, 另外一个是read_timeout,如果直接指定timeout=xxx,则connection_timeout和read_timeout都为指定的值,如果需要分别指定,则要采用元祖的形似,如:timeout=(5, 120),由于在代码中统一指定了timeout为5,猜测可能触发read_timeout断连,将timeout参数指定为None后重试,依然存在问题;

    4. 此时猜测可能是ns3到s3的请求发生超时,TCP连接直接被s3的网关给Reset了,利用ss -nat,发现了大量的TCP卡在CLOSE-WAIT状态的连接,回想了下TCP的断连过程,即S3网关触发了TCP的timeout,主动发送了FIN请求断连,ns3收到该请求后,被提前将TCP状态置为CLOSE-WAIT状态,并发送ACK给S3服务端,但是由于应用程序并不知道TCP层已经变为了CLOSE-WAIT,等到需要去内核缓存区读取数据的时候,发现缓冲区中也确实存在数据,就将数据取走并发送给用户,但是当再次来取的时候,发现内核缓存区为空,然后就一直等待数据的到来,直到超时。

    5. 利用tcpdump抓包验证,由于下载文件会产生较多的数据包,因此只截取一小段:
      果然,在传输过程中,可以看到,客户端一直在给服务端宣告自己的window为0,应用层在忙着处理事物,让服务端keepalive:


      TCP Zero Window可参考如下: https://accedian.com/blog/tcp-receive-window-everything-need-know/

    6. 至此,问题的原因比较明显了,应该是代码中处理第二片的分段下载的逻辑问题,目前的逻辑是:当客户端发起非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连接超时断连。请求流程图如下:

    7. 事后写了个最小代码来重现这个问题:从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
  • 相关阅读:
    写给大忙人的spring cloud 1.x学习指南
    spring boot 1.x完整学习指南(含各种常见问题servlet、web.xml、maven打包,spring mvc差别及解决方法)
    Spring-Data-Redis下实现redis连接断开后自动重连(真正解决)
    写给大忙人的nginx核心配置详解(匹配&重写、集群、环境变量&上下文、Lua)
    javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 解决方法
    linux下配置nginx使用ftp目录作为静态资源文件的目标目录
    写给大忙人的centos下ftp服务器搭建(以及启动失败/XFTP客户端一直提示“用户身份验证失败”解决方法)
    linux下mysql 8.0安装
    写给大忙人的Elasticsearch架构与概念(未完待续)
    写给大忙人的ELK最新版6.2.4学习笔记-Logstash和Filebeat解析(java异常堆栈下多行日志配置支持)
  • 原文地址:https://www.cnblogs.com/webber1992/p/14555702.html
Copyright © 2011-2022 走看看