Contents
  1. 1. 系统架构
  2. 2. 问题描述
  3. 3. TCP连接队列
    1. 3.1. TCP连接状态转换
      1. 3.1.1. 连接建立
      2. 3.1.2. 连接关闭
    2. 3.2. TCP队列溢出及丢弃
  4. 4. nginx负载节点
    1. 4.1. nginx输入参数校验
  5. 5. 其他相关点
  6. 6. 致谢&参考

最新生产环境的Nginx出了点问题,总是会波动性地出现请求无法下发的错误,客户投诉爆炸了都,仔细排查了下,最终终于找到了解决问题的办法,特此记录一下。

其实在之前的【Nginx】Nginx高并发业务场景下的Linux系统配置及Nginx配置 这篇文章中就已经对高并发的服务器及NGINX配置做了比较详细的介绍,但是还不完整,正好通过这次的trouble shooting过程,对高并发服务器的配置再一次进行了完善。笔者也会在随后更新一下之前的文章内容。

系统架构

还是先来看一下系统架构,nginx在其中起到的作用就一个单纯的负载均衡,将前端发来的请求,均衡地分发到负载的节点上。

1
2
3
4
5
6
7
8
9
+--------+  HTTP   +-------+  HTTP   +----------+
| Client | ------> | | ------> | Upstream |
+--------+ | | +----------+
| | HTTP +----------+
| Nginx | ------> | Upstream |
| | +----------+
| | HTTP +----------+
| | ------> | Upstream |
+-------+ +----------+

问题描述

出现报错的是nginx的这个报错:

1
no live upstreams while connecting to upstreams

从字面上看,就是没有可用的负载节点。没有负载节点? 其实无非就是下面两个原因

  1. 负载节点down掉了
  2. nginx评估认为负载节点不可用

负载节点有没有down掉可以很容易验证,而且我们的负载节点已经验证过是完全可靠的,只不过业务峰值的请求量超过了单线程处理时的最大能力,所以才在前面搭建了nginx作为负载均衡节点。

那就剩下为什么nginx会认为负载节点不可用。期间有人提到,会不会是tcp连接队列太小了,导致nginx任务负载节点连接出了问题。

TCP连接队列

网上有非常多的tcp连接队列的讲解和配置。不过中文的blog其实都是大家相互转载和借鉴,真正付诸实践的文章就那么几篇。但是每个人遇到的情况肯定是不同的,所以也不能单纯按照网上的文章去碰运气,还是要彻底搞清楚底层的原理,才能对自己的问题有所帮助。

TCP连接状态转换

关于一个TCP连接的各种状态之间的转换,网上已经有非常多的描述,这里我就不做赘述,网上有几张图,基本上介绍的比较清楚了



上面这张图是对TCP连接过程中的所有状态的一个集合,比较全面,但是也比较难懂,不过还是强烈建议彻底搞清楚TCP连接的各种状态,这样有利于分析网络上的问题。
我们拆成两部分来理解:

连接建立



大部分介绍TCP连接队列的中文blog都引用了这张图,其实这张图里面描述的都是正确的,但是有几个容易误解的点,笔者就是纠结全连接队列和establised连接,纠结了好久。

  1. 当server收到syn后,在系统底层,就把一个tcp连接,放到了半连接队列(syns queue)中,等到收到client回复的ack后,将其从半连接队列移入全连接队列(accept queue)。所以就有了针对半连接队列的攻击,即如果server收不到client回复的ack,那么半连接队列就有被占满的可能,也就是syn flood攻击。
  2. 当TCP连接被放入到accept queue中后,要等待上层的应用把这个连接取走(accpet),上层应用取走一个连接后,全连接队列就空出来一个位置。所以全连接队列数*不等于*established数

连接关闭



上图介绍了在一个连接关闭时的全过程,图中已经画的非常详细了,这里就不做过多的介绍,关于TIME_WAIT的介绍在【Nginx】Nginx高并发业务场景下的Linux系统配置及Nginx配置 里面也有很详细的说明了。

