TCP 半連線佇列和全連線佇列滿了會發生什麼?又該如何應對?
前言
網上許多部落格針對增大 TCP 半連線佇列和全連線佇列的方式如下:
- 增大 TCP 半連線佇列的方式是增大 /proc/sys/net/ipv4/tcp_max_syn_backlog;
- 增大 TCP 全連線佇列的方式是增大 listen() 函式中的 backlog;
這裡先跟大家說下,上面的方式都是不準確的。
“你怎麼知道不準確?”
很簡單呀,因為我做了實驗和看了 TCP 協議棧的核心原始碼,發現要增大這兩個佇列長度,不是簡簡單單增大某一個引數就可以的。
接下來,就會以實戰 + 原始碼分析,帶大家解密 TCP 半連線佇列和全連線佇列。
“原始碼分析,那不是勸退嗎?我們搞 Java 的看不懂呀”
放心,本文的原始碼分析不會涉及很深的知識,因為都被我刪減了,你只需要會條件判斷語句 if、左移右移操作符、加減法等基本語法,就可以看懂。
另外,不僅有原始碼分析,還會介紹 Linux 排查半連線佇列和全連線佇列的命令。
“哦?似乎很有看頭,那我姑且看一下吧!”
行,沒有被勸退的小夥伴,值得鼓勵,下面這圖是本文的提綱:
本文提綱正文
什麼是 TCP 半連線佇列和全連線佇列?
在 TCP 三次握手的時候,Linux 核心會維護兩個佇列,分別是:
- 半連線佇列,也稱 SYN 佇列;
- 全連線佇列,也稱 accepet 佇列;
服務端收到客戶端發起的 SYN 請求後,核心會把該連線儲存到半連線佇列,並向客戶端響應 SYN+ACK,接著客戶端會返回 ACK,服務端收到第三次握手的 ACK 後,核心會把連線從半連線佇列移除,然後建立新的完全的連線,並將其新增到 accept 佇列,等待程序呼叫 accept 函式時把連線取出來。
不管是半連線佇列還是全連線佇列,都有最大長度限制,超過限制時,核心會直接丟棄,或返回 RST 包。
實戰 - TCP 全連線佇列溢位
如何知道應用程式的 TCP 全連線佇列大小?
在服務端可以使用 ss
命令,來檢視 TCP 全連線佇列的情況:
但需要注意的是 ss
命令獲取的 Recv-Q/Send-Q
在「LISTEN 狀態」和「非 LISTEN 狀態」所表達的含義是不同的。從下面的核心程式碼可以看出區別:
在「LISTEN 狀態」時,Recv-Q/Send-Q
表示的含義如下:
- Recv-Q:當前全連線佇列的大小,也就是當前已完成三次握手並等待服務端
accept()
- Send-Q:當前全連線最大佇列長度,上面的輸出結果說明監聽 8088 埠的 TCP 服務,最大全連線長度為 128;
在「非 LISTEN 狀態」時,Recv-Q/Send-Q
表示的含義如下:
- Recv-Q:已收到但未被應用程序讀取的位元組數;
- Send-Q:已傳送但未收到確認的位元組數;
測試環境如何模擬 TCP 全連線佇列溢位的場景?
實驗環境:
- 客戶端和服務端都是 CentOs 6.5 ,Linux 核心版本 2.6.32
- 服務端 IP 192.168.3.200,客戶端 IP 192.168.3.100
- 服務端是 Nginx 服務,埠為 8088
這裡先介紹下 wrk
工具,它是一款簡單的 HTTP 壓測工具,它能夠在單機多核 CPU 的條件下,使用系統自帶的高效能 I/O 機制,通過多執行緒和事件模式,對目標機器產生大量的負載。
本次模擬實驗就使用 wrk
工具來壓力測試服務端,發起大量的請求,一起看看服務端 TCP 全連線佇列滿了會發生什麼?有什麼觀察指標?
客戶端執行 wrk
命令對服務端發起壓力測試,併發 3 萬個連線:
在服務端可以使用 ss
命令,來檢視當前 TCP 全連線佇列的情況:
其間共執行了兩次 ss 命令,從上面的輸出結果,可以發現當前 TCP 全連線佇列上升到了 129 大小,超過了最大 TCP 全連線佇列。
當超過了 TCP 最大全連線佇列,服務端則會丟掉後續進來的 TCP 連線,丟掉的 TCP 連線的個數會被統計起來,我們可以使用 netstat -s 命令來檢視:
上面看到的 41150 times ,表示全連線佇列溢位的次數,注意這個是累計值。可以隔幾秒鐘執行下,如果這個數字一直在增加的話肯定全連線佇列偶爾滿了。
從上面的模擬結果,可以得知,當服務端併發處理大量請求時,如果 TCP 全連線佇列過小,就容易溢位。發生 TCP 全連線隊溢位的時候,後續的請求就會被丟棄,這樣就會出現服務端請求數量上不去的現象。
全連線佇列溢位Linux 有個引數可以指定當 TCP 全連線佇列滿了會使用什麼策略來回應客戶端。
實際上,丟棄連線只是 Linux 的預設行為,我們還可以選擇向客戶端傳送 RST 復位報文,告訴客戶端連線已經建立失敗。
tcp_abort_on_overflow 共有兩個值分別是 0 和 1,其分別表示:
- 0 :如果全連線佇列滿了,那麼 server 扔掉 client 發過來的 ack ;
- 1 :如果全連線佇列滿了,server 傳送一個
reset
包給 client,表示廢掉這個握手過程和這個連線;
如果要想知道客戶端連線不上服務端,是不是服務端 TCP 全連線佇列滿的原因,那麼可以把 tcp_abort_on_overflow 設定為 1,這時如果在客戶端異常中可以看到很多 connection reset by peer
的錯誤,那麼就可以證明是由於服務端 TCP 全連線佇列溢位的問題。
通常情況下,應當把 tcp_abort_on_overflow 設定為 0,因為這樣更有利於應對突發流量。
舉個例子,當 TCP 全連線佇列滿導致伺服器丟掉了 ACK,與此同時,客戶端的連線狀態卻是 ESTABLISHED,程序就在建立好的連線上傳送請求。只要伺服器沒有為請求回覆 ACK,請求就會被多次重發。如果伺服器上的程序只是短暫的繁忙造成 accept 佇列滿,那麼當 TCP 全連線佇列有空位時,再次接收到的請求報文由於含有 ACK,仍然會觸發伺服器端成功建立連線。
所以,tcp_abort_on_overflow 設為 0 可以提高連線建立的成功率,只有你非常肯定 TCP 全連線佇列會長期溢位時,才能設定為 1 以儘快通知客戶端。
如何增大 TCP 全連線佇列呢?
是的,當發現 TCP 全連線佇列發生溢位的時候,我們就需要增大該佇列的大小,以便可以應對客戶端大量的請求。
TCP 全連線佇列足最大值取決於 somaxconn 和 backlog 之間的最小值,也就是 min(somaxconn, backlog)。從下面的 Linux 核心程式碼可以得知:
somaxconn
是 Linux 核心的引數,預設值是 128,可以通過/proc/sys/net/core/somaxconn
來設定其值;backlog
是listen(int sockfd, int backlog)
函式中的 backlog 大小,Nginx 預設值是 511,可以通過修改配置檔案設定其長度;
前面模擬測試中,我的測試環境:
- somaxconn 是預設值 128;
- Nginx 的 backlog 是預設值 511
所以測試環境的 TCP 全連線佇列最大值為 min(128, 511),也就是 128
,可以執行 ss
命令檢視:
現在我們重新壓測,把 TCP 全連線佇列搞大,把 somaxconn
設定成 5000:
接著把 Nginx 的 backlog 也同樣設定成 5000:
最後要重啟 Nginx 服務,因為只有重新呼叫 listen()
函式 TCP 全連線佇列才會重新初始化。
重啟完後 Nginx 服務後,服務端執行 ss 命令,檢視 TCP 全連線佇列大小:
從執行結果,可以發現 TCP 全連線最大值為 5000。
增大 TCP 全連線佇列後,繼續壓測
客戶端同樣以 3 萬個連線併發傳送請求給服務端:
服務端執行 ss
命令,檢視 TCP 全連線佇列使用情況:
從上面的執行結果,可以發現全連線佇列使用增長的很快,但是一直都沒有超過最大值,所以就不會溢位,那麼 netstat -s
就不會有 TCP 全連線佇列溢位個數的顯示:
說明 TCP 全連線佇列最大值從 128 增大到 5000 後,服務端抗住了 3 萬連線併發請求,也沒有發生全連線佇列溢位的現象了。
如果持續不斷地有連線因為 TCP 全連線佇列溢位被丟棄,就應該調大 backlog 以及 somaxconn 引數。
實戰 - TCP 半連線佇列溢位
如何檢視 TCP 半連線佇列長度?
很遺憾,TCP 半連線佇列長度的長度,沒有像全連線佇列那樣可以用 ss 命令檢視。
但是我們可以抓住 TCP 半連線的特點,就是服務端處於 SYN_RECV
狀態的 TCP 連線,就是在 TCP 半連線佇列。
於是,我們可以使用如下命令計算當前 TCP 半連線佇列長度:
如何模擬 TCP 半連線佇列溢位場景?
模擬 TCP 半連線溢位場景不難,實際上就是對服務端一直髮送 TCP SYN 包,但是不回第三次握手 ACK,這樣就會使得服務端有大量的處於 SYN_RECV
狀態的 TCP 連線。
這其實也就是所謂的 SYN 洪泛、SYN 攻擊、DDos 攻擊。
測試環境實驗環境:
- 客戶端和服務端都是 CentOs 6.5 ,Linux 核心版本 2.6.32
- 服務端 IP 192.168.3.200,客戶端 IP 192.168.3.100
- 服務端是 Nginx 服務,埠為 8088
注意:本次模擬實驗是沒有開啟 tcp_syncookies,關於 tcp_syncookies 的作用,後續會說明。
本次實驗使用 hping3
工具模擬 SYN 攻擊:
當服務端受到 SYN 攻擊後,連線服務端 ssh 就會斷開了,無法再連上。只能在服務端主機上執行檢視當前 TCP 半連線佇列大小:
同時,還可以通過 netstat -s 觀察半連線佇列溢位的情況:
上面輸出的數值是累計值,表示共有多少個 TCP 連線因為半連線佇列溢位而被丟棄。隔幾秒執行幾次,如果有上升的趨勢,說明當前存在半連線佇列溢位的現象。
大部分人都說 tcp_max_syn_backlog 是指定半連線佇列的大小,是真的嗎?
很遺憾,半連線佇列的大小並不單單隻跟 tcp_max_syn_backlog
有關係。
上面模擬 SYN 攻擊場景時,服務端的 tcp_max_syn_backlog 的預設值如下:
但是在測試的時候發現,服務端最多隻有 256 個半連線佇列,而不是 512,所以半連線佇列的最大長度不一定由 tcp_max_syn_backlog 值決定的。
接下來,走進 Linux 核心的原始碼,來分析 TCP 半連線佇列的最大值是如何決定的。
TCP 第一次握手(收到 SYN 包)的 Linux 核心程式碼如下,其中縮減了大量的程式碼,只需要重點關注 TCP 半連線佇列溢位的處理邏輯:
從原始碼中,我可以得出共有三個條件因佇列長度的關係而被丟棄的:
- 如果半連線佇列滿了,並且沒有開啟 tcp_syncookies,則會丟棄;
- 若全連線佇列滿了,且沒有重傳 SYN+ACK 包的連線請求多於 1 個,則會丟棄;
- 如果沒有開啟 tcp_syncookies,並且 max_syn_backlog 減去 當前半連線佇列長度小於 (max_syn_backlog >> 2),則會丟棄;
關於 tcp_syncookies 的設定,後面在詳細說明,可以先給大家說一下,開啟 tcp_syncookies 是緩解 SYN 攻擊其中一個手段。
接下來,我們繼續跟一下檢測半連線佇列是否滿的函式 inet_csk_reqsk_queue_is_full 和 檢測全連線佇列是否滿的函式 sk_acceptq_is_full :
從上面原始碼,可以得知:
- 全連線佇列的最大值是
sk_max_ack_backlog
變數,sk_max_ack_backlog 實際上是在 listen() 原始碼裡指定的,也就是 min(somaxconn, backlog); - 半連線佇列的最大值是
max_qlen_log
變數,max_qlen_log 是在哪指定的呢?現在暫時還不知道,我們繼續跟進;
我們繼續跟進程式碼,看一下是哪裡初始化了半連線佇列的最大值 max_qlen_log:
從上面的程式碼中,我們可以算出 max_qlen_log 是 8,於是代入到 檢測半連線佇列是否滿的函式 reqsk_queue_is_full :
也就是 qlen >> 8
什麼時候為 1 就代表半連線佇列滿了。這計算這不難,很明顯是當 qlen 為 256 時,256 >> 8 = 1
。
至此,總算知道為什麼上面模擬測試 SYN 攻擊的時候,服務端處於 SYN_RECV
連線最大隻有 256 個。
可見,半連線佇列最大值不是單單由 max_syn_backlog 決定,還跟 somaxconn 和 backlog 有關係。
在 Linux 2.6.32 核心版本,它們之間的關係,總體可以概況為:
- 當 max_syn_backlog > min(somaxconn, backlog) 時, 半連線佇列最大值 max_qlen_log = min(somaxconn, backlog) * 2;
- 當 max_syn_backlog < min(somaxconn, backlog) 時, 半連線佇列最大值 max_qlen_log = max_syn_backlog * 2;
半連線佇列最大值 max_qlen_log 就表示服務端處於 SYN_REVC 狀態的最大個數嗎?
依然很遺憾,並不是。
max_qlen_log 是理論半連線佇列最大值,並不一定代表服務端處於 SYN_REVC 狀態的最大個數。
在前面我們在分析 TCP 第一次握手(收到 SYN 包)時會被丟棄的三種條件:
- 如果半連線佇列滿了,並且沒有開啟 tcp_syncookies,則會丟棄;
- 若全連線佇列滿了,且沒有重傳 SYN+ACK 包的連線請求多於 1 個,則會丟棄;
- 如果沒有開啟 tcp_syncookies,並且 max_syn_backlog 減去 當前半連線佇列長度小於 (max_syn_backlog >> 2),則會丟棄;
假設條件 1 當前半連線佇列的長度 「沒有超過」理論的半連線佇列最大值 max_qlen_log,那麼如果條件 3 成立,則依然會丟棄 SYN 包,也就會使得服務端處於 SYN_REVC 狀態的最大個數不會是理論值 max_qlen_log。
似乎很難理解,我們繼續接著做實驗,實驗見真知。
服務端環境如下:
配置完後,服務端要重啟 Nginx,因為全連線佇列最大和半連線佇列最大值是在 listen() 函式初始化。
根據前面的原始碼分析,我們可以計算出半連線佇列 max_qlen_log 的最大值為 256:
客戶端執行 hping3 發起 SYN 攻擊:
服務端執行如下命令,檢視處於 SYN_RECV 狀態的最大個數:
可以發現,服務端處於 SYN_RECV 狀態的最大個數並不是 max_qlen_log 變數的值。
這就是前面所說的原因:如果當前半連線佇列的長度 「沒有超過」理論半連線佇列最大值 max_qlen_log,那麼如果條件 3 成立,則依然會丟棄 SYN 包,也就會使得服務端處於 SYN_REVC 狀態的最大個數不會是理論值 max_qlen_log。
我們來分析一波條件 3 :
從上面的分析,可以得知如果觸發「當前半連線佇列長度 > 192」條件,TCP 第一次握手的 SYN 包是會被丟棄的。
在前面我們測試的結果,服務端處於 SYN_RECV 狀態的最大個數是 193,正好是觸發了條件 3,所以處於 SYN_RECV 狀態的個數還沒到「理論半連線佇列最大值 256」,就已經把 SYN 包丟棄了。
所以,服務端處於 SYN_RECV 狀態的最大個數分為如下兩種情況:
- 如果「當前半連線佇列」沒超過「理論半連線佇列最大值」,但是超過 max_syn_backlog - (max_syn_backlog >> 2),那麼處於 SYN_RECV 狀態的最大個數就是 max_syn_backlog - (max_syn_backlog >> 2);
- 如果「當前半連線佇列」超過「理論半連線佇列最大值」,那麼處於 SYN_RECV 狀態的最大個數就是「理論半連線佇列最大值」;
每個 Linux 核心版本「理論」半連線最大值計算方式會不同。
在上面我們是針對 Linux 2.6.32 版本分析的「理論」半連線最大值的演算法,可能每個版本有些不同。
比如在 Linux 5.0.0 的時候,「理論」半連線最大值就是全連線佇列最大值,但依然還是有佇列溢位的三個條件:
如果 SYN 半連線佇列已滿,只能丟棄連線嗎?
並不是這樣,開啟 syncookies 功能就可以在不使用 SYN 半連線佇列的情況下成功建立連線,在前面我們原始碼分析也可以看到這點,當開啟了 syncookies 功能就不會丟棄連線。
syncookies 是這麼做的:伺服器根據當前狀態計算出一個值,放在己方發出的 SYN+ACK 報文中發出,當客戶端返回 ACK 報文時,取出該值驗證,如果合法,就認為連線建立成功,如下圖所示。
開啟 syncookies 功能syncookies 引數主要有以下三個值:
- 0 值,表示關閉該功能;
- 1 值,表示僅當 SYN 半連線佇列放不下時,再啟用它;
- 2 值,表示無條件開啟功能;
那麼在應對 SYN 攻擊時,只需要設定為 1 即可:
如何防禦 SYN 攻擊?
這裡給出幾種防禦 SYN 攻擊的方法:
- 增大半連線佇列;
- 開啟 tcp_syncookies 功能
- 減少 SYN+ACK 重傳次數
方式一:增大半連線佇列
在前面原始碼和實驗中,得知要想增大半連線佇列,我們得知不能只單純增大 tcp_max_syn_backlog 的值,還需一同增大 somaxconn 和 backlog,也就是增大全連線佇列。否則,只單純增大 tcp_max_syn_backlog 是無效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 核心引數:
增大 backlog 的方式,每個 Web 服務都不同,比如 Nginx 增大 backlog 的方法如下:
最後,改變了如上這些引數後,要重啟 Nginx 服務,因為半連線佇列和全連線佇列都是在 listen() 初始化的。
方式二:開啟 tcp_syncookies 功能
開啟 tcp_syncookies 功能的方式也很簡單,修改 Linux 核心引數:
方式三:減少 SYN+ACK 重傳次數
當服務端受到 SYN 攻擊時,就會有大量處於 SYN_REVC 狀態的 TCP 連線,處於這個狀態的 TCP 會重傳 SYN+ACK ,當重傳超過次數達到上限後,就會斷開連線。
那麼針對 SYN 攻擊的場景,我們可以減少 SYN+ACK 的重傳次數,以加快處於 SYN_REVC 狀態的 TCP 連線斷開。
巨人的肩膀
[1] 系統性能調優必知必會.陶輝.極客時間.
[2] https://www.cnblogs.com/zengkefu/p/5606696.html
[3] https://blog.cloudflare.com/syn-packet-handling-in-the-wild/
小林是專為大家圖解的工具人,Goodbye,我們下次見!