从SYN-Flood攻击到TCP/IP协议

这篇博客缘起一次服务故障,在写下事故报告的同时总结分析了这次故障原因及教训。

我们的新搜索后台2012年8月份上线,10月份偶发不稳定,具体症状是lvs探测发现某个后台频繁上下线,吞吐很低,基本不能正常 返回结果,11月份该情况加剧。当在线调试跟踪时,发现后台基本无动作,CPU利用率很低,负载也不高。

查看系统的网络连接状态,如下左(右是正常连接):
connect_error.png connect_normal.png

从表象上看,我们开始怀疑网络通信环节出现了问题,很多链接不能被正常接收处理,而这种情况类似常见的SYN-Flood攻击的特征,在通信环节存在握手等待和无应答的情况。我们的第一反应是有恶意攻击发生,但和运维同事确认后排除了这种可能。那么具体是怎么回事呢?

这得从我们搜索服务的背景讲起了。搜索服务采用的是常见的前后台同步调用方式,有很多搜索前台页调用搜索后台,调用方式不一而足,各种都有。基本上会等待超时重试,重试几次后无结果则返回。 而搜索后台用的是自行开发的一个轻型的网络通信模型,对应源码在ServerFrameEx中。该网络通信对每个工作线程独立接收client的通信请求,每次通信完成(成功或失败)后自行断开。除了共用一个网络socket句柄进行监听(加锁互斥)外,其他的通信都独立进行。

search_netframe_v1.png

搜索通信利用原生socket API,按HTTP1.0协议传输搜索内容。HTTP基于tcp/ip实现,是一种面向连接的流协议。 追根溯源到了TCP协议的连接机制上,然后我们发现了那个臭虫。在杀死臭虫之前,先补上TCP协议socket编程的一些基础知识。

TCP协议是传输安全的协议,为了保证其传输的安全和有效,利用了状态控制来保证通信各个阶段的可控和可重复。机制较为复杂,各个部分的设置不当容易导致各种网络问题。下面摘来两个TCP的图:

tcp_status.png tcp_translate.png

第一个图是状态转移图,TCP/IP详解里有,结合该图可以帮助我们分析网络通信状态,利用它我们可以发现事故的网络连接在连接开始和结束出现积压,对应第二个图则是在accept和close环节出现异常。

