解Bug之路——Nginx 502 Bad Gateway

前言

事实证实,读过Linux内核源码确实有很大的好处,尤为在处理问题的时刻。当你看到报错的那一瞬间,就能把现象/缘由/以及解决方案一股脑的在脑中闪现。甚至一些边边角角的现象都能很快的反应过来是为什么。笔者读过一些Linux TCP协议栈的源码,就在解决下面这个问题的时候有一种很是流畅的感受。前端

Bug现场

首先,这个问题其实并不难解决,可是这个问题引起的现象却是挺有意思。先描述一下现象吧,笔者要对自研的dubbo协议隧道网关进行压测(这个网关的设计也挺有意思,准备放到后面的博客里面)。先看下压测的拓扑吧:node

501064d41dcf4f40bea8e753e7debe4d

为了压测笔者gateway的单机性能,两端仅仅各保留一台网关,即gateway1和gateway2。压到必定程度就开始报错,致使压测中止。很天然的就想到,网关扛不住了。nginx

网关的状况

去Gateway2的机器上看了一下,没有任何报错。而Gateway1则有大量的502报错。502是Bad Gateway,Nginx的经典报错,首先想到的就是Gateway2不堪重负被Nginx在Upstream中踢掉。后端

d2f327ec6de943e89778dc2db44d609e

那么,就先看看Gateway2的负载状况把,查了下监控,发现Gateway2在4核8G的机器上只用了一个核,彻底看不出来有瓶颈的样子,难道是IO有问题?看了下小的可怜的网卡流量打消了这个猜测。cookie

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

这时候,发现一个有意思的现象,Nginx确用满了CPU!网络

dfd9dcdc19da41ee955b6e1efd17dfea

再次压测,去Nginx所在机器上top了一下,发现Nginx的4个Worker分别占了一个核把CPU吃满-_-!运维

1400f2d38f4a49709f3b83b9d75ca10a

什么,号称性能强悍的Nginx居然这么弱,说好的事件驱动\epoll边沿触发\纯C打造的呢?必定是用的姿式不对!socket

去掉Nginx直接通讯毫无压力

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

3d3843b0a36d4425b30853bdca23192b

去Nginx上看下日志

因为Nginx机器权限并不在笔者手上,因此一开始没有关注其日志,如今就联系一下对应的运维去看一下吧。在accesslog里面发现了大量的502报错,确实是Nginx的。又看了下错误日志,发现有大量的ide

Cannot assign requested address

因为笔者读过TCP源码,一瞬间就反应过来,是端口号耗尽了!因为Nginx upstream和后端Backend默认是短链接,因此在大量请求流量进来的时候回产生大量TIME_WAIT的链接。

741cf50e0232448aa89f05e9f3180988

而这些TIME_WAIT是占据端口号的,并且基本要1分钟左右才能被Kernel回收。

87f0d029d0a549ba86dada05ea85685b

cat /proc/sys/net/ipv4/ip_local_port_range
32768	61000

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

cf95760ad33443bf901539da3c70f3de

为何Nginx的CPU是100%

而笔者也很快想明白了Nginx为何吃满了机器的CPU,问题就出来端口号的搜索过程。

e8bfea7fc4e44320a739eeffe5334f1e

让咱们看下最耗性能的一段函数:

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的存在,因此每次搜索从下一个待分配的端口号开始计算,以个位数的搜索就能找到端口号。以下图所示:

b785282e55784eaea55a3fd7f54ad70b

因此当端口号耗尽后,Nginx的Worker进程就沉浸在上述for循环中不可自拔,把CPU吃满。

11870a77d6bb4b94b645abb31330c257

为何Gateway1调用Nginx没有问题

很简单,由于笔者在Gateway1调用Nginx的时候设置了Keepalived,因此采用的是长链接,就没有这个端口号耗尽的限制。

ac76c34b0df744f89f4a4e349fe8c230

Nginx 后面有多台机器的话

因为是由于端口号搜索致使CPU 100%,并且但凡是有可用端口号,由于hint的缘由,搜索次数可能就是1和28232的区别。

cce2e8db73c84b1ea619e91f94a7ac90

由于端口号限制是针对某个特定的远端server:port的。因此,只要Nginx的Backend有多台机器,甚至同一个机器上的多个不一样端口号,只要不超过临界点,Nginx就不会有任何压力。

ca8014b01d4b4b92a81f11859ac65241

把端口号范围调大

比较无脑的方案固然是把端口号范围调大,这样就能抗更多的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_range
22768	61000
cat /proc/sys/net/ipv4/tcp_max_tw_buckets
20000

开启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_established
static 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;	
}

上面这段代码逻辑以下所示:

4bb71f935bb7404696990e9175db7e19

在开启了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设置的时机以下图所示:

41aebeb1b2044b39845ed4251031490a

因此若是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环境下形成很大的影响,建议不开启。

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
}

这样前端和后端都是长链接,你们又能够愉快的玩耍了。

490b183c134f462c921f9e05bb89775d

由此产生的风险点

因为对单个远端ip:port耗尽会致使CPU吃满这种现象。因此在Nginx在配置Upstream时候须要格外当心。假设一种状况,PE扩容了一台Nginx,为防止有问题,就先配一台Backend看看状况,这时候若是量比较大的话击穿临界点就会形成大量报错(而应用自己确毫无压力,毕竟临界值是470.5TPS(28232/60)),甚至在同Nginx上的非此域名的请求也会由于CPU被耗尽而得不到响应。多配几台Backend/开启tcp_tw_reuse或许是不错的选择。

总结

应用再强大也仍是承载在内核之上,始终逃不出Linux内核的樊笼。因此对于Linux内核自己参数的调优仍是很是有意义的。若是读过一些内核源码,无疑对咱们排查线上问题有着很大的助力,同时也能指导咱们避过一些坑!

相关文章
相关标签/搜索