前言

事实证明,读过Linux内核源码的确有很大的益处,尤其在解决问题的时刻。当你看到报错的那一瞬间,就能把景象/起因/以及解决方案一股脑的在脑中闪现。甚至一些边边角角的景象都能很快的反馈过去是为何。笔者读过一些Linux TCP协定栈的源码,就在解决上面这个问题的时候有一种十分晦涩的感觉。

Bug现场

首先,这个问题其实并不难解决,然而这个问题引发的景象倒是挺有意思。先形容一下景象吧, 笔者要对自研的dubbo协定隧道网关进行压测(这个网关的设计也挺有意思,筹备放到前面的博客外面)。先看下压测的拓扑吧:
为了压测笔者gateway的单机性能,两端仅仅各保留一台网关,即gateway1和gateway2。压到肯定水平就开始报错,导致压测进行。很天然的就想到,网关扛不住了。

网关的状况

去Gateway2的机器上看了一下,没有任何报错。而Gateway1则有大量的502报错。502是Bad Gateway,Nginx的经典报错,首先想到的就是Gateway2不堪重负被Nginx在Upstream中踢掉。
那么,就先看看Gateway2的负载状况把,查了下监控,发现Gateway2在4核8G的机器上只用了一个核,齐全看不出来有瓶颈的样子,难道是IO有问题?看了下小的可怜的网卡流量打消了这个猜测。

Nginx所在机器CPU利用率靠近100%

这时候,发现一个有意思的景象,Nginx确用满了CPU!
再次压测,去Nginx所在机器上top了一下,发现Nginx的4个Worker别离占了一个核把CPU吃满-_-!
什么,号称性能强悍的Nginx居然这么弱,说好的事件驱动epoll边际触发纯C打造的呢?肯定是用的姿态不对!

去掉Nginx间接通信毫无压力

既然猜想是Nginx的瓶颈,就把Nginx去掉吧。Gateway1和Gateway2直连,压测TPS外面就飙升了,而且Gateway2的CPU最多也就吃了2个核,毫无压力。

去Nginx上看下日志

因为Nginx机器权限并不在笔者手上,所以一开始没有关注其日志,当初就分割一下对应的运维去看一下吧。在accesslog外面发现了大量的502报错,的确是Nginx的。又看了下谬误日志,发现有大量的

Cannot assign requested address 

因为笔者读过TCP源码,一瞬间就反馈过去,是端口号耗尽了!因为Nginx upstream和后端Backend默认是短连贯,所以在大量申请流量进来的时候回产生大量TIME_WAIT的连贯。
而这些TIME_WAIT是占据端口号的,而且根本要1分钟左右能力被Kernel回收。

cat /proc/sys/net/ipv4/ip_local_port_range32768    61000 

也就是说,只有一分钟之内产生28232(61000-32768)个TIME_WAIT的socket就会造成端口号耗尽,也即470.5TPS(28232/60),只是一个很容易达到的压测值。事实上这个限度是Client端的,Server端没有这样的限度,因为Server端口号只有一个8080这样的有名端口号。而在 upstream中Nginx表演的就是Client,而Gateway2就表演的是Nginx

为什么Nginx的CPU是100%

而笔者也很快想明确了Nginx为什么吃满了机器的CPU,问题就进去端口号的搜寻过程。
让咱们看下最耗性能的一段函数:

int __inet_hash_connect(...){        // 留神,这边是static变量        static u32 hint;        // hint有助于不从0开始搜寻,而是从下一个待调配的端口号搜寻        u32 offset = hint + port_offset;        .....        inet_get_local_port_range(&low, &high);        // 这边remaining就是61000 - 32768        remaining = (high - low) + 1        ......        for (i = 1; i <= remaining; i++) {            port = low + (i + offset) % remaining;            /* port是否占用check */            ....            goto ok;        }        .......ok:        hint += i;        ......} 

看下面那段代码,如果始终没有端口号可用的话,则须要循环remaining次能力宣告端口号耗尽,也就是28232次。而如果依照失常的状况,因为有hint的存在,所以每次搜寻从下一个待调配的端口号开始计算,以个位数的搜寻就能找到端口号。如下图所示:
所以当端口号耗尽后,Nginx的Worker过程就沉迷在上述for循环中不可自拔,把CPU吃满。

为什么Gateway1调用Nginx没有问题

很简略,因为笔者在Gateway1调用Nginx的时候设置了Keepalived,所以采纳的是长连贯,就没有这个端口号耗尽的限度。

Nginx 前面有多台机器的话

因为是因为端口号搜寻导致CPU 100%,而且凡是有可用端口号,因为hint的起因,搜寻次数可能就是1和28232的区别。
因为端口号限度是针对某个特定的远端server:port的。 所以,只有Nginx的Backend有多台机器,甚至同一个机器上的多个不同端口号,只有不超过临界点,Nginx就不会有任何压力。

把端口号范畴调大

比拟无脑的计划当然是把端口号范畴调大,这样就能抗更多的TIME_WAIT。同时将tcp_max_tw_bucket调小,tcp_max_tw_bucket是kernel中最多存在的TIME_WAIT数量,只有port范畴 - tcp_max_tw_bucket大于肯定的值,那么就始终有port端口可用,这样就能够防止再次到调大临界值得时候持续击穿临界点。

cat /proc/sys/net/ipv4/ip_local_port_range22768    61000cat /proc/sys/net/ipv4/tcp_max_tw_buckets20000 

开启tcp_tw_reuse

这个问题Linux其实早就有了解决方案,那就是tcp_tw_reuse这个参数。

echo '1' > /proc/sys/net/ipv4/tcp_tw_reuse 

事实上TIME_WAIT过多的起因是其回收工夫居然须要1min,这个1min其实是TCP协定中规定的2MSL工夫,而Linux中就固定为1min。

#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT                  * state, about 60 seconds    */ 

2MSL的起因就是排除网络上还残留的包对新的同样的五元组的Socket产生影响,也就是说在2MSL(1min)之内重用这个五元组会有危险。为了解决这个问题,Linux就采取了一些列措施避免这样的状况,使得在大部分状况下1s之内的TIME_WAIT就能够重用。上面这段代码,就是检测此TIME_WAIT是否重用。

__inet_hash_connect    |->__inet_check_establishedstatic int __inet_check_established(......){    ......        /* Check TIME-WAIT sockets first. */    sk_nulls_for_each(sk2, node, &head->twchain) {        tw = inet_twsk(sk2);        // 如果在time_wait中找到一个match的port,就判断是否可重用        if (INET_TW_MATCH(sk2, net, hash, acookie,                    saddr, daddr, ports, dif)) {            if (twsk_unique(sk, sk2, twp))                goto unique;            else                goto not_unique;        }    }    ......} 

而其中的外围函数就是twsk_unique,它的判断逻辑如下:

int tcp_twsk_unique(......){    ......    if (tcptw->tw_ts_recent_stamp &&        (twp == NULL || (sysctl_tcp_tw_reuse &&                 get_seconds() - tcptw->tw_ts_recent_stamp > 1))) {       // 对write_seq设置为snd_nxt+65536+2       // 这样可能确保在数据传输速率<=80Mbit/s的状况下不会被回绕         tp->write_seq = tcptw->tw_snd_nxt + 65535 + 2        ......        return 1;    }    return 0;    } 

下面这段代码逻辑如下所示:
在开启了tcp_timestamp以及tcp_tw_reuse的状况下,在Connect搜寻port时只有比之前用这个port的TIME_WAIT状态的Socket记录的最近工夫戳>1s,就能够重用此port,行将之前的1分钟缩短到1s。同时为了避免潜在的序列号抵触,间接将write_seq加上在65537,这样,在单Socket传输速率小于80Mbit/s的状况下,不会造成序列号重叠(抵触)。
同时这个tw_ts_recent_stamp设置的机会如下图所示:
所以如果Socket进入TIME_WAIT状态后,如果始终有对应的包发过来,那么会影响此TIME_WAIT对应的port是否可用的工夫。 开启了这个参数之后,因为从1min缩短到1s,那么Nginx单台对单Upstream可接受的TPS就从原来的470.5TPS(28232/60)一跃晋升为28232TPS,增长了60倍。
如果还嫌性能不够,能够配上下面的端口号范畴调大以及tcp_max_tw_bucket调小持续晋升tps,不过tcp_max_tw_bucket调小可能会有序列号重叠的危险,毕竟Socket不通过2MSL阶段就被重用了。

不要开启tcp_tw_recycle

开启tcp_tw_recyle这个参数会在NAT环境下造成很大的影响,倡议不开启,具体见笔者的另一篇博客:

https://my.oschina.net/alchemystar/blog/3119992 

Nginx upstream改成长连贯

事实上,下面的一系列问题都是因为Nginx对Backend是短连贯导致。 Nginx从 1.1.4 开始,实现了对后端机器的长连贯反对性能。在Upstream中这样配置能够开启长连贯的性能:

upstream backend {    server 127.0.0.1:8080;# It should be particularly noted that the keepalive directive does not limit the total number of connections to upstream servers that an nginx worker             process can open. The connections parameter should be set to a number small enough to let upstream servers process new incoming connections as     well.    keepalive 32;     keepalive_timeout 30s; # 设置后端连贯的最大idle工夫为30s} 

这样前端和后端都是长连贯,大家又能够欢快的游玩了。

由此产生的危险点

因为对单个远端ip:port耗尽会导致CPU吃满这种景象。所以在Nginx在配置Upstream时候须要分外小心。假如一种状况,PE扩容了一台Nginx,为避免有问题,就先配一台Backend看看状况,这时候如果量比拟大的话击穿临界点就会造成大量报错(而利用自身确毫无压力,毕竟临界值是470.5TPS(28232/60)),甚至在同Nginx上的非此域名的申请也会因为CPU被耗尽而得不到响应。多配几台Backend/开启tcp_tw_reuse或者是不错的抉择。

总结

利用再弱小也还是承载在内核之上,始终逃不出Linux内核的樊笼。所以对于Linux内核自身参数的调优还是十分有意义的。如果读过一些内核源码,无疑对咱们排查线上问题有着很大的助力,同时也能领导咱们避过一些坑!