下面进一步说明TCP主要 的几个API:

  • socket 创建句柄

    • 相当于获取文件句柄的概念,向内核申请分配一个网络的句柄资源
    • tips:
    • 默认上限
      • 网络socket句柄在linux下被视为普通文件句柄,由系统文件句柄上限
      • 默认的进程打开文件上限是1024个,可以通过ulimit -n查看
    • 系统动作
      • 根据参数选择协议类型
  • bind 绑定句柄和网络地址

    • server端实现
    • tips:
    • 地址复用
      • 该部分存在地址复用的问题,若需要让多个进程可以占用同一网络端口, 可以通过设置setsockopt SO_REUSEADDR来达到端口复用的目的,这样的好处之一是,端口服务不至于被异常的进程阻塞。引一段话:“如果自己的程序不小心崩溃了,重新启动程序的时候往往会在bind调用上失败, 错误原因为Address Already In Use,往往要等待两分钟才能再次绑定。 ”
    • 默认的网卡绑定
      • 多网卡服务器时,需要注意网卡绑定问题,最好绑定到具体IP
  • listen 开始监听

    • server开始接收client连接请求,至此tcp网络通信开始
    • 对应状态LISTEN
    • tips:
    • 半连接数backlog
      • 决定半连接数上限,需要针对服务设置合适的大小
    • 底层的动作
      • 阻塞
  • accept 从监听端口接收连接

    • 处理连接请求,3次握手
    • 对应状态SYN_RCVD
    • tips:
    • SYN Flood攻击

      • 3次握手时,若客户端断开,服务端会保持该连接,处于SYN_RECV状态,并尝试重传规定次数,失败后才抛弃。

        SYN_RECV状态的存活时间由系统参数决定,
        其存活时间为SYN_ACKC重试次数*单次SYN_RECV超时时间
        即参数tcp_synack_retries乘以ip_conntrack_tcp_timeout_syn_recv

    • 线程安全

      • 一个句柄只能一个线程accept,多线程accept,状态未可知。
  • connect 建立连接

    • client尝试建立连接,第一次握手
    • 对应状态SYN_SENT 和 SYN_RCVD
    • tips:
    • 具体动作
      • 对方返回ACK即建立连接,当本机的SYN未能正确被接收时,该连接是不可靠的(可能重置RST和丢弃)
    • 阻塞/非阻塞
      • 阻塞是等待连接建立后返回,非阻塞不等待即返回
      • 阻塞方式简单易实现,非高并发时推荐使用
      • 非阻塞配合select, poll, epoll等方法可以实现多连接快速处理
  • send 发送数据

    • 连接本端向对端发送数据
    • 对应状态ESTABLISHED
    • tips:
    • 具体动作
      • 将数据投递到系统发送缓存区即返回。当缓存区满时,阻塞。应设置相应超时。
    • 分包
      • IP层分成多个数据包乱序传输,有序组装,高并发时会丢包,因为窗口大小的限制
    • 双向通道
      • 收发是双向的,可并行执行
    • 校验和
      • 校验和也是可能出错的
    • 多线程
      • 单线程收发,参考文件IO,多线程并发send会出现问题
  • recv 接收数据

    • 连接本端从对端接收数据
    • 对应状态ESTABLISHED
    • tips
    • 具体动作
      • 第一个数据包到达系统接收缓存区时即开始触发,直至最后一个数据包,中间时间间隔视通讯文件大小和网络状态而定。应设置相应超时
    • 面向连接的本地缓冲池
      • 缓冲池是面向连接的,而不是面向线程
    • 缓冲池大小
      • 收发都有系统缓冲池,其大小由系统设定,当服务通信量较大且收发速度太快时,可调整其大小以优化性能
    • 移动窗口
      • 移动窗口是TCP的精髓之一,用于底层整理乱序的数据包。根据内容大小规模和网络通信状况适当调大初始窗口大小可以提高网络通信状况和效率
    • 乱序
      • 对于上层应用而言,可以认为其内容是顺序流。TCP底层是基于乱序和序号组装,对于特定服务不排除其错误
  • shutdown 停止收发

    • 停止收发数据
    • 对应状态FIN_WAIT
    • tips
    • 具体动作
      • 停止数据收发。若停止写,会触发4次挥手的2次
    • 用处
      • 某些特定应用需要先发后收,通过shutdown可以触发对方服务
  • closesocket 断开连接

    • 断开连接并回收资源
    • 对应状态CLOSE_WAIT
    • tips
    • 具体动作
      • 移交该网络句柄给系统托管,系统将其数据收发后触发4次挥手,回收资源
    • 优雅关闭,参见SO_LINGER设置
  • setsockopt 设置网络参数

    • 对以上各API进行定制化设置
    • tips
    • 默认设置
    • 合理的设置实现
      • SO_KEEPALIVE
        • 服务端若需要确认客户端是否存活,或者需维持大量的会话连接,可设该选项,以节约资源,有效通信。
        • 坏处:受瞬时状态影响,从而断开健康连接。
        • 对于短连接,该选项不应被设置。长连接,自己掂量。一般来说,没啥用。
      • SO_LINGER
        • 不设置的话,close可以立刻返回,该句柄的待发送数据移交系统处理。处理是否成功由移交时刻决定。
        • 设置的话,会阻塞close动作。应设置合适的超时时间,并检验close的返回值
        • 可参见:http://my.oschina.net/u/136923/blog/70011
      • TCP_DEFER_ACCEPT
      • SO_RCVBUF和SO_SNDBUF
        • 默认8K,若发送文件较大且通信并不密集时,可考虑稍微设置大些
      • SO_RCVLOWAT和SO_SNDLOWAT
        • 主要供非阻塞机制用,提示当收发达到该阈值时触发事件
      • SO_RCVTIMEO和SO_SNDTIMEO
        • 发送超时,接收超时
      • TCP_NODELAY和TCP_CORK
        • 禁用Nagle算法,对于搜索不需要禁用

          Apache HTTPD 是因特网上最流行的Web服务器,它的所有套接字就都设置了TCP_NODELAY选项,
          而且其性能也深受大多数用户的满意。这是为什么呢?答案就在于实现的差别之上。由BSD
          衍生的TCP/IP协议栈(值得注意的是FreeBSD)在这种状况下的操作就不同。当在TCP_NODELAY
          模式下提交大量小数据块传输时,大量信息将按照一次write()函数调用发送一块数据的方式
          发送出去。然而,因为负责请求交付确认的记数器是面向字节而 非面向包(在 Linux上)的,
          所以引入延迟的概率就降低了很多。结果仅仅和全部数据的大小有关系。而 Linux 在第一包到
          达之后就要求确认,FreeBSD则在进行如此操作之前会等待好几百个包。
      • TCP_DEFER_ACCEPT
        • 利用第三次握手携带传输数据,适合轻量级tcp服务,如HTTP请求等,少了一个回合
        • 该关键字为Linux版本,其它系统调研可知,有类似定义
      • TCP_QUICKACK
        • 将每次会话的ACK应答和下次的数据发送绑在一起,这样的话省略ACK的往返,提高效率,对于广域网比较有用

前面说了这么多,我们现在绕回来到具体应用服务上来,搜索后台的服务采用原始的socket编程,利用一个listen的server端口接收客户端请求,然后交给线程池进行相应的网络连接及业务处理,模型还是比较简单的。但即便如此,还是在accept环节出现了问题。上面也说明了,accept和connect环节执行TCP协议中的三次握手动作。前图中的网络模型会在具体的线程中进行握手动作。当网络压力较小可以正常工作,但若线程资源耗尽(线程池的空闲线程为0)则不能进行后续握手动作。 客户端的connect请求不能在超时时间内得到处理,则会主动中止connect请求,一个正常连接变成了半连接状态,这样就人为地制造了SYN-Flood攻击。当这样的请求多了之后,网络资源会迅速耗尽(主要是半连接池资源,可参见syn-flood攻击原理)。而更要命的是,这种情况一旦发生,就很难自行消除,因为此时的线程资源也会被消耗在网络握手上,无法立刻释放,形成恶性循环。

找到原因后,去修复解决问题就好办得多了,老大改写了该网络模型,将网络连接和业务逻辑解藕 ,用单线程集中处理网络连接,然后分发给线程池具体处理后台逻辑。若网络请求过多,则主动抛弃新进的连接请求,以缓解压力。下图是改进后的网络模型:

search_netframe_v2.png

改良后紧急上线,后台服务在那一刹那恢复正常,这个时候程序员的那个憋屈的小心脏豁然开朗,原来知识就是力量。

参考:

http://www.eifr.com/article.php?id=1752&page=2

http://s99f.blog.163.com/blog/static/35118365200951943541878/

http://www.embedu.org/Column/Column179.htm

http://www.cnblogs.com/biyeymyhjob/archive/2012/08/11.html

http://www.myexception.cn/operating-system/490297.html

http://blog.csdn.net/hairetz/article/details/4083389

http://blog.csdn.net/hairetz/article/details/4223219

http://blog.csdn.net/hairetz/article/details/4223234

http://blog.csdn.net/factor2000/article/details/3929816