仔细吃透TCP的连接建立和释放的过程,再看一看开篇的那张全状态集合图,基本就理解了TCP的各个状态及转换机制了。

TCP队列溢出及丢弃

回到我们分析问题的路上,既然TCP有两个队列,就存在溢出的可能,我们怎么确定是否队列有溢出呢?

执行下面的两个命令都可以查看TCP的连接队列是否有异常:

1
2
3
$ netstat -s | egrep "listen|LISTEN"
或者
$ ss -s

下图是我在一个发生过TCP连接队列溢出的机器上执行命令后的结果



理论上,ss命令和netstat都可以显示tcp连接的分析统计,但是笔者猜测,netstat看到的应该是从服务器启动到现在的所有记录,即其结果是不清零的,而ss -s命令则是有一个时间统计区间的,所以上图中,只有netstat命令显示了很多半连接队列溢出或全连接队列丢弃的统计,而ss -s命令则只显示了当前的socket队列连接情况,因为上次调优后,没有出现tcp队列溢出的情况了,所以ss就显示正常的结果。
另外,当有大量连接时,ss命令要比netstat命令执行效率要高很多。例如当你想查看当前所有tcp连接状态时,下面的命令在当前有10k条连接时,要执行大概1秒才能统计出来,而ss命令则是秒出的。

1
$ netstat -n | awk '/^tcp/ {++state[$NF]} END {for(key in state) print key,"\t",state[key]}'

既然TCP连接队列长度不够,我们就需要调整其长度。

  • 半连接队列的最大值取决于:max(/proc/sys/net/ipv4/tcp_max_syn_backlog),默认为512;当启用syncookies时,没有逻辑最大长度,忽略tcp_max_syn_backlog设置,syncookies的设置可以防范SYN flood攻击。所以只要我们启用了syncookies,则队列长度即可忽略,而且现在基本上可以通过防火墙来防范syn flood攻击。
  • 全连接队列的最大值取决于:min(backlog, /proc/sys/net/core/somaxconn),在linux内核2.2版本以后,backlog参数控制的accept queue的大小,backlog是在socket创建的时候传入的,属于listen函数里的参数;somaxconn是内核的参数,默认是128

所以,我们的重点就在于调整全连接队列长度,backlog是由应用程序在创建监听时传入的,nginx这个值默认是511,至于java和C程序都可以通过相关函数来设置backlog值;而somaxconn是通过net.core.somaxconn参数来设置的,默认是128。

