解Bug之路-Nginx 502 Bad Gateway
解Bug之路-Nginx 502 Bad Gateway
前言
事實(shí)證明,讀過Linux內(nèi)核源碼確實(shí)有很大的好處,尤其在處理問題的時(shí)刻。當(dāng)你看到報(bào)錯(cuò)的那一瞬間,就能把現(xiàn)象/原因/以及解決方案一股腦的在腦中閃現(xiàn)。甚至一些邊邊角角的現(xiàn)象都能很快的反應(yīng)過來是為何。筆者讀過一些Linux TCP協(xié)議棧的源碼,就在解決下面這個(gè)問題的時(shí)候有一種非常流暢的感覺。
Bug現(xiàn)場(chǎng)
首先,這個(gè)問題其實(shí)并不難解決,但是這個(gè)問題引發(fā)的現(xiàn)象倒是挺有意思。先描述一下現(xiàn)象吧,
筆者要對(duì)自研的dubbo協(xié)議隧道網(wǎng)關(guān)進(jìn)行壓測(cè)(這個(gè)網(wǎng)關(guān)的設(shè)計(jì)也挺有意思,準(zhǔn)備放到后面的博客里面)。先看下壓測(cè)的拓?fù)浒?
為了壓測(cè)筆者gateway的單機(jī)性能,兩端僅僅各保留一臺(tái)網(wǎng)關(guān),即gateway1和gateway2。壓到一定程度就開始報(bào)錯(cuò),導(dǎo)致壓測(cè)停止。很自然的就想到,網(wǎng)關(guān)扛不住了。
網(wǎng)關(guān)的情況
去Gateway2的機(jī)器上看了一下,沒有任何報(bào)錯(cuò)。而Gateway1則有大量的502報(bào)錯(cuò)。502是Bad Gateway,Nginx的經(jīng)典報(bào)錯(cuò),首先想到的就是Gateway2不堪重負(fù)被Nginx在Upstream中踢掉。
那么,就先看看Gateway2的負(fù)載情況把,查了下監(jiān)控,發(fā)現(xiàn)Gateway2在4核8G的機(jī)器上只用了一個(gè)核,完全看不出來有瓶頸的樣子,難道是IO有問題?看了下小的可憐的網(wǎng)卡流量打消了這個(gè)猜想。
Nginx所在機(jī)器CPU利用率接近100%
這時(shí)候,發(fā)現(xiàn)一個(gè)有意思的現(xiàn)象,Nginx確用滿了CPU!
再次壓測(cè),去Nginx所在機(jī)器上top了一下,發(fā)現(xiàn)Nginx的4個(gè)Worker分別占了一個(gè)核把CPU吃滿-_-!
什么,號(hào)稱性能強(qiáng)悍的Nginx竟然這么弱,說好的事件驅(qū)動(dòng)\epoll邊沿觸發(fā)\純C打造的呢?一定是用的姿勢(shì)不對(duì)!
去掉Nginx直接通信毫無壓力
既然猜測(cè)是Nginx的瓶頸,就把Nginx去掉吧。Gateway1和Gateway2直連,壓測(cè)TPS里面就飆升了,而且Gateway2的CPU最多也就吃了2個(gè)核,毫無壓力。
去Nginx上看下日志
由于Nginx機(jī)器權(quán)限并不在筆者手上,所以一開始沒有關(guān)注其日志,現(xiàn)在就聯(lián)系一下對(duì)應(yīng)的運(yùn)維去看一下吧。在accesslog里面發(fā)現(xiàn)了大量的502報(bào)錯(cuò),確實(shí)是Nginx的。又看了下錯(cuò)誤日志,發(fā)現(xiàn)有大量的
Cannot assign requested address
由于筆者讀過TCP源碼,一瞬間就反應(yīng)過來,是端口號(hào)耗盡了!由于Nginx upstream和后端Backend默認(rèn)是短連接,所以在大量請(qǐng)求流量進(jìn)來的時(shí)候回產(chǎn)生大量TIME_WAIT的連接。
而這些TIME_WAIT是占據(jù)端口號(hào)的,而且基本要1分鐘左右才能被Kernel回收。
cat /proc/sys/net/ipv4/ip_local_port_range
32768 61000
也就是說,只要一分鐘之內(nèi)產(chǎn)生28232(61000-32768)個(gè)TIME_WAIT的socket就會(huì)造成端口號(hào)耗盡,也即470.5TPS(28232/60),只是一個(gè)很容易達(dá)到的壓測(cè)值。事實(shí)上這個(gè)限制是Client端的,Server端沒有這樣的限制,因?yàn)镾erver端口號(hào)只有一個(gè)8080這樣的有名端口號(hào)。而在
upstream中Nginx扮演的就是Client,而Gateway2就扮演的是Nginx
為什么Nginx的CPU是100%
而筆者也很快想明白了Nginx為什么吃滿了機(jī)器的CPU,問題就出來端口號(hào)的搜索過程。
讓我們看下最耗性能的一段函數(shù):
int __inet_hash_connect(...)
{
// 注意,這邊是static變量
static u32 hint;
// hint有助于不從0開始搜索,而是從下一個(gè)待分配的端口號(hào)搜索
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;
......
}
看上面那段代碼,如果一直沒有端口號(hào)可用的話,則需要循環(huán)remaining次才能宣告端口號(hào)耗盡,也就是28232次。而如果按照正常的情況,因?yàn)橛衕int的存在,所以每次搜索從下一個(gè)待分配的端口號(hào)開始計(jì)算,以個(gè)位數(shù)的搜索就能找到端口號(hào)。如下圖所示:
所以當(dāng)端口號(hào)耗盡后,Nginx的Worker進(jìn)程就沉浸在上述for循環(huán)中不可自拔,把CPU吃滿。
為什么Gateway1調(diào)用Nginx沒有問題
很簡(jiǎn)單,因?yàn)楣P者在Gateway1調(diào)用Nginx的時(shí)候設(shè)置了Keepalived,所以采用的是長(zhǎng)連接,就沒有這個(gè)端口號(hào)耗盡的限制。
Nginx 后面有多臺(tái)機(jī)器的話
由于是因?yàn)槎丝谔?hào)搜索導(dǎo)致CPU 100%,而且但凡有可用端口號(hào),因?yàn)閔int的原因,搜索次數(shù)可能就是1和28232的區(qū)別。
因?yàn)槎丝谔?hào)限制是針對(duì)某個(gè)特定的遠(yuǎn)端server:port的。
所以,只要Nginx的Backend有多臺(tái)機(jī)器,甚至同一個(gè)機(jī)器上的多個(gè)不同端口號(hào),只要不超過臨界點(diǎn),Nginx就不會(huì)有任何壓力。
把端口號(hào)范圍調(diào)大
比較無腦的方案當(dāng)然是把端口號(hào)范圍調(diào)大,這樣就能抗更多的TIME_WAIT。同時(shí)將tcp_max_tw_bucket調(diào)小,tcp_max_tw_bucket是kernel中最多存在的TIME_WAIT數(shù)量,只要port范圍 - tcp_max_tw_bucket大于一定的值,那么就始終有port端口可用,這樣就可以避免再次到調(diào)大臨界值得時(shí)候繼續(xù)擊穿臨界點(diǎn)。
cat /proc/sys/net/ipv4/ip_local_port_range
22768 61000
cat /proc/sys/net/ipv4/tcp_max_tw_buckets
20000
開啟tcp_tw_reuse
這個(gè)問題Linux其實(shí)早就有了解決方案,那就是tcp_tw_reuse這個(gè)參數(shù)。
echo '1' > /proc/sys/net/ipv4/tcp_tw_reuse
事實(shí)上TIME_WAIT過多的原因是其回收時(shí)間竟然需要1min,這個(gè)1min其實(shí)是TCP協(xié)議中規(guī)定的2MSL時(shí)間,而Linux中就固定為1min。
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT
* state, about 60 seconds */
2MSL的原因就是排除網(wǎng)絡(luò)上還殘留的包對(duì)新的同樣的五元組的Socket產(chǎn)生影響,也就是說在2MSL(1min)之內(nèi)重用這個(gè)五元組會(huì)有風(fēng)險(xiǎn)。為了解決這個(gè)問題,Linux就采取了一些列措施防止這樣的情況,使得在大部分情況下1s之內(nèi)的TIME_WAIT就可以重用。下面這段代碼,就是檢測(cè)此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中找到一個(gè)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;
}
}
......
}
而其中的核心函數(shù)就是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))) {
// 對(duì)write_seq設(shè)置為snd_nxt+65536+2
// 這樣能夠確保在數(shù)據(jù)傳輸速率<=80Mbit/s的情況下不會(huì)被回繞
tp->write_seq = tcptw->tw_snd_nxt + 65535 + 2
......
return 1;
}
return 0;
}
上面這段代碼邏輯如下所示:
在開啟了tcp_timestamp以及tcp_tw_reuse的情況下,在Connect搜索port時(shí)只要比之前用這個(gè)port的TIME_WAIT狀態(tài)的Socket記錄的最近時(shí)間戳>1s,就可以重用此port,即將之前的1分鐘縮短到1s。同時(shí)為了防止?jié)撛诘男蛄刑?hào)沖突,直接將write_seq加上在65537,這樣,在單Socket傳輸速率小于80Mbit/s的情況下,不會(huì)造成序列號(hào)重疊(沖突)。
同時(shí)這個(gè)tw_ts_recent_stamp設(shè)置的時(shí)機(jī)如下圖所示:
所以如果Socket進(jìn)入TIME_WAIT狀態(tài)后,如果一直有對(duì)應(yīng)的包發(fā)過來,那么會(huì)影響此TIME_WAIT對(duì)應(yīng)的port是否可用的時(shí)間。
開啟了這個(gè)參數(shù)之后,由于從1min縮短到1s,那么Nginx單臺(tái)對(duì)單Upstream可承受的TPS就從原來的470.5TPS(28232/60)一躍提升為28232TPS,增長(zhǎng)了60倍。
如果還嫌性能不夠,可以配上上面的端口號(hào)范圍調(diào)大以及tcp_max_tw_bucket調(diào)小繼續(xù)提升tps,不過tcp_max_tw_bucket調(diào)小可能會(huì)有序列號(hào)重疊的風(fēng)險(xiǎn),畢竟Socket不經(jīng)過2MSL階段就被重用了。
不要開啟tcp_tw_recycle
開啟tcp_tw_recyle這個(gè)參數(shù)會(huì)在NAT環(huán)境下造成很大的影響,建議不開啟,具體見筆者的另一篇博客:
https://my.oschina.net/alchemystar/blog/3119992
Nginx upstream改成長(zhǎng)連接
事實(shí)上,上面的一系列問題都是由于Nginx對(duì)Backend是長(zhǎng)連接導(dǎo)致。
Nginx從 1.1.4 開始,實(shí)現(xiàn)了對(duì)后端機(jī)器的長(zhǎng)連接支持功能。在Upstream中這樣配置可以開啟長(zhǎng)連接的功能:
upstream backend {
server 127.0.0.1:8080;
keepalive 32; # 后端長(zhǎng)連接數(shù)量
keepalive_timeout 30s; # 設(shè)置后端連接的最大idle時(shí)間為30s
}
這樣前端和后端都是長(zhǎng)連接,大家又可以愉快的玩耍了。
由此產(chǎn)生的風(fēng)險(xiǎn)點(diǎn)
由于對(duì)單個(gè)遠(yuǎn)端ip:port耗盡會(huì)導(dǎo)致CPU吃滿這種現(xiàn)象。所以在Nginx在配置Upstream時(shí)候需要格外小心。假設(shè)一種情況,PE擴(kuò)容了一臺(tái)Nginx,為防止有問題,就先配一臺(tái)Backend看看情況,這時(shí)候如果量比較大的話擊穿臨界點(diǎn)就會(huì)造成大量報(bào)錯(cuò)(而應(yīng)用本身確毫無壓力,畢竟臨界值是470.5TPS(28232/60)),甚至在同Nginx上的非此域名的請(qǐng)求也會(huì)因?yàn)镃PU被耗盡而得不到響應(yīng)。多配幾臺(tái)Backend/開啟tcp_tw_reuse或許是不錯(cuò)的選擇。
總結(jié)
應(yīng)用再強(qiáng)大也還是承載在內(nèi)核之上,始終逃不出Linux內(nèi)核的樊籠。所以對(duì)于Linux內(nèi)核本身參數(shù)的調(diào)優(yōu)還是非常有意義的。如果讀過一些內(nèi)核源碼,無疑對(duì)我們排查線上問題有著很大的助力,同時(shí)也能指導(dǎo)我們避過一些坑!
