解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內核自己參數的調優仍是很是有意義的。若是讀過一些內核源碼,無疑對咱們排查線上問題有着很大的助力,同時也能指導咱們避過一些坑!

相關文章
相關標籤/搜索