来看一下经过系统调优和nginx参数优化(调优过程参见http://lipeng1667.github.io/2019/08/05/high-concurrent-system-configration-with-nginx-and-rhel/)后的全连接队列数:

执行下列语句(ss 参数说明: -l 显示监听 -n忽略主机名 -t仅显示tcp -4 ipv4协议)

1
$ ss -lnt -4



如上图显示,recQ就是当前TCP队列中的数量,当上层应用取TCP连接取的足够快时,队列都是空的; sendQ是队列总长度。 可以看到nginx监听的443端口上已经调优到了65535。

笔者之前一直纠结为何ss统计后的ESTABLISHED连接数量要远远大于监听时设置的128默认全连接队列长度,后来终于在这个StackOverFlow的回答中找到了答案。



nginx负载节点

但是经过上面的TCP队列调优后,发现情况并未得到任何改善,依旧是周期性稳定地出现no live upstreams while connecting to upstreamserror日志。
那我们就换一个思路,为何nginx会认为没有可用的负载节点呢?明明负载节点好好的呢? 于是笔者就详细检查了nginx的配置,终于找到了一条尝试的解决办法。

关于nginx的轮询机制这里也不做赘述,网上同样有很多”相似”的文章介绍。其中提到了,默认情况下,当一个节点出错时,nginx会将当前节点自动设置成down状态,及新的请求不会下发到当前节点,在一段时间后,重新上线该节点。

这样就和我们的现象很吻合了,当某个请求,导致了nginx的所有负载节点均返回了失败的结果,nginx将所有节点下线,所以新的请求就会没有负载可用,便返回错误;而过段时间后,节点尝试上线,业务恢复。

为了验证我们的想法,笔者通过在nginx的配置中,设置忽略负载节点错误max_fail=0,业务运行正常,不在出现错误。详细的配置可以参见【Nginx】Nginx高并发业务场景下的Linux系统配置及Nginx配置最后的nginx配置部分。

nginx输入参数校验

至于为何请求会导致负载节点返回错误,到现在还是个迷,只是知道周期性的nginx无可用节点是因为一个客户在晚上做模拟攻击测试,输入的参数中有很多sql注入和脚本攻击等代码,但是我们的负载节点本身已经做了防范,如果直接访问负载节点是没有问题的,但是不知为何nginx任务负载节点返回了错误信息。。。

所以我们就不得不设置nginx直接拦截掉这些非法的输入参数请求,在nginx配置的server部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# block invalid url
if ( $query_string ~* ".*[;'<>].*" ){
return 404;
}
if ( $query_string ~* "(curl|=cat|cat )") {
return 404;
}
if ($request_uri ~* "(cost\()|(concat\()") {
return 444;
}
if ($request_uri ~* "[+|(%20)]union[+|(%20)]") {
return 404;
}
if ($request_uri ~* "[+|(%20)]and[+|(%20)]") {
return 444;
}
if ($request_uri ~* "[+|(%20)]select[+|(%20)]") {
return 404;
}

以上是笔者针对我们的服务器遭受攻击时做部分拦截,网上有很多比较全的拦截语法,但是笔者不建议在nginx节点上开启过滤。因为这些过滤说白了就是字符串的匹配算法,而字符串的匹配是最慢的一种计算了,会浪费掉一些CPU资源。确实开启了字符串过滤后,nginx线程的CPU资源上升了大概20%个点。

其他相关点

在解决这个问题的过程中,也顺带了解了一些其他有用的知识,比如ESTABLISHED连接的时间状态,tcp keepalive的开启机制等等。

我们执行下面的命令:

1
$ netstat -apno | grep ESTAB | head

在最后一列,因为加入了-o命令,所以会显示ESTABLISHED连接的检测时间。

  • keepalive - when the keepalive timer is ON for the socket
  • on - when the retransmission timer is ON for the socket
  • off - none of the above is ON

我们可以通过这个值,看到每个连接检测的时间,如果想要使用keepalive,必须在客户端发起socket连接时,显示地声明keepalive才行。笔者的服务端看到的ESTABLISHED连接,基本都是off状态,即没有任何时间检测,一旦因为网络问题,比如防火墙直接掐掉了这些连接,这些连接就会变成不可用的假连接。。。

可以参见这个问题的讨论Too many established connections left open




写在最后,这次Trouble Shooting过程中,发现了很多blog的内容,都基本上一样,就比如那个tcp连接时序图,都打着同一个水印,而很多文字内容,都是一样的。很多内容都是许多年前的了,比如tcp_tw_recycle这个参数,还有不少文章推荐打开,其实对现代服务器,节省的那点资源,基本上可以忽略不计。

所以,解决问题还要靠自己对很多基础知识的深入了解。笔者解决这个问题,前后研究TCP状态,研究Nginx配置,研究backlog参数等等,其实花费了很长的时间。只有如此,才能真正做到触类旁通,而不是停留在病急乱投医地状态。

另外,善用Google!

致谢&参考

Contents
  1. 1. 系统架构
  2. 2. 问题描述
  3. 3. TCP连接队列
    1. 3.1. TCP连接状态转换
      1. 3.1.1. 连接建立
      2. 3.1.2. 连接关闭
    2. 3.2. TCP队列溢出及丢弃
  4. 4. nginx负载节点
    1. 4.1. nginx输入参数校验
  5. 5. 其他相关点
  6. 6. 致谢&参考