筆者最近解決了一個很是曲折的問題,從抓包開始一路排查到不一樣內核版本間的細微差別,最後才完美解釋了全部的現象。在這裏將整個過程寫成博文記錄下來,但願可以對讀者有所幫助。(篇幅可能會有點長,耐心看完,絕對物有所值~)html
先來介紹一下出問題的環境吧,調用拓撲以下圖所示:linux
合做方的多臺機器用NAT將多個源ip映射成同一個出口ip 20.1.1.1,而咱們內網將多個Nginx映射成同一個目的ip 30.1.1.1。這樣,在防火牆和LVS之間,全部的請求始終是經過(20.1.1.1,30.1.1.1)這樣一個ip地址對進行訪問。
同時還固定了一個參數,那就是目的端口號始終是443。
nginx
因爲對方是採用短鏈接和Nginx進行交互的,並且採用的協議是HTTP-1.0。因此咱們的Nginx在每一個請求完成後,會主動關閉鏈接,從而形成有大量的TIME_WAIT。
值得注意的是,TIME_WAIT取決於Server端和Client端誰先關閉這個Socket。因此Nginx做爲Server端先關閉的話,也必然會產生TIME_WAIT。
後端
內核參數配置以下所示:網絡
cat /proc/sys/net/ipv4/tcp_tw_reuse 0 cat /proc/sys/net/ipv4/tcp_tw_recycle 0 cat /proc/sys/net/ipv4/tcp_timestamps 1
其中tcp_tw_recycle設置爲0。這樣,能夠有效解決tcp_timestamps和tcp_tw_recycle在NAT狀況下致使的鏈接失敗問題。具體見筆者以前的博客:session
https://www.cnblogs.com/alchemystar/p/13444964.html
好了,介紹完環境,咱們就能夠正式描述Bug現場了。app
表象是合做方的應用出現大量的建立鏈接異常,而Server端確沒有任何關於這些異常的任何異常日誌,彷彿就歷來沒有出現過這些請求同樣。
負載均衡
出現問題後,筆者翻了下LVS對應的監控曲線,其中有個曲線的變現很是的詭異。以下圖所示:
什麼狀況?看上去像創建不了鏈接了?可是雖然業務有大量的報錯,依舊有很高的訪問量,看日誌的話,每秒請求應該在550向上!和這個曲線上面每秒只有30個新建鏈接是矛盾的!socket
觀察了幾天後。發現,天天都在10點左右開始發生報錯,同時在12點左右就慢慢恢復。
感受就像天天10點在作活動,致使流量超過了系統瓶頸,進而暴露出問題。而11:40以後,流量慢慢降低,系統才慢慢恢復。難道LVS這點量都撐不住?才550TPS啊?就崩潰了?tcp
難道就是傳說中的網絡問題?看了下監控,流量確實增長,不過只佔了將近1/8的帶寬,離打爆網絡還遠着呢。
無論三七二十一,先抓包吧!
在這裏筆者給出一個典型的抓包結果:
序號 | 時間 | 源地址 | 目的地址 | 源端口號 | 目的端口號 | 信息 |
---|---|---|---|---|---|---|
1 | 09:57:30.60 | 30.1.1.1 | 20.1.1.1 | 443 | 33735 | [FIN,ACK]Seq=507,Ack=2195,TSval=1164446830 |
2 | 09:57:30.64 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [FIN,ACK]Seq=2195,Ack=508,TSval=2149756058 |
3 | 09:57:30.64 | 30.1.1.1 | 20.1.1.1 | 443 | 33735 | [ACK]Seq=508,Ack=2196,TSval=1164446863 |
4 | 09:59:22.06 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [SYN]Seq=0,TSVal=21495149222 |
5 | 09:59:22.06 | 30.1.1.1 | 20.1.1.1 | 443 | 33735 | [ACK]Seq=1,Ack=1487349876,TSval=1164558280 |
6 | 09:59:22.08 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [RST]Seq=1487349876 |
上面抓包結果以下圖所示,一開始33735->443這個Socket四次揮手。在將近兩分鐘後又使用了同一個33735端口和443創建鏈接,443給33735回了一個莫名其妙的Ack,致使33735發了RST!
首先最可疑的是爲何發送了一個莫名其妙的Ack回來?筆者想到,這個Ack是WireShark給我計算出來的。爲了咱們方便,WireShark會根據Seq=0而調整Ack的值。事實上,真正的Seq是個隨機數!有沒有多是WireShark在某些狀況下計算錯誤?
仍是看看最原始的未通過加工的數據吧,因而筆者將wireshark的
Relative sequence numbers
給取消了。取消後的抓包結果立馬就有意思了!調整事後抓包結果以下所示:
序號 | 時間 | 源地址 | 目的地址 | 源端口號 | 目的端口號 | 信息 |
---|---|---|---|---|---|---|
1 | 09:57:30.60 | 30.1.1.1 | 20.1.1.1 | 443 | 33735 | [FIN,ACK]Seq=909296387,Ack=1556577962,TSval=1164446830 |
2 | 09:57:30.64 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [FIN,ACK]Seq=1556577962,Ack=909296388,TSval=2149756058 |
3 | 09:57:30.64 | 30.1.1.1 | 20.1.1.1 | 443 | 33735 | [ACK]Seq=909296388,Ack=1556577963,TSval=1164446863 |
4 | 09:59:22.06 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [SYN]Seq=69228087,TSVal=21495149222 |
5 | 09:59:22.06 | 30.1.1.1 | 20.1.1.1 | 443 | 33735 | [ACK]Seq=909296388,Ack=1556577963,TSval=1164558280 |
6 | 09:59:22.08 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [RST]Seq=1556577963 |
看錶中,四次揮手裏面的Seq和Ack對應的值和三次回收中那個錯誤的ACK徹底一致!也就是說,四次回收後,五元組並無消失,而是在111.5s內還存活着!按照TCPIP狀態轉移圖,只有TIME_WAIT狀態纔會如此。
咱們能夠看看Linux關於TIME_WAIT處理的內核源碼:
switch (tcp_timewait_state_process(inet_twsk(sk), skb, th)) { // 若是是TCP_TW_SYN,那麼容許此SYN分節重建鏈接 // 即容許TIM_WAIT狀態躍遷到SYN_RECV case TCP_TW_SYN: { struct sock *sk2 = inet_lookup_listener(dev_net(skb->dev), &tcp_hashinfo, iph->saddr, th->source, iph->daddr, th->dest, inet_iif(skb)); if (sk2) { inet_twsk_deschedule(inet_twsk(sk), &tcp_death_row); inet_twsk_put(inet_twsk(sk)); sk = sk2; goto process; } /* Fall through to ACK */ } // 若是是TCP_TW_ACK,那麼,返回記憶中的ACK,這和咱們的現象一致 case TCP_TW_ACK: tcp_v4_timewait_ack(sk, skb); break; // 若是是TCP_TW_RST直接發送RESET包 case TCP_TW_RST: tcp_v4_send_reset(sk, skb); inet_twsk_deschedule(inet_twsk(sk), &tcp_death_row); inet_twsk_put(inet_twsk(sk)); goto discard_it; // 若是是TCP_TW_SUCCESS則直接丟棄此包,不作任何響應 case TCP_TW_SUCCESS:; } goto discard_it;
上面的代碼有兩個分支,值得咱們注意,一個是TCP_TW_ACK,在這個分支下,返回TIME_WAIT記憶中的ACK和咱們的抓包現象如出一轍。還有一個TCP_TW_SYN,它代表了在
TIME_WAIT狀態下,能夠立馬重用此五元組,跳過2MSL而達到SYN_RECV狀態!
狀態的遷移就在於tcp_timewait_state_process這個函數,咱們着重看下想要觀察的分支:
enum tcp_tw_status tcp_timewait_state_process(struct inet_timewait_sock *tw, struct sk_buff *skb, const struct tcphdr *th) { bool paws_reject = false; ...... paws_reject = tcp_paws_reject(&tmp_opt, th->rst); if (!paws_reject && (TCP_SKB_CB(skb)->seq == tcptw->tw_rcv_nxt && (TCP_SKB_CB(skb)->seq == TCP_SKB_CB(skb)->end_seq || th->rst))) { ...... // 重複的ACK,discard此包 return TCP_TW_SUCCESS; } // 若是是SYN分節,並且經過了paws校驗 if (th->syn && !th->rst && !th->ack && !paws_reject && (after(TCP_SKB_CB(skb)->seq, tcptw->tw_rcv_nxt) || (tmp_opt.saw_tstamp && (s32)(tcptw->tw_ts_recent - tmp_opt.rcv_tsval) < 0))) { ...... // 返回TCP_TW_SYN,容許重用TIME_WAIT五元組從新創建鏈接 return TCP_TW_SYN; } // 若是沒有經過paws校驗,則增長統計參數 if (paws_reject) NET_INC_STATS_BH(twsk_net(tw), LINUX_MIB_PAWSESTABREJECTED); if (!th->rst) { // 若是沒有經過paws校驗,並且這個分節包含ack,則將TIMEWAIT持續時間從新延長 // 咱們抓包結果的結果沒有ACK,只有SYN,因此並不會延長 if (paws_reject || th->ack) inet_twsk_schedule(tw, &tcp_death_row, TCP_TIMEWAIT_LEN, TCP_TIMEWAIT_LEN); // 返回TCP_TW_ACK,也即TCP重傳ACK到對面 return TCP_TW_ACK; } }
根據上面源碼,PAWS(Protect Againest Wrapped Sequence numbers防止迴繞)校驗機制若是生效而拒絕此分節的話,LINUX_MIB_PAWSESTABREJECTED這個統計參數會增長,對應於Linux中的命令便是:
netstat -s | grep reject 216576 packets rejects in established connections because of timestamp
這麼上去後端的Nginx一統計,果真有大量的報錯。並且根據筆者的觀察,這個統計參數急速增長的時間段就是出問題的時間段,也就是天天早上10:00-12:00左右。每次大概會增長1W多個統計參數。
那麼何時PAWS會不經過呢,咱們直接看下tcp_paws_reject的源碼吧:
static inline int tcp_paws_reject(const struct tcp_options_received *rx_opt, int rst) { if (tcp_paws_check(rx_opt, 0)) return 0; // 若是是rst,則放鬆要求,60s沒有收到對端報文,認爲PAWS檢測經過 if (rst && get_seconds() >= rx_opt->ts_recent_stamp + TCP_PAWS_MSL) return 0; return 1; } static inline int tcp_paws_check(const struct tcp_options_received *rx_opt, int paws_win) { // 若是ts_recent中記錄的上次報文(SYN)的時間戳,小於當前報文的時間戳(TSval),代表paws檢測經過 // paws_win = 0 if ((s32)(rx_opt->ts_recent - rx_opt->rcv_tsval) <= paws_win) return 1; // 不然,上一次得到ts_recent時間戳的時刻的24天以後,爲真代表已經有超過24天沒有接收到對端的報文了,認爲PAWS檢測經過 if (unlikely(get_seconds() >= rx_opt->ts_recent_stamp + TCP_PAWS_24DAYS)) return 1; return 0; }
在抓包的過程當中,咱們明顯發現,在四次揮手時候,記錄的tsval是2149756058,而下一次syn三次握手的時候是21495149222,反而比以前的小了!
序號 | 時間 | 源地址 | 目的地址 | 源端口號 | 目的端口號 | 信息 |
---|---|---|---|---|---|---|
2 | 09:57:30.64 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [FIN,ACK]TSval=2149756058 |
4 | 09:59:22.06 | 20.1.1.1 | 30.1.1.1 | 33735 | 443 | [SYN]TSVal=21495149222 |
因此PAWS校驗不過。
那麼爲何會這個SYN時間戳比以前揮手的時間戳還小呢?那固然是NAT的鍋嘍,NAT把多臺機器的ip虛擬成同一個ip。可是多臺機器的時間戳(也即從啓動開始到如今的時間,非牆上時間),以下圖所示:
可是還有一個疑問,筆者記得TIME_WAIT也即2MSL在Linux的代碼裏面是定義爲了60s。爲什麼抓包的結果卻存活了將近2分鐘之久呢?
因而筆者開始閱讀器關於TIME_WAIT定時器的源碼,具體可見筆者的另外一篇博客:
從Linux源碼看TIME_WAIT狀態的持續時間 https://www.cnblogs.com/alchemystar/p/13883871.html
結論以下
在TIME_WAIT不少的狀態下,TIME_WAIT可以存活112.5s,將近兩分鐘的時間,和咱們的抓包結果一致。
固然了,這個計算只是針對Linux 2.6和3.10內核而言,而對紅帽維護的3.10.1127內核版本則會有另外的變化,這個變化致使了一個令筆者感到很是奇異的現象,這個在後面會提到。
如上面所解釋,只有在Server端TIME_WAIT尚未消失時候,重用這個Socket的時候,趕上了反序的時間戳SYN,就會發生這種問題。因爲NAT前面的全部機器時間戳都不相同,因此有很大機率會致使時間戳反序!
筆者知道,防火牆的端口號選擇邏輯是RoundRobin的,也即從2048開始一直增加到65535,再回繞到2048,以下圖所示:
但咱們在線下壓測的時候,明顯速率遠超560tps,那爲什麼確沒有這樣的問題出現呢。很簡單,是由於
TCP_SYN_SUCCESS這個分支,因爲咱們的壓測機沒有過NAT,那麼時間戳始終保持單IP下的單調遞增,即使>560TPS以後,走的也是TCP_SYN_SUCCESS,將TIME_WAIT Socket重用爲SYN_RECV,天然不會出現這樣的問題,以下圖所示:
等等,564TPS?這個和LVS陡然下跌的TPS基本相同!難道在端口號複用以後LVS就不會新建鏈接(實際上是LVS中的session表項)?從而致使統計參數並不增長?
因而筆者直接去擼了一下LVS的源碼:
tcp_conn_schedule |->ip_vs_schedule /* 若是新建conn表項成功,則對已有鏈接數++ */ |->ip_vs_conn_stats 而在咱們的入口函數ip\_vs\_in中 static unsigned int ip_vs_in(unsigned int hooknum, struct sk_buff *skb, const struct net_device *in, const struct net_device *out, int (*okfn) (struct sk_buff *)) { ...... // 若是能找到對應的五元組 cp = pp->conn_in_get(af, skb, pp, &iph, iph.len, 0, &res_dir); if (likely(cp)) { /* For full-nat/local-client packets, it could be a response */ if (res_dir == IP_VS_CIDX_F_IN2OUT) { return handle_response(af, skb, pp, cp, iph.len); } } else { /* create a new connection */ int v; // 找不到對應的五元組,則新建鏈接,同時conn++ if (!pp->conn_schedule(af, skb, pp, &v, &cp)) return v; } ...... }
很明顯的,若是當前五元組表項存在,則直接複用表項,而不存在,才建立新的表項,同時conn++。而表項須要在LVS的Fintimeout時間超事後才消失(在筆者的環境裏面是120s)。這樣,在端口號複用的時候,由於<112.5s,因此LVS會直接複用表項,而統計參數不會有任何變化,從而致使了下面這個曲線。
當流量慢慢變小,沒法達到重用端口號的條件的時候,曲線又會垂直上升。和筆者的推測一致。也就是說在五元組固定四元的狀況下>529tps(63487/120)的時候,在此固定業務下的新建鏈接數不會增長。
而圖中僅存的560-529=>21+個鏈接建立,是由另外一個業務的vip引發,在這個vip上,因爲量很小,沒有端口複用。可是LVS統計的是總數量,因此在端口號開始複用以後,始終會有少許的新建鏈接存在。
值得注意的是,端口號複用以後,LVS轉發的時候就會直接使用這個映射表項,因此相同的五元組到LVS後會轉發給相同的Nginx,而不會進行WRR(Weight Round Robin)負載均衡,表現出了必定的"親和性"。以下圖所示:
好了,如今能夠下結論了。在ip源和目的地址固定,目的端口號也固定的狀況下,五元組的可變量只有ip源端口號了。而源端口號最可能是65535個,若是計算保留端口號(0-2048)的話(假設防火牆保留2048個),那麼最多可以使用63487個端口。
因爲每使用一個端口號,在高負載的狀況下,都會產生一個112.5s才消失的TIME_WAIT。那麼在63487/112.5也就是564TPS(使用短鏈接)的狀況下,就會複用TIME_WAIT下的Socket。再加上PAWS校驗,就會形成大量的鏈接建立異常!
這個論斷和筆者觀察到的應用報錯以及LVS監控曲線一致。
由於LVS是在529TPS時候開始垂直降低,而端口號複用是在564TPS的時候開始,二者所需TPS很是接近,因此通常LVS出現曲線異常的時候,基本就是開始報錯的時候!可是LVS曲線異常只能代表複用表項,並不能代表必定有問題,由於能夠經過調節某些內核參數使得在端口號複用的時候不報錯!
在端口號複用狀況下,lvs自己的新建鏈接數沒法表明真實TPS。
首先,筆者嘗試限制Nginx所在Linux中最大TIME_WAIT數量
echo '5000' > /proc/sys/net/ipv4/tcp_tw_max_bucket
這基於一個很簡單的想法,TIME_WAIT狀態越少,那麼命中TIME_WAIT狀態Socket的機率確定越小。設置了以後,確實報錯量確實減小了好多。但因爲TPS超越極限以後端口號不停的迴繞,致使仍是一直在報錯,不會有根本性好轉。
若是將tcp_tw_max_bucket設置爲0,那麼按理論上來講不會出問題了。
可是無疑將TCP精心設計的TIME_WAIT這個狀態給廢棄了,筆者以爲這樣作過於冒險,因而沒有進行嘗試。
這個問題本質是因爲五元組在限定了4元,只有源端口號可變的狀況下,端口號只有
2048-65535可用。那麼咱們放開源地址的限定,例如將源IP增長到3個,無疑能夠將TPS擴大三倍。
同理,將目的地址給擴容,也能達到相似的效果。
但據網工反映,合做方經過他們的防火牆出來以後就只有一個IP,而一個IP在咱們的防火牆上並不能映射成多個IP,多以在不變動它們網絡設置的狀況下沒法擴展源地址。而擴容目的地址,也須要對合做方網絡設置進行修改。本着不讓合做方改動的服務精神,筆者開始嘗試其它方案。
在一開始筆者沒有搞明白LVS那個詭異的曲線的時候,筆者並不知道在端口複用的狀況下,LVS會表現出"親和性"。因而想着,若是擴容Nginx後,根據負載均衡原則,正好落到有這個TIME_WAIT五元組的機率會下降,因此嘗試着另擴容了一倍的Nginx。但因爲以前所說的LVS在端口號複用下的親和性,反而加大了TIME_WAIT段!
在筆者想明白LVS的"親和性"以後,對擴容Nginx會致使更多的報錯已經有了心理預期,不過被現實啪啪啪打臉!報錯量和以前基本同樣。更奇怪的是,筆者發現非活躍鏈接數監控(即非ESTABLISHED)狀態,會在端口號複用以後,呈現出一種負載不均衡的現象,以下圖所示。
筆者上去新擴容的Nginx看了一下,發現新Nginx只有不多量的因爲PAWS引發的報錯,增加速率很慢,基本1個小時只有100多。而舊Nginx一個小時就有1W多!
那麼按照這個錯誤比例分佈,就很好理解爲何造成這樣的曲線了。由於LVS的親和性,在端口號複用時刻,落到舊Nginx上會大機率失敗,從而在Fintimeout到期後,從新選擇一個負載均衡的時候,若是落到新Nginx上,按照統計參數來看基本都會成功,但若是仍是落到舊Nginx上則基本還會失敗,如此往復。就自然的造成了一個優先選擇的過程,從而形成了這個曲線。
固然實際的過程會比這個複雜一點,多一些步驟,但大致是這個思路。
而在端口複用結束後,無論落到哪一個Nginx上都會成功,因此負載均衡又會慢慢趨於均衡。
新擴容的Nginx表現異常優異,在這個TPS下沒有問題,那究竟是爲何呢?筆者想了一天都沒想明白。睡了一覺以後,對比了二者的內核參數,忽然豁然開朗。原來新擴容的Nginx所在的內核版本變了,變成了3.10!
筆者連忙對比起了原來的2.6內核和3.10的內核版本變化,但毫無所得。。。思惟有陷入了停滯
等等,咱們線上的內核版本是3.10.1127,並非官方的內核,難道代碼有所不一樣?因而筆者立馬下載了3.10.1127的源碼。這一比對,終於讓筆者找到了緣由所在,看以下代碼!
void inet_twdr_twkill_work(struct work_struct *work) { struct inet_timewait_death_row *twdr = container_of(work, struct inet_timewait_death_row, twkill_work); bool rearm_timer = false; int i; BUILD_BUG_ON((INET_TWDR_TWKILL_SLOTS - 1) > (sizeof(twdr->thread_slots) * 8)); while (twdr->thread_slots) { spin_lock_bh(&twdr->death_lock); for (i = 0; i < INET_TWDR_TWKILL_SLOTS; i++) { if (!(twdr->thread_slots & (1 << i))) continue; while (inet_twdr_do_twkill_work(twdr, i) != 0) { // 若是此次沒處理完,將rearm_timer設置爲true rearm_timer = true; if (need_resched()) { spin_unlock_bh(&twdr->death_lock); schedule(); spin_lock_bh(&twdr->death_lock); } } twdr->thread_slots &= ~(1 << i); } spin_unlock_bh(&twdr->death_lock); } // 在這邊多了一個rearm_timer,並將定時器設置爲1s以後 // 這樣,原來須要額外等待的7.5s如今收斂爲額外等待1s if (rearm_timer) mod_timer(&twdr->tw_timer, jiffies + HZ); }
如代碼所示,3.10.1127對TIME_WAIT的時間輪處理作了加速,讓原來須要額外等待的7.5s收斂爲額外等待的1s。通過校訂後的時間輪以下所示:
那麼TIME_WAIT的存活時間就從112.5s降低到60.5s(計算公式8.5*7+1)。
那麼,在這個狀態下,咱們的端口複用臨界TPS就達到了(65535-2048)/60.5=1049tps,因爲線上業務量並無達到這一tps。因此對於新擴容的Nginx,並不會形成TIME_WAIT下的端口複用。因此錯誤量並無變多!固然,因爲舊Nginx的存在,錯誤量也沒有變少。
可是,因爲那個神奇的選擇性負載均衡的存在,在端口複用時間越長,每秒鐘的報錯量會越少!直到LVS的表項所有指到新Nginx集羣,就不會再有報錯了!
固然了,根據上面的計算,在TPS繼續上漲到1049後,依舊會產生錯誤。新版本內核只不過拉高了臨界值,因此筆者仍是要尋求更加完全的解決方案。
Linux TCP的實現對TIME_WAIT的處理用時間輪在筆者看來並非什麼高明的處理方式。
Linux自己對於Timer的處理自己就提供了紅黑樹這樣的方案。放着這樣好的方案不用,恰恰去實現一個精度不高還很複雜的時間輪。
所幸在Linux 4.x版本中,擯棄了時間輪,直接使用Linux自己的紅黑樹方案。感受天然多了!
筆者一開始並不想修改這個參數,由於修改意味着關閉PAWS校驗。要是真有個什麼亂序包之類,就少了一層防護手段。可是事到現在,爲了避免讓合做方修改,只能改這個參數了。不過因爲是咱們是專線!因此風險可控。
echo '0' > /proc/sys/net/ipv4/tcp_tw_max_bucket
運行至今,業務上反饋良好。終於,這個問題終於被解決了!!!!!!
補充一句,關閉tcp_timestamps只是筆者在種種限制下所作的選擇,更好的方案應該是擴充源或者目的地址。
解決這個問題真的是一波三折。在問題解決過程當中,從LVS源碼看到Linux 2.6內核對TIME_WAIT狀態的處理,再到3.10內核和3.10.1127內核之間的細微區別。
爲了解釋全部的疑點,筆者始終在找尋着各類蛛絲馬跡。雖然不追尋這些,問題依舊大機率可以經過各類嘗試獲得解決。可是,那些奇怪的曲線始終縈繞在筆者心頭,讓筆者日思夜想。而後,忽然靈光乍現,找到線索後頓悟的那種感受實在是太棒了!這也是筆者解決複雜問題源源不斷的動力!
歡迎你們關注我公衆號,裏面有各類乾貨,還有大禮包相送哦!