服務端不迴應客戶端的syn握手,連接建立失敗原因排查

背景

測試環境有一個後臺服務,部署在內網服務器A上(無外網地址),給app提供接口。app訪問這個後臺服務時,ip地址是公網地址,那這個請求是如何到達我們的內網服務器A呢,這塊我諮詢了網絡同事,我畫了簡圖如下:

image-20230816152413599

請求會直接打到防火牆上,防火牆對請求先做了DNAT轉換(將目的地址轉換爲後臺服務器的地址192.168.1.3),另外,爲了確保後臺服務處理完請求後,能正常返回響應,所以,防火牆還做了SNAT轉換(將源地址轉換爲防火牆的內網地址192.168.1.2)。

其實我之前測試過,不做SNAT也可以正常回包,但我只是一個開發,網工這塊並不瞭解,所以網絡同事肯定是有自己的其他考慮,總之,外網發來的進入後臺服務器的報文,其源ip都變成了防火牆的ip。

簡單而言,這也是一個典型的NAT環境。

再來說,我們遇到啥問題。我們這次變更,在192.168.1.3這個服務器上,加了個openresty(nginx增強版本),由openresty承接請求,然後反向代理到後臺服務。

結果,測試同事反饋,app發出去的一些包,在三次握手的第一次握手就失敗了。

當時,是在後臺服務的機器上抓包,發現:app側,好幾個請求發了syn,但是後端沒有迴應,然後一直重傳syn,重傳n次後放棄。

image-20230816153806813

我一想,這難道是本次引入的openresty組件的問題?這要是上線了還得了,趕緊查查。

排查過程

先是自己在本地開發環境試了好久,app被我玩得死去活來,並沒有復現問題。

由於本週測試同事休假了,然後,我自己在測試環境又玩了好久,還是沒有復現問題,但是之前,測試同事復現了好些次,我當時也在場。怎麼,這次我自己就復現不出來呢?

復現不出來就在網上隨便逛逛,然後找到一些文章,說了一些可能的原因,至於是哪個原因,那得執行命令來確診。

// 檢查指標,看看有沒有因爲時間戳丟棄syn包的情況
netstat -s |egrep -e SYNs -e "time stamp"

image-20230816154705224

我一看,果然和網上說的對得上:tcp_tw_recycle參數在nat環境下,觸發了linux的paws機制,導致丟包。

這個paws機制在nat環境下丟包,開啓的前提是,服務器上打開了如下參數:

[root@VM-0-6-centos ~]# sysctl -a |egrep -e tcp_tw_recycle -e tcp_timestamps
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_tw_recycle = 1

此時,我大概感覺就是這麼個事情了,然後瞭解了這個問題場景後,果然在本地復現了,復現後就是照着改,然後問題就解決了。

解決不代表結束,我們得詳細瞭解下這個機制,這個機制啓動的參數中有一個net.ipv4.tcp_tw_recycle,這個參數,乍一看,是回收time_wait狀態的socket,而在網上搜索linux time_wait時,出來的第一頁的答案,很多都會跟你說,改下面的參數,改了就好了:

vi /etc/sysctl.conf

編輯文件,加入以下內容:
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_fin_timeout = 30

改了有什麼後果呢,不知道。這就在某些場景下埋下了隱患。要講清楚這個問題,還得先了解下這個time_wait。

time_wait

time_wait是什麼

請看狀態變遷圖:

tcp狀態變遷

大家知道四次揮手關閉連接吧,其中,首先發起揮手的一方(或者叫:首先發起關閉連接的一方),在經歷FIN_WAIT_1/CLOSING/FIN_WAIT_2等路徑後,最終會進入time_wait狀態。

其實,此時已經完成了4次揮手,爲什麼連接不直接進入關閉狀態呢,爲啥還要等到2MSL後,才能進入關閉狀態呢?

既然tcp協議組設計了這麼一個狀態,自然是爲了解決某些問題。在講它能解決的問題之前,我們先簡單實踐下,看看什麼情況下會出現該狀態。

在什麼地方出現該狀態

在我們傳統的cs模型裏,app/web網頁是客戶端,後端是服務端,那麼,一定是app端/web網頁發起主動關閉嗎,不見得。後端也可以主動發起揮手。

按照我們上面的說法,主動關閉方最終進入time_wait狀態。

看下面的例子,我本地telnet 10.80.121.114 9900(9900是一個nginx進程在監聽)後,在dos框裏隨便輸,這時就會導致後端主動關閉連接。

image-20230816162204316

此時,在後端就會出現time_wait:

[root@xxx-access ~]# netstat -ntp|grep 9900
tcp        0      0 10.80.121.114:9900      10.0.235.78:14966       TIME_WAIT   -   

所以,因爲後端發起揮手,所以後端進入time_wait。

另外,測試了下本機發起http短連接的場景,此時也是由後端主動發起揮手的,因此,也是後端進入time_wait。

GET / HTTP/1.1
Connection: close
User-Agent: PostmanRuntime/7.32.3
Accept: */*
Host: 10.80.121.114:9900
Accept-Encoding: gzip, deflate, br

image-20230816174229925

time_wait的危害

發起揮手的一端,進入time_wait後,會在該狀態下持續一段時間,協議規定這個時間等於2MSL,這個時間還是比較長的,以分鐘爲單位。

在這個時間段內,不能出現重複的四元組。大家看如下的例子。

我在服務器上去telnet 百度,正常來說,我可以打開n個和百度服務器之間的tcp連接。但每個連接都需要耗費一個本地的端口。如果我們在耗盡本地可用的端口後,會出現什麼事情呢?

我們先設置本機只運行使用兩個端口:

echo "61000 61001" > /proc/sys/net/ipv4/ip_local_port_range

首先,打開一個shell,執行:

## 110.242.68.4是www.baidu.com後的某個ip
[root@VM-0-6-centos ~]# telnet 110.242.68.4 443
Trying 110.242.68.4...
Connected to 110.242.68.4.
Escape character is '^]'.

查看狀態:

[root@VM-0-6-centos ~]# netstat -ntp|grep 443
tcp        0      0 10.0.0.6:61000          110.242.68.4:443        ESTABLISHED 987835/telnet       

再打開一個shell,進行同樣動作後,查看:

[root@VM-0-6-centos ~]# netstat -ntp|grep 443
tcp        0      0 10.0.0.6:61000          110.242.68.4:443        ESTABLISHED 987835/telnet       
tcp        0      0 10.0.0.6:61001          110.242.68.4:443        ESTABLISHED 987986/telnet 

此時,本機的61000/61001端口都已被使用,此時,本機已經沒有端口可用了,再執行telnet:

[root@VM-0-6-centos ~]# telnet 110.242.68.4 443
Trying 110.242.68.4...
telnet: connect to address 110.242.68.4: Cannot assign requested address

這裏,我們模擬的不是time_wait狀態的socket,而是established狀態,但結果是一樣的,因爲,socket四元組不能完全一致,在服務端ip+端口+本地ip已經確定的情況下,唯一可以發生變化的就是本地端口,但是本地端口已經全被佔用,因此,新的連接就無法建立。

但是,只要我們四元組其他部分可以改變,就還是可以建立連接,比如我們對www.baidu.com後的另一個ip來連接,就可以連上了:

[root@VM-0-6-centos ~]# telnet 110.242.68.3 443
Trying 110.242.68.3...
Connected to 110.242.68.3.
Escape character is '^]'.

此時狀態:

[root@VM-0-6-centos ~]# netstat -ntp|grep 443
tcp        0      0 10.0.0.6:61000          110.242.68.3:443        ESTABLISHED 990953/telnet       
tcp        0      0 10.0.0.6:61000          110.242.68.4:443        ESTABLISHED 990886/telnet       
tcp        0      0 10.0.0.6:61001          110.242.68.4:443        ESTABLISHED 990927/telnet  

這裏,簡單總結下,time_wait出現在主動關閉端,如果該端短時間內和對端建立了大量連接,然後又主動關閉,就會導致該端的大量端口被佔用(由於端口號最大爲65535,除去1-1024這些著名端口,可用的就是64000多個,也就是說短時間內,該端和對端最多建立6w多個連接再關閉,就會把這些端口全耗盡);此時,該端再想和對端建立連接,就會失敗。

除了這部分的危害,其餘的會額外佔用內存、cpu之類的,基本不是什麼太大的事情(除非在某些嵌入式設備上,我工作反正不涉及這塊)。

出現大量time_wait的場景

再背一遍:只有主動關閉方纔會進入time_wait。

1、外網訪問我方服務

典型場景,服務提供給外網訪問,且,我方服務端主動關閉連接。此時的四元組:

本端:localip + 服務端口,對端:用戶外網ip + 隨機端口。

但此時,由於對端ip和端口都是用戶真實ip+端口,雖然出現大量time_wait,但因爲四元組不重複,此時,不會導致用戶連接不能建立的問題。

2、防火牆/lvs等訪問業務接入層

此時,防火牆或者lvs的ip作爲客戶端,訪問後臺業務接入層nginx等。此時:

本端:防火牆 ip + 本地端口,對端:nginx + 固定端口,此時,對端ip端口固定,本端ip固定,可變的唯有本地端口,如果此時是本端主動關閉,本端就會出現大量time_wait,影響到和接入層的新連接建立。

3、我方服務接入層,訪問後端真實服務

典型場景,nginx機器(本端)將請求反向代理給後端,且本端主動關閉連接。此時的四元組:

本端:nginx ip + 本地端口,對端:後端機器ip + 固定端口,此時,對端的ip和端口是固定的,本端ip固定,如果和後端發生大量短連接,就可能導致本地端口耗盡,無法建立新的連接。

4、我方後端服務,訪問依賴的服務、中間件、db、第三方服務等

該場景下,如果也是我方主動關閉連接,陷入time_wait的話。此時的四元組:

本端:後端服務ip + 本地端口,對端:中間件、db等ip + 固定端口,此時,面臨的是和上面2/3類似的問題。

怎麼解決大量time_wait的問題

增加四元組的方式

先看看,到底需不需要解決,如上面的第一種場景,如果只考慮新連接不能建立的問題,那麼,是不需要解決time_wait過多的問題;

2/3/4,理論上需要解決,如果真的有這麼大的量,導致新連接無法建立的話。但是,解決的辦法,很多,不考慮系統內核參數的話,只需要保證四元組不重複即可。

就像我們上面那個telnet百度的實驗一樣,百度有多個ip,在ip1:443上耗盡了本地端口,那可以換到百度的ip2上。

大家可以參考下面這個文章,寫得很好:https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux

The solution is more quadruplets.5 This can be done in several ways (in the order of difficulty to setup):

  • use more client ports by setting net.ipv4.ip_local_port_range to a wider range;
  • use more server ports by asking the web server to listen to several additional ports (81, 82, 83, …);
  • use more client IP by configuring additional IP on the load balancer and use them in a round-robin fashion;6 or
  • use more server IP by configuring additional IP on the web server.

翻譯下就是:

解決辦法就是擁有更多的四元組即可。

  • 更多的本地端口可供使用,通過net.ipv4.ip_local_port_range,但最大也就65535
  • 更多的服務端端口,如服務端可以監聽81/82/83,多個端口都可以處理請求
  • 更多的客戶端ip
  • 更多的服務端ip

複用time_wait(僅適用連接發起方)

上面我們說,time_wait會導致新連接無法建立。但是,如果打開了參數:net.ipv4.tcp_tw_reuse = 1,新連接建立的時候,就可以在time_wait狀態下已持續超過1s的那些socket中選一個來用。

我們怎麼知道哪些socket在time_wait狀態已經持續超過1s了呢,那就依賴另一個參數:

net.ipv4.tcp_timestamps = 1 (默認就是1)

這個參數是默認打開的,它會給socket關聯上一個時間戳。

這塊具體的,還是參考文章吧:

https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux

總之,這是一個推薦的方案,適用於time_wait過多,新連接建立不起來的問題。但是,注意,這個只適用於發起連接的一方,不適用於接收連接的一方。

回收time_wait(適用於連接發起方、連接接收方)

終於來到了惡名昭彰的參數:net.ipv4.tcp_tw_recycle. 它也是依託於net.ipv4.tcp_timestamps參數才能生效。

這個參數,首先會加快time_wait狀態socket的回收,如果你在服務器上執行netstat,經常看不到time_wait狀態的socket,那就很可能是打開了這個參數。

其次,它還有個副作用,它會啓用一個叫做PAWS(PAWS : Protection Against Wrapping Sequence)的機制。

這個機制就是解決sequence迴繞的問題,比如,本端和對端建立了一個連接,此時開始發送請求,假設seq爲1,數據包長度100. 但這個包在路由給對端的時候,可能進入了某個異常的路由器,被阻塞了,遲遲未能到達對端。

這時,本端就會重傳這個seq=1的包,本次,走了一條比較快的路由,到達對端了。

接下來,我們又和對端進行了很多交互,seq來到了最大值附近,最大爲2*32次方 - 1。此時,我們關閉本次連接。

接下來,我們又建立了一個新的連接(正巧,四元組和剛關閉的這個一致),由於seq已經最大,發生了迴繞,變成了從頭開始,此時,我們又發了一個seq爲1,數據包長度200的包給對端;而此時,之前上一輪那個走了歧路的包,意外到達對端了,此時,對端就會認爲第一輪那個包是ok的,反而把我們本輪的包給丟了。

我找了個網圖(侵刪):

Image

爲了解決這個問題,就引入了時間戳機制,每個包中都帶了自己本地生成的一個時間戳,而且,這個時間戳就是本地生成的,但是是遞增的,比如,下面的第一個包,時間戳爲96913730,第二包爲:96913734

image-20230816222836776

image-20230816222924005

引入這個時間戳機制後,就可以解決上面的seq迴繞問題了,因爲每次收到這種帶時間戳的包時,服務端都會維護下我方的最新時間戳,當收到在第一輪中誤入歧途的包時,由於其時間戳比較小(比較老),比當前服務端維護的時間戳小,就會認定這個包有問題,直接丟棄。

但是,服務端(對端)是針對我方ip維護了一個時間戳,回到開頭的例子,我方ip在通過防火牆以後,訪問到後端服務時,後端服務看到的ip是防火牆的ip;那麼,後端服務器就只會維護一個防火牆的最新時間戳,這是有問題的。

比如我們兩個人各自用app訪問服務,此時,各自本地生成的時間戳是不一致的,假設A生成的時間戳較大,此時,服務端維護的時間戳就是A生成的,接到B生成的時間戳較小的包時,就會直接丟棄。

比如,下面的第807包,時間戳爲12億左右:

image-20230816224223361

而到了808包,時間戳到了2億,這就會導致錯亂:

image-20230816224320263

在這期間,服務端的netstat統計可以看到,很多被拒絕的syn:

image-20230816224521807

補充下:

在處理三次握手的第一次握手時,協議棧相關代碼中根據時間戳丟棄syn的邏輯:

image-20230816224725383

最終的解決辦法

我是直接關閉了net.ipv4.tcp_tw_recycle參數,關閉後,再測試多手機同時使用app,已經沒有拒絕syn的指標繼續增長的情況了。

image-20230816224922889

net.ipv4.tcp_tw_recycle在新版本被刪除

由於其在nat環境存在的巨大問題,基本就只剩下很少場景可以用了。後來linux 4.1內核又上了一個特性,導致這個參數徹底失效,然後在4.2版本被刪除。

image-20230816225208165

https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=4396e46187ca5070219b81773c4e65088dac50cc

參考資料

https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux

https://zhuanlan.zhihu.com/p/356087235

https://elixir.bootlin.com/linux/v3.10/source/net/ipv4/tcp_ipv4.c#L203

https://stackoverflow.com/questions/6426253/tcp-tw-reuse-vs-tcp-tw-recycle-which-to-use-or-both

https://blog.csdn.net/qq_25046827/article/details/131839126

https://www.ietf.org/rfc/rfc1323.txt

https://www.cnxct.com/coping-with-the-tcp-time_wait-state-on-busy-linux-servers-in-chinese-and-dont-enable-tcp_tw_recycle/#ftoc-heading-11

https://github.com/y123456yz/Reading-and-comprehense-linux-Kernel-network-protocol-stack

https://mp.weixin.qq.com/s/2xkYbczdHKgpUnicBw0pkA

https://www.suse.com/support/kb/doc/?id=000019286

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章