從STGW流量下降探秘內(nèi)核收包機(jī)制
問題現(xiàn)象
在STGW現(xiàn)網(wǎng)運(yùn)營中,出現(xiàn)了一起流量突然下降的Case,此時(shí)我們的健康撥測機(jī)制探測到失敗,并且用戶側(cè)重試次數(shù)增多、請(qǐng)求延遲增大。但通過已有的各類監(jiān)控進(jìn)行定位,只發(fā)現(xiàn)整體CPU、內(nèi)存、進(jìn)程狀態(tài)、QPS(每秒請(qǐng)求數(shù))等關(guān)鍵指標(biāo)雖然出現(xiàn)波動(dòng),但均未超過告警水位。
如圖,流量出現(xiàn)了跌幅,并且出現(xiàn)健康檢查撥測失敗。
但是,整體CPU在流量出現(xiàn)缺口的期間,并未超過閾值,反而有一些下降,隨后因?yàn)榛謴?fù)正常流量沖高才出現(xiàn)一個(gè)小毛刺。
此外,內(nèi)存和應(yīng)用層監(jiān)控,都沒有發(fā)現(xiàn)明顯異常。
前期探索
顯然,僅憑這些常規(guī)監(jiān)控,無法定位問題根本原因,盡量拿到更多的問題信息,成為了當(dāng)務(wù)之急。幸運(yùn)的是,從STGW自研的秒級(jí)監(jiān)控系統(tǒng)中,我們查到了一些關(guān)鍵的信息。
在STGW自研的監(jiān)控系統(tǒng)里,我們增加了核心資源細(xì)粒度監(jiān)控,針對(duì)CPU、內(nèi)存、內(nèi)核網(wǎng)絡(luò)協(xié)議棧這些核心指標(biāo)支持秒級(jí)監(jiān)控、監(jiān)控指標(biāo)更細(xì)化,如下圖就是出問題時(shí)間段,cpu各個(gè)核心的秒級(jí)消耗情況。
通過STGW CPU細(xì)粒度監(jiān)控展示的信息,可以看到在出現(xiàn)問題的時(shí)間段內(nèi),部分CPU核被跑滿,并且是由于軟中斷消耗造成,回溯整個(gè)問題時(shí)間段,我們還發(fā)現(xiàn),在一段長時(shí)間內(nèi),這種軟中斷熱點(diǎn)偏高都會(huì)在幾個(gè)固定的核上出現(xiàn),不會(huì)轉(zhuǎn)移給其他核。
此外,STGW的監(jiān)控模塊支持在出現(xiàn)系統(tǒng)核心資源異常時(shí),抓取當(dāng)時(shí)的函數(shù)調(diào)用棧信息,有了函數(shù)調(diào)用信息,我們能更準(zhǔn)確的知道是什么造成了系統(tǒng)核心資源異常,而不是繼續(xù)猜想。如圖展示了STGW監(jiān)控抓到的函數(shù)調(diào)用及cpu占比信息:
通過函數(shù)棧監(jiān)控信息,我們發(fā)現(xiàn)了inet_lookup_listener函數(shù)是當(dāng)時(shí)CPU軟中斷熱點(diǎn)的主要消耗者。出現(xiàn)問題時(shí),其他函數(shù)調(diào)用在沒有發(fā)生多少變化情況下,inet_lookup_listener由原本很微小的cpu消耗占比,一下子沖到了TOP1。
通過這里,我們可以初步確定,inet_lookup_listener消耗過高跟軟中斷熱點(diǎn)強(qiáng)相關(guān),當(dāng)熱點(diǎn)將cpu單核跑滿后就可能引發(fā)出流量有損的問題。由于軟中斷熱點(diǎn)持續(xù)在產(chǎn)生,線上穩(wěn)定性隱患很大?;谶@個(gè)緊迫的穩(wěn)定性問題,我們從為什么產(chǎn)生熱點(diǎn)、為什么熱點(diǎn)只在部分cpu core上出現(xiàn)兩個(gè)方向,進(jìn)行了問題分析、定位和解決。
為什么產(chǎn)生了熱點(diǎn)
1. 探秘 inet_lookup_listener
由于perf已經(jīng)給我們提供了熱點(diǎn)所在,首先從熱點(diǎn)函數(shù)入手進(jìn)行分析,結(jié)合內(nèi)核代碼得知,__inet_lookup系列函數(shù)是用于將收到的數(shù)據(jù)包定位到一個(gè)具體的socket上,但只有握手包會(huì)進(jìn)入到找__inet_lookup_listener的邏輯,大部分?jǐn)?shù)據(jù)包是通過__inet_lookup_established尋找socket。
具體分析lookup_listener的代碼我們發(fā)現(xiàn),由于listen socket不具備四元組特征,因此內(nèi)核只能用監(jiān)聽端口計(jì)算一個(gè)哈希值,并使用了 listening_hash 哈希桶存起來,握手包發(fā)過來的時(shí)候,就從該哈希桶中尋找對(duì)應(yīng)的listen socket。
- struct sock *__inet_lookup_listener(struct net *net,
- struct inet_hashinfo *hashinfo,
- const __be32 saddr, __be16 sport,
- const __be32 daddr, const unsigned short hnum,
- const int dif)
- {
- // 省略了部分代碼
- // 獲取listen fd 哈希桶
- struct inet_listen_hashbucket *ilb = &hashinfo->listening_hash[hash];
- result = NULL;
- hiscore = 0;
- // 遍歷桶中的節(jié)點(diǎn)
- sk_nulls_for_each_rcu(sk, node, &ilb->head) {
- score = compute_score(sk, net, hnum, daddr, dif);
- if (score > hiscore) {
- result = sk;
- hiscore = score;
- reuseport = sk->sk_reuseport;
- if (reuseport) {
- phash = inet_ehashfn(net, daddr, hnum,
- saddr, sport);
- matches = 1;
- }
- } else if (score == hiscore && reuseport) {
- matches++;
- if (((u64)phash * matches) >> 32 == 0)
- result = sk;
- phash = next_pseudo_random32(phash);
- }
- }
- }
相對(duì)來說并不復(fù)雜的lookup_listener函數(shù)為什么會(huì)造成這么大的cpu開銷?經(jīng)過進(jìn)一步定位后,發(fā)現(xiàn)問題所在:listen哈希桶開的太小了,只有32個(gè)。
- /* This is for listening sockets, thus all sockets which possess wildcards. */
- #define INET_LHTABLE_SIZE 32 /* Yes, really, this is all you need. */
為什么內(nèi)核這里會(huì)覺得listen哈希桶大小32就滿足需要了呢?
在IETF(互聯(lián)網(wǎng)工程任務(wù)組)關(guān)于端口的規(guī)劃中,0-1023是System port,系統(tǒng)保留使用,1024-49151為Registered port,為IANA(互聯(lián)網(wǎng)數(shù)字分配機(jī)構(gòu))可分配給一些固定應(yīng)用,49152-65535是Dynamic port,是可以真正自由使用的。當(dāng)然了,這只是IETF的一個(gè)規(guī)劃,在Linux中,除了System port,另兩個(gè)端口段并未真的做了明顯區(qū)分,除非端口已經(jīng)被占用,用戶可以自由使用,這里提一個(gè)Linux中跟端口劃分有關(guān)聯(lián)的內(nèi)核參數(shù):ip_local_port_range,它表示系統(tǒng)在建立TCP/UDP連接時(shí),系統(tǒng)給它們分配的端口范圍,默認(rèn)的ip_local_port_range值是32768-60999,進(jìn)行這個(gè)設(shè)置后好處是,61000~65535端口是可以更安全的用來做為服務(wù)器監(jiān)聽,而不用擔(dān)心一些TCP連接將其占用。
因此,在正常的情況下,服務(wù)器的listen port數(shù)量,大概就是幾w個(gè)這樣的量級(jí)。這種量級(jí)下,一個(gè)port對(duì)應(yīng)一個(gè)socket,哈希桶大小為32是可以接受的。然而在內(nèi)核支持了reuseport并且被廣泛使用后,情況就不一樣了,在多進(jìn)程架構(gòu)里,listen port對(duì)應(yīng)的socket數(shù)量,是會(huì)被幾十倍的放大的。以應(yīng)用層監(jiān)聽了5000個(gè)端口,reuseport 使用了50個(gè)cpu核心為例,5000*50/32約等于7812,意味著每次握手包到來時(shí),光是查找listen socket,就需要遍歷7800多次。隨著機(jī)器硬件性能越來越強(qiáng),應(yīng)用層使用的cpu數(shù)量增多,這個(gè)問題還會(huì)繼續(xù)加劇。
正因?yàn)樯鲜鲈?,并且我們現(xiàn)網(wǎng)機(jī)器開啟了reuseport,在端口數(shù)量較多的機(jī)器里,inet_lookup_listener的哈希桶大小太小,遍歷過程消耗了cpu,導(dǎo)致出現(xiàn)了函數(shù)熱點(diǎn)。
2. 如何解決__inet_lookup_listener問題
Linux社區(qū)難道沒有注意到開啟reuseport后,原來的哈希桶大小不夠用這個(gè)問題嗎?
其實(shí)社區(qū)是注意到了這個(gè)問題的,并且有修復(fù)這個(gè)問題。
從Linux 4.17開始,Linux社區(qū)就修復(fù)了由于reuseport帶來的socket數(shù)量過多,導(dǎo)致inet_lookup_listener查找緩慢的問題,修復(fù)方案分兩步:
1. 引入了兩次查找,首先還是根據(jù)目的端口進(jìn)行哈希,接著會(huì)使用握手包中拿到的四元組信息,按照四元組進(jìn)行第一次查找,如果四元組獲取不到結(jié)果,則使用之前那種對(duì)于任意IP地址查找。
- struct sock *__inet_lookup_listener(struct net *net,
- struct inet_hashinfo *hashinfo,
- struct sk_buff *skb, int doff,
- const __be32 saddr, __be16 sport,
- const __be32 daddr, const unsigned short hnum,
- const int dif, const int sdif)
- {
- struct inet_listen_hashbucket *ilb2;
- struct sock *result = NULL;
- unsigned int hash2;
- // 根據(jù)目的端口進(jìn)行第一次哈希
- hash2 = ipv4_portaddr_hash(net, daddr, hnum);
- ilb2 = inet_lhash2_bucket(hashinfo, hash2);
- // 根據(jù)四元組信息再做一次查找
- result = inet_lhash2_lookup(net, ilb2, skb, doff,
- saddr, sport, daddr, hnum,
- dif, sdif);
- if (result)
- goto done;
- /* Lookup lhash2 with INADDR_ANY */
- // 四元組沒查到,嘗試在0.0.0.0監(jiān)聽范圍查找
- hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
- ilb2 = inet_lhash2_bucket(hashinfo, hash2);
- result = inet_lhash2_lookup(net, ilb2, skb, doff,
- saddr, sport, htonl(INADDR_ANY), hnum,
- dif, sdif);
- done:
- if (IS_ERR(result))
- return NULL;
- return result;
- }
2. 合并處理reuseport放大出來的socket,在發(fā)現(xiàn)指定的端口開啟了reuseport后,不再是遍歷式的去獲取到合適的socket,而是將其看成一個(gè)整體,二次哈希后,調(diào)用 reuseport_select_sock,取到合適的socket。
- static struct sock *inet_lhash2_lookup(struct net *net,
- struct inet_listen_hashbucket *ilb2,
- struct sk_buff *skb, int doff,
- const __be32 saddr, __be16 sport,
- const __be32 daddr, const unsigned short hnum,
- const int dif, const int sdif)
- {
- bool exact_dif = inet_exact_dif_match(net, skb);
- struct inet_connection_sock *icsk;
- struct sock *sk, *result = NULL;
- int score, hiscore = 0;
- u32 phash = 0;
- inet_lhash2_for_each_icsk_rcu(icsk, &ilb2->head) {
- sk = (struct sock *)icsk;
- score = compute_score(sk, net, hnum, daddr,
- dif, sdif, exact_dif);
- if (score > hiscore) {
- if (sk->sk_reuseport) {
- // 如果是reuseport,進(jìn)行二次哈希查找
- phash = inet_ehashfn(net, daddr, hnum,
- saddr, sport);
- result = reuseport_select_sock(sk, phash,
- skb, doff);
- if (result)
- return result;
- }
- result = sk;
- hiscore = score;
- }
- }
- return result;
- }
總結(jié)來說,社區(qū)通過引入兩次查找+合并reuseport sockets的處理,解決了reuseport帶來的sockets數(shù)量放大效果。這里結(jié)合我們的探索,另外提供兩個(gè)可行的低成本解決方案:
1. 修改內(nèi)核哈希桶大小,根據(jù)reuseport增加socket的倍數(shù),相應(yīng)提高INET_LHTABLE_SIZE,或者直接改成例如2048
- #define INET_LHTABLE_SIZE 2048
2. 關(guān)閉reuseport可以減少socket數(shù)目到32個(gè)哈希桶大小能承受的范圍,從而降低該函數(shù)消耗。
加上社區(qū)方案,這里的三個(gè)方法在本質(zhì)上都是減少listen table哈希桶的遍歷復(fù)雜度。社區(qū)的方案一套比較系統(tǒng)的方法,今后隨著內(nèi)核版本升級(jí),肯定會(huì)將這個(gè)問題解決掉。但短期升級(jí)內(nèi)核的成本較高,所以后面兩個(gè)方案就可以用來短期解決問題。此外,關(guān)閉reuseport雖然不需要更改內(nèi)核,但需要考慮應(yīng)用層server對(duì)于reuseport的依賴情況。
為什么熱點(diǎn)只在部分核心出現(xiàn)
解決完哈希桶問題后,我們并沒有定位到全部的問題,前面提到,軟中斷熱點(diǎn)僅在部分cpu核上出現(xiàn),如果僅僅是__inet_lookup_listener問題,按理所有cpu核的軟中斷消耗都會(huì)偏高。如果這里問題沒有解釋清楚,一旦出現(xiàn)熱點(diǎn)函數(shù),一些單核就會(huì)被跑滿,意味著整機(jī)容量強(qiáng)依賴部分單核的性能瓶頸,超出了單核能力就會(huì)有損,這是完全不能接受的。
1. 從CPU中斷數(shù)入手
根據(jù)問題現(xiàn)象,我們做了一些假設(shè),在這里最直觀的假設(shè)就是,我們的數(shù)據(jù)包在各個(gè)核上并不是負(fù)載均衡的。
首先,通過cat /proc/interrupts找到網(wǎng)卡在各個(gè)cpu核的中斷數(shù),發(fā)現(xiàn)網(wǎng)卡在各個(gè)核的硬中斷就已經(jīng)不均衡了。那么會(huì)是硬中斷親和性的問題嗎?接著檢查了網(wǎng)卡各個(gè)隊(duì)列的smp_affiinity,發(fā)現(xiàn)每個(gè)隊(duì)列與cpu核都是一一對(duì)應(yīng)并且互相錯(cuò)開,硬中斷親和性設(shè)置沒有問題。
緊接著,我們排查了網(wǎng)卡,我們的網(wǎng)卡默認(rèn)都打開了RSS(網(wǎng)卡多隊(duì)列),每個(gè)隊(duì)列綁定到一個(gè)核心上,既然硬中斷親和性沒有問題,那么會(huì)是網(wǎng)卡多隊(duì)列本身就不均衡嗎?通過ethtool -S eth0/eth1再過濾出每個(gè)rx_queue的收包數(shù),我們得到如下圖:
原來網(wǎng)卡多隊(duì)列收包就已經(jīng)嚴(yán)重不均衡了,以入包量升序排序,發(fā)現(xiàn)不同的rx_queue 收包數(shù)量相差達(dá)到了上萬倍!
2. 探究網(wǎng)卡多隊(duì)列(RSS)
這里我們著重檢查了幾個(gè)網(wǎng)卡多隊(duì)列的參數(shù)
- // 檢查網(wǎng)卡的隊(duì)列數(shù)
- ethtool -l eth0
- Current hardware settings:
- RX: 0
- TX: 0
- Other: 1
- Combined: 48
- // 檢查硬件哈希開關(guān)
- ethtool -k eth0
- receive-hashing: on
- // 檢查硬件哈希的參數(shù),這里顯示以TCP是以四元組信息進(jìn)行哈希
- ethtool -n eth0 rx-flow-hash tcp4
- TCP over IPV4 flows use these fields for computing Hash flow key:
- IP SA
- IP DA
- L4 bytes 0 & 1 [TCP/UDP src port]
- L4 bytes 2 & 3 [TCP/UDP dst port]
這些參數(shù)都是符合預(yù)期的,數(shù)據(jù)包會(huì)根據(jù)TCP包的四元組哈希到不同的隊(duì)列上。我們繼續(xù)使用假設(shè)論證法,會(huì)是數(shù)據(jù)包本身就是比如長連接,導(dǎo)致不均衡嗎?通過檢查我們服務(wù)端的日志,發(fā)現(xiàn)請(qǐng)求的ip和端口都是比較分散的,傳輸?shù)臄?shù)據(jù)也都是較小文件,并沒有集中化。
經(jīng)過了一番折騰,我們有了新的假設(shè),由于我們現(xiàn)網(wǎng)大部分流量是IPIP隧道及GRE封裝的數(shù)據(jù)包,在普通數(shù)據(jù)包的IP header上多了一層header,外層IP與我們在server看到的并不一樣,外層IP是非常集中的。這里是否會(huì)讓網(wǎng)卡多隊(duì)列的均衡策略失效呢?
來源網(wǎng)圖,以GRE包為例,IP數(shù)據(jù)包其實(shí)是分了外層IP頭部、gre層、內(nèi)層IP頭部,以及再往上的TCP/UDP層,如果只獲取了外層IP頭部,則較為集中,難以進(jìn)行分散。
經(jīng)過同事幫忙牽線,我們從網(wǎng)卡廠商處獲得了重要的信息,不同的網(wǎng)卡對(duì)于多隊(duì)列哈希算法是不一樣的!
從網(wǎng)卡廠商處進(jìn)一步確認(rèn)得知,我們在使用的這款網(wǎng)卡,是不支持解析封裝后的數(shù)據(jù)包的,只會(huì)以外層IP作為哈希依據(jù)。廠商提供了一款新型號(hào)的網(wǎng)卡,是支持解析IPIP及GRE內(nèi)層IP PORT的。我們經(jīng)過實(shí)測這兩種網(wǎng)卡,發(fā)現(xiàn)確實(shí)如此。
看到這里,網(wǎng)卡多隊(duì)列不均衡問題原因已經(jīng)定位清楚,由于現(xiàn)網(wǎng)使用了IPIP或GRE這類封裝協(xié)議,部分網(wǎng)卡不支持解析內(nèi)層IP PORT進(jìn)行哈希,從而導(dǎo)致多隊(duì)列不均衡,進(jìn)一步導(dǎo)致cpu硬中斷不均衡,然后不均衡的軟中斷熱點(diǎn)便出現(xiàn)了。
3. 如何解決網(wǎng)卡多隊(duì)列不均衡
對(duì)于STGW來說,我們已經(jīng)確定了不均衡的網(wǎng)卡型號(hào),都是型號(hào)較老的網(wǎng)卡,我們正在逐步使用新的網(wǎng)卡型號(hào),新網(wǎng)卡型號(hào)已驗(yàn)證支持IPIP及GRE格式的數(shù)據(jù)包負(fù)載均衡。
為什么RPS沒有起作用
Receive Packet Steering (RPS),是內(nèi)核的一種負(fù)載均衡機(jī)制,即便硬件層面收到的數(shù)據(jù)包不均衡的,RPS會(huì)對(duì)數(shù)據(jù)包再次進(jìn)行哈希與分流,保證其進(jìn)入網(wǎng)絡(luò)協(xié)議棧是均衡的。
經(jīng)過確認(rèn),出問題機(jī)器上都開啟了RPS。所以問題還是沒有解釋清楚,即便舊型號(hào)的網(wǎng)卡RSS不均衡,但經(jīng)過內(nèi)核RPS后,數(shù)據(jù)包才會(huì)送給網(wǎng)絡(luò)協(xié)議棧,然后調(diào)用_inet_lookup_listener,此時(shí)依然出現(xiàn)熱點(diǎn)不均衡,說明RPS并未生效。
1. 了解硬件及內(nèi)核收包流程
由于引入了RPS這個(gè)概念,在定位該問題前,我梳理了一份簡明收包流程,通過了解數(shù)據(jù)包是如何通過硬件、內(nèi)核、再到內(nèi)核網(wǎng)絡(luò)協(xié)議棧,可以更清晰的了解RPS所處的位置,以及我們遇到的問題。
如上圖所示,數(shù)據(jù)包在進(jìn)入內(nèi)核IP/TCP協(xié)議棧之前,經(jīng)歷了這些步驟:
- 網(wǎng)口(NIC)收到packets
- 網(wǎng)口通過DMA(Direct memeory access)將數(shù)據(jù)寫入到內(nèi)存(RAM)中。
- 網(wǎng)口通過RSS(網(wǎng)卡多隊(duì)列)將收到的數(shù)據(jù)包分發(fā)給某個(gè)rx隊(duì)列,并觸發(fā)該隊(duì)列所綁定核上的CPU中斷。
- 收到中斷的核,調(diào)用該核所在的內(nèi)核軟中斷線程(softirqd)進(jìn)行后續(xù)處理。
- softirqd負(fù)責(zé)將數(shù)據(jù)包從RAM中取到內(nèi)核中。
- 如果開啟了RPS,RPS會(huì)選擇一個(gè)目標(biāo)cpu核來處理該包,如果目標(biāo)核非當(dāng)前正在運(yùn)行的核,則會(huì)觸發(fā)目標(biāo)核的IPI(處理器之間中斷),并將數(shù)據(jù)包放在目標(biāo)核的backlog隊(duì)列中。
- 軟中斷線程將數(shù)據(jù)包(數(shù)據(jù)包可能來源于第5步、或第6步),通過gro(generic receive offload,如果開啟的話)等處理后,送往IP協(xié)議棧,及之后的TCP/UDP等協(xié)議棧。
回顧我們前面定位的問題,__inet_lookup_listener熱點(diǎn)對(duì)應(yīng)的是IP協(xié)議棧的問題,網(wǎng)卡多隊(duì)列不均衡是步驟3,RSS階段出現(xiàn)的問題。RPS則是在步驟6中。
2. 探秘RPS負(fù)載不均衡問題
通過cat /proc/net/softnet_stat,可以獲取到每個(gè)核接收的RPS次數(shù)。拿到這個(gè)數(shù)目后,我們發(fā)現(xiàn),不同的核在接收RPS次數(shù)上相差達(dá)到上百倍,并且RPS次數(shù)最多的核,正好就是軟中斷消耗出現(xiàn)熱點(diǎn)的核。
至此我們發(fā)現(xiàn),雖然網(wǎng)卡RSS存在不均衡,但RPS卻依然將過多的數(shù)據(jù)包給了部分cpu core,沒有做到負(fù)載均衡,這才是導(dǎo)致我們軟中斷熱點(diǎn)不均衡的直接原因。
通過在內(nèi)核代碼庫中找到RPS相關(guān)代碼并進(jìn)行分析,我們再次發(fā)現(xiàn)了一些可疑的點(diǎn)
- static int get_rps_cpu(struct net_device *dev, struct sk_buff *skb,
- struct rps_dev_flow **rflowp)
- {
- // 省略部分代碼
- struct netdev_rx_queue *rxqueue;
- struct rps_map *map;
- struct rps_dev_flow_table *flow_table;
- struct rps_sock_flow_table *sock_flow_table;
- int cpu = -1;
- u16 tcpu;
- skb_reset_network_header(skb);
- if (rps_ignore_l4_rxhash) {
- // 計(jì)算哈希值
- __skb_get_rxhash(skb);
- if (!skb->rxhash)
- goto done;
- }
- else if(!skb_get_rxhash(skb))
- goto done;
- // 通過哈希值計(jì)算出目標(biāo)CPU
- if (map) {
- tcpu = map->cpus[((u64) skb->rxhash * map->len) >> 32];
- if (cpu_online(tcpu)) {
- cpu = tcpu;
- goto done;
- }
- }
- done:
- return cpu;
- }
- /*
- * __skb_get_rxhash: calculate a flow hash based on src/dst addresses
- * and src/dst port numbers. Sets rxhash in skb to non-zero hash value
- * on success, zero indicates no valid hash. Also, sets l4_rxhash in skb
- * if hash is a canonical 4-tuple hash over transport ports.
- */
- void __skb_get_rxhash(struct sk_buff *skb)
- {
- struct flow_keys keys;
- u32 hash;
- if (!skb_flow_dissect(skb, &keys))
- return;
- if (keys.ports)
- skb->l4_rxhash = 1;
- // 使用TCP/UDP四元組進(jìn)行計(jì)算
- /* get a consistent hash (same value on both flow directions) */
- if (((__force u32)keys.dst < (__force u32)keys.src) ||
- (((__force u32)keys.dst == (__force u32)keys.src) &&
- ((__force u16)keys.port16[1] < (__force u16)keys.port16[0]))) {
- swap(keys.dst, keys.src);
- swap(keys.port16[0], keys.port16[1]);
- }
- // 使用jenkins哈希算法
- hash = jhash_3words((__force u32)keys.dst,
- (__force u32)keys.src,
- (__force u32)keys.ports, hashrnd);
- if (!hash)
- hash = 1;
- skb->rxhash = hash;
- }
猜想一:rps_ignore_l4_rxhash未打開,導(dǎo)致不均衡?
通過代碼發(fā)現(xiàn) rps_ignore_l4_rxhash 會(huì)影響當(dāng)前是否計(jì)算哈希值,當(dāng)前機(jī)器未設(shè)置ignore_l4_rxhash,則內(nèi)核會(huì)直接使用網(wǎng)卡RSS計(jì)算出的哈希值,根據(jù)上面定位的網(wǎng)卡RSS不均衡的結(jié)論,RSS哈希值可能是不準(zhǔn)的,這里會(huì)導(dǎo)致問題嗎?
我們將ignore_l4_rxhash開關(guān)進(jìn)行打開
- sysctl -w kernel.rps_ignore_l4_rxhash=1
發(fā)現(xiàn)并沒有對(duì)不均衡問題產(chǎn)生任何改善,排除這個(gè)假設(shè)。
猜想二:RPS所使用的哈希算法有缺陷,導(dǎo)致不均衡?
對(duì)于負(fù)載不均衡類的問題,理所應(yīng)當(dāng)會(huì)懷疑當(dāng)前使用的均衡算法存在缺陷,RPS這里使用的是jenkins hash(jhash_3words)算法,是一個(gè)比較著名且被廣泛使用的算法,經(jīng)過了很多環(huán)境的驗(yàn)證,出現(xiàn)缺陷的可能性較小。但我們還是想辦法進(jìn)行了一些驗(yàn)證,由于是內(nèi)核代碼,并且沒有提供替代性的算法,改動(dòng)內(nèi)核的代價(jià)相對(duì)較高。
因此這里我們采取的對(duì)比的手段快速確定,在同樣的內(nèi)核版本,在現(xiàn)網(wǎng)找到了負(fù)載均衡的機(jī)器,檢查兩邊的一些內(nèi)核開關(guān)和RPS配置都是一致的,說明同樣的RPS哈希算法,只是部分機(jī)器不均衡,因此這里算法側(cè)先不做進(jìn)一步挖掘。
猜想三:和RSS問題一樣,RPS也不支持對(duì)封裝后的數(shù)據(jù)進(jìn)行四元組哈希?
skb_flow_dissect是負(fù)責(zé)解析出TCP/UDP四元組,經(jīng)過初步分析,內(nèi)核是支持IPIP、GRE等通用的封裝協(xié)議,并從這些協(xié)議數(shù)據(jù)包中,取出需要的四元組信息進(jìn)行哈希。
在各種假設(shè)與折騰都沒有找到新的突破之時(shí),我們使用systemtap這個(gè)內(nèi)核調(diào)試神器,hook了關(guān)鍵的幾個(gè)函數(shù)和信息,經(jīng)過論證和測試后,在現(xiàn)網(wǎng)進(jìn)行了短暫的debug,收集到了所需要的關(guān)鍵信息。
- #! /usr/bin/env stap
- /*
- Analyse problem that softirq not balance with RPS.
- Author: dalektan@tencent.com
- Usage:
- stap -p4 analyse_rps.stp -m stap_analyse_rps
- staprun -x cpuid stap_analyse_rps.ko
- */
- // To record how cpu changed with rps execute
- private global target_cpu = 0
- private global begin_cpu
- private global end_cpu
- probe begin {
- target_cpu = target()
- begin_cpu = target_cpu - 2
- end_cpu = target_cpu + 2
- // 指定需要分析的cpu范圍,避免對(duì)性能產(chǎn)生影響
- printf("Prepare to analyse cpu is :%d-%d\n", begin_cpu, end_cpu)
- }
- // To record tsv ip addr, daddr and protocol(ipip, gre or tcp)
- probe kernel.function("ip_rcv").call {
- if (cpu() >= begin_cpu && cpu() <= end_cpu) {
- ip_protocol = ipmib_get_proto($skb)
- // if not tcp, ipip, gre, then return
- if (ip_protocol == 4 || ip_protocol == 6 || ip_protocol == 47) {
- saddr = ip_ntop(htonl(ipmib_remote_addr($skb, 0)))
- daddr = ip_ntop(htonl(ipmib_local_addr($skb, 0)))
- printf("IP %s -> %s proto:%d rx_queue:%d cpu:%d\n",
- saddr, daddr, ip_protocol, $skb->queue_mapping-1, cpu())
- }
- }
- }
- // To record tcp states
- probe tcp.receive.call {
- if (cpu() >= begin_cpu && cpu() <= end_cpu) {
- printf("TCP %s:%d -> %s:%d syn:%d rst:%d fin:%d cpu:%d\n",
- saddr, sport , daddr, dport, syn, rst, fin, cpu())
- }
- }
通過使用上述systemtap腳本進(jìn)行分析后,我們得到了一個(gè)關(guān)鍵信息,大量GRE協(xié)議(圖中proto:47)的數(shù)據(jù)包,無論其四元組是什么,都被集中調(diào)度到了單個(gè)核心上,并且這個(gè)核心正好是軟中斷消耗熱點(diǎn)核。并且其他協(xié)議數(shù)據(jù)包未出現(xiàn)這個(gè)問題。
走到這里,問題漸為開朗,GRE數(shù)據(jù)包未按預(yù)期均衡到各個(gè)核心,但根據(jù)之前的分析,RPS是支持GRE協(xié)議獲取四元組的,為什么在這里,不同的四元組,依然被哈希算成了同一個(gè)目標(biāo)核呢?
3. 探究GRE數(shù)據(jù)包不均衡之謎
帶著這個(gè)問題進(jìn)一步挖掘,通過抓包以及代碼比對(duì)分析,很快有了突破,定位到了原因是:當(dāng)前內(nèi)核僅識(shí)別GRE_VERSION=0的GRE協(xié)議包并獲取其四元組信息,而我們的數(shù)據(jù)包,是GRE_VERSION=1的。
- // skb_flow_dissect 獲取四元組信息
- switch (ip_proto) {
- case IPPROTO_GRE: {
- struct gre_hdr {
- __be16 flags;
- __be16 proto;
- } *hdr, _hdr;
- hdr = skb_header_pointer(skb, nhoff, sizeof(_hdr), &_hdr);
- if (!hdr)
- return false;
- /*
- * Only look inside GRE if version zero and no
- * routing
- */
- // 只解析GRE_VERSION = 0的GRE協(xié)議數(shù)據(jù)包
- if (!(hdr->flags & (GRE_VERSION|GRE_ROUTING))) {
- proto = hdr->proto;
- nhoff += 4;
- if (hdr->flags & GRE_CSUM)
- nhoff += 4;
- if (hdr->flags & GRE_KEY)
- nhoff += 4;
- if (hdr->flags & GRE_SEQ)
- nhoff += 4;
- if (proto == htons(ETH_P_TEB)) {
- const struct ethhdr *eth;
- struct ethhdr _eth;
- eth = skb_header_pointer(skb, nhoff,
- sizeof(_eth), &_eth);
- if (!eth)
- return false;
- proto = eth->h_proto;
- nhoff += sizeof(*eth);
- }
- goto again;
- }
- break;
- }
首先,我們先確認(rèn)一下,GRE_VERSION=1是符合規(guī)范的嗎,答案是符合的,如果去了解一下PPTP協(xié)議的話,可以知道RFC規(guī)定了PPTP協(xié)議就是使用的GRE協(xié)議封裝并且GRE_VERSION=1。
那么為什么內(nèi)核這里不支持這種標(biāo)記呢?
通過在Linux社區(qū)進(jìn)行檢索,我們發(fā)現(xiàn)Linux 4.10版本開始支持PPTP協(xié)議下的GRE包識(shí)別與四元組獲取,也就是GRE_VERSION=1的情況。由于沒有加入對(duì)PPTP協(xié)議的支持,因此出現(xiàn)不識(shí)別GRE_VERSION=1(PPTP協(xié)議)的情況,RPS不會(huì)去獲取這種數(shù)據(jù)包的四元組信息,哈希后也是不均衡的,最終導(dǎo)致單核出現(xiàn)軟中斷熱點(diǎn)。
4. 如何解決RPS不均衡問題
至此,所有的問題都已經(jīng)撥開云霧,最后對(duì)于RPS不均衡問題,這里提供三種解決方案:
- 對(duì)于RSS網(wǎng)卡多隊(duì)列已經(jīng)是均衡的機(jī)器,可以將修改kernel.rps_ignore_l4_rxhash = 0,讓RPS直接使用網(wǎng)卡硬件哈希值,由于硬件哈希值足夠分散,因此RPS效果也是均衡的。
- 對(duì)于RSS網(wǎng)卡多隊(duì)列均衡的機(jī)器,通過ethtool -S/-L查看或修改網(wǎng)卡多隊(duì)列數(shù)目,如果隊(duì)列數(shù)不少于cpu核數(shù),再將多隊(duì)列通過/proc/irq/設(shè)備id/smp_affinity分散綁定到不同的cpu核。這樣可以充分利用網(wǎng)卡RSS的均衡效果,而無需打開內(nèi)核RPS。
- 在當(dāng)前內(nèi)核進(jìn)行修改或熱補(bǔ)丁,在RPS函數(shù)中新增對(duì)GRE_VERSION=1的GRE數(shù)據(jù)包的識(shí)別與四元組獲取。
- 升級(jí)內(nèi)核到Linux 4.10之后,即可支持PPTP協(xié)議包的RPS負(fù)載均衡。
總結(jié)
最后,總結(jié)一下整個(gè)問題和定位過程,我們從一次流量下降,業(yè)務(wù)有損的問題出發(fā),從最開始找不到思路,到找到軟中斷熱點(diǎn)這個(gè)關(guān)鍵點(diǎn),再到區(qū)分IP協(xié)議棧、網(wǎng)卡多隊(duì)列、內(nèi)核收包這三個(gè)層面進(jìn)行問題入手,沒有滿足于已經(jīng)解決的部分問題,不斷深挖了下去,終于各個(gè)方向擊破,撥開了問題的層層面紗,并給出了解決方案。
借著對(duì)問題的定位和解決,收獲良多,學(xué)習(xí)了內(nèi)核收包流程,熟悉了內(nèi)核問題定位工具和手段。感謝STGW組里同事,文中諸多成果都是團(tuán)隊(duì)的共同努力。
【本文為51CTO專欄作者“騰訊技術(shù)工程”原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)聯(lián)系原作者(微信號(hào):Tencent_TEG)】