相關影片推薦
從websocket協議到tcp自定義協議,tcp分包與粘包,明文傳輸
TCP/IP協議棧深度解析丨實現單機百萬連線丨最佳化三次握手、四次揮手
C/C++Linux伺服器開發/後臺架構師【零聲教育】-學習影片教程-騰訊課堂
近期遇到一個問題,簡單點說,主機A上顯示一條ESTABLISHED狀態的TCP連線到主機B,而主機B上卻沒有任何關於主機A的連線資訊,經查明,這是由於主機A和主機B的傳送/接收緩衝區差異巨大,導致主機B程序退出後,主機A暫時憋住,主機B頻繁傳送零視窗探測,FIN_WAIT1狀態超時,進而連線被銷燬,然而主機A並不知情導致。
正好昨天也有人諮詢另外一個類似的問題,那麼就抽昨晚和今天早上的時間,寫一篇總結吧。
TCP處處是坑!
不要覺得你對TCP的實現的程式碼爛熟於心了就能把控它的所有行為!不知道你有沒有發現,目前市面上新上市的關於Linux核心協議棧的書可謂是汗牛充棟,然而無論作者是國內的還是國外,幾乎都是碰到TCP就草草略過,反而對IP,ARP,DNS這些大書特書,Why?因為Linux核心裡TCP的程式碼太亂太複雜了,很少有人能看明白80%以上的,即便真的有看過的,其中還包括只懂程式碼而不懂網路技術的,我就發現很多聲稱自己精通Linux核心TCP/IP原始碼,結果竟然不知道什麼是預設路由…
所以我打算寫一篇文章,趁著這個FIN_WAIT1問題,順便表達一下我是如何學習網路技術,我是如何解決網路問題的方法論觀點,都是形而上,個人看法:
- 設計覆蓋全面的復現實驗
- 通讀協議標準文件,理解實現建議
- 再次實驗,預測並確認問題以外的現象
- 核對程式碼實現,跟蹤程式碼的Changelog
- 寫一個自己的實現或者亂改程式碼
…
本文聊聊TCP的FIN_WAIT1以及TCP假連線(死連線)問題。先看FIN_WAIT1。
首先還是從狀態機入手,看看和FIN_WAIT1相關的狀態機轉換圖:
我們只考慮常規的從ESTABLISHED狀態的轉換,很簡單的一個單一狀態轉換:
ESTAB狀態傳送FIN即切換到FIN_WAIT1狀態;
FIN_WAIT1狀態下收到針對FIN的ACK即可離開FIN_WAIT1到達FIN_WAIT2.
看一下和上述狀態機轉換相關的簡單時序圖:
從狀態圖和時序圖上,我們很明確地可以看到,FIN_WAIT1持續1個RTT左右的時間!這個時間段幾乎不會被肉眼觀察到,轉瞬而即逝
然而,這是真的嗎?
我們之所以得到FIN_WAIT1持續1個RTT這個結論,基於兩個假設,即:
TCP的對端是一個正常的TCP端;
兩端TCP之間的鏈路是正常的,可達的。
OK,接下來我們來設計一個實驗模擬異常的情況。準備實驗拓撲如下:
host1和host2的系統核心版本(uname -r獲取):
3.10.0-862.2.3.el7.x86_64
首先,我們看一下如果對端TCP針對FIN傳送的ACK丟失,會發生什麼。按照上述的時序圖,正常應該是FIN_WAIT1將會永久持續。我們來驗證一下。
【文章福利】:小編整理了一些個人覺得比較好的學習書籍、影片資料共享在群檔案裡面,有需要的可以自行新增哦!~點選加入(832218493需要自取)
實驗1:模擬ACK丟失
在host1上做以下命令:
nc -l -p 1234
host2上完成以下命令:
cat /dev/zero|nc 1.1.1.1 1234
以上保證了host1和host2之間的TCP建立並且連線之間有持續的資料傳輸。接下來,在host2上執行下列動作:
iptables -A INPUT -p tcp --tcp-flags ACK,FIN ACKkillall nc
此時在host2上:
[root@localhost ~]# netstat -antp|grep 1234tcp 0 1229 1.1.1.2:39318 1.1.1.1:1234 FIN_WAIT1 -
連續上翻命令,這個FIN_WAIT1均不會消失,暫時符合我們的預期…出去抽根菸,刷會兒微博…回來後,發現這個FIN_WAIT1消失了!
它是如何消失的呢?這個時候,我們提取netstat資料,執行“ netstat -st”,會發現:
TcpExt:... 1 connections aborted due to timeout
多了一條timeout連線!
我這裡直接說答案吧。
雖然說在協議上規範上看,TCP沒有必要為鏈路或者說對端的不合常規的行為而買單,但是從現實角度,TCP的實現必須處理異常情況,TCP的實現必然要有所限制!。
我們知道,計算機是無法處理無限,無窮這種抽線的數學概念的,所有如果針對FIN的ACK遲遲不來,那麼必然要有一個等待的極限,這個極限在Linux核心協議棧中由以下引數控制:
net.ipv4.tcp_orphan_retries # 預設值是0!這裡有坑...
這個引數表示如果一直都收不到針對FIN的ACK,那麼在徹底銷燬這個FIN_WAIT1的連線前,等待幾輪RTO退避。
所謂的orphan tcp connection,意思就是說,在Linux程序層面,建立該連線的程序已經退出銷燬了,然而在TCP協議層面,它依然在遵循TCP狀態機的轉換規則存在著。
注意,這個引數不是一個時間量,而是一個次數量。我們知道,TCP每一次超時,都會對下一次超時時間進行指數退避,這裡的次數量就是要經過幾次退避的時間。舉一個例子,如果RTO是2ms,而tcp_orphan_retries 的值是4,那麼所計算出的FIN_WAIT1容忍時間就是:
T=21+22+23+24T=21+22+23+24
還是看看Linux核心文件怎麼說的吧
tcp_orphan_retries - INTEGER
This value influences the timeout of a locally closed TCP connection, when RTO retransmissions remain unacknowledged.
See tcp_retries2 for more details.
The default value is 8.
If your machine is a loaded WEB server,
you should think about lowering this value, such sockets
may consume significant resources. Cf. tcp_max_orphans.
讓我們看看tcp_retries2,以獲取數值的含義:
tcp_retries2 - INTEGER
This value influences the timeout of an alive TCP connection,
when RTO retransmissions remain unacknowledged.
Given a value of N, a hypothetical TCP connection following
exponential backoff with an initial RTO of TCP_RTO_MIN would
retransmit N times before killing the connection at the (N+1)th RTO.
The default value of 15 yields a hypothetical timeout of 924.6
seconds and is a lower bound for the effective timeout.
TCP will effectively time out at the first RTO which exceeds the hypothetical timeout.
RFC 1122 recommends at least 100 seconds for the timeout,
which corresponds to a value of at least 8.
雖然說文件上預設值的建議是8,但是大多數的Linux發行版上其預設值都是0。更多詳情,就自己看RFC和Linux原始碼吧。
有了這個引數保底,我們知道,即便是ACK永遠不來,FIN_WAIT1狀態也不會一直持續下去的,這有效避免了有針對性截獲ACK或者不傳送ACK而導致的DDoS,退一萬步講,即便是沒有DDoS,這種做法也具有資源利用率的容錯性,使得資源使用更加高效。
實驗1的結論如下:
如果主動斷開端呼叫了close關掉了程序,它會進入FIN_WAIT1狀態,此時如果它再也收不到ACK,無論是針對pending在傳送緩衝的資料還是FIN,它都會嘗試重新發送,在收到ACK前會嘗試N次退避,該N由tcp_orphan_retries引數控制。
接下來,我們來看一個更加複雜一點的問題,還是先從實驗說起。
實驗2:模擬對端TCP不收資料,接收視窗憋死
在host1上做以下命令:
# 模擬小接收快取,使得憋住接收視窗更加容易sysctl -w net.ipv4.tcp_rmem="16 32 32"nc -l -p 1234
host2上完成以下命令:
cat /dev/zero|nc 1.1.1.1 1234sleep 5 # 稍微等一下killall nc
此時,我們發現host2的TCP連線進入了FIN_WAIT1狀態。然而抓包看的話,資料傳輸依然在進行:
05:15:51.674630 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 305:321, ack 1, win 5840, options [nop,nop,TS val 1210945 ecr 238593370], length 1605:15:51.674690 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 321, win 0, options [nop,nop,TS val 238593471 ecr 1210945], length 005:15:51.674759 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 321, win 16, options [nop,nop,TS val 238593471 ecr 1210945], length 005:15:51.777774 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 321:325, ack 1, win 5840, options [nop,nop,TS val 1211048 ecr 238593471], length 405:15:51.777874 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 325, win 16, options [nop,nop,TS val 238593497 ecr 1211048], length 005:15:52.182918 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 325:341, ack 1, win 5840, options [nop,nop,TS val 1211453 ecr 238593497], length 1605:15:52.182970 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 341, win 0, options [nop,nop,TS val 238593599 ecr 1211453], length 005:15:52.183055 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 341, win 16, options [nop,nop,TS val 238593599 ecr 1211453], length 005:15:52.592759 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 341:357, ack 1, win 5840, options [nop,nop,TS val 1211863 ecr 238593599], length 1605:15:52.592813 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 357, win 0, options [nop,nop,TS val 238593701 ecr 1211863], length 005:15:52.592871 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 357, win 16, options [nop,nop,TS val 238593701 ecr 1211863], length 005:15:52.695160 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 357:361, ack 1, win 5840, options [nop,nop,TS val 1211965 ecr 238593701], length 405:15:52.695276 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 361, win 16, options [nop,nop,TS val 238593727 ecr 1211965], length 005:15:53.099612 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 361:377, ack 1, win 5840, options [nop,nop,TS val 1212370 ecr 238593727], length 1605:15:53.099641 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 377, win 0, options [nop,nop,TS val 238593828 ecr 1212370], length 005:15:53.099671 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 377, win 16, options [nop,nop,TS val 238593828 ecr 1212370], length 005:15:53.505028 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 377:393, ack 1, win 5840, options [nop,nop,TS val 1212775 ecr 238593828], length 1605:15:53.505081 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 393, win 0, options [nop,nop,TS val 238593929 ecr 1212775], length 005:15:53.505138 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 393, win 16, options [nop,nop,TS val 238593929 ecr 1212775], length 005:15:53.605923 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [P.], seq 393:397, ack 1, win 5840, options [nop,nop,TS val 1212876 ecr 238593929], length 4
這是顯然的,這是因為收發兩端巨大的快取大小差異造成的,即便是host2傳送端程序退出了,在退出前已經有大量資料pending到了TCP的傳送緩衝區裡面而脫離已經被銷燬的程序了,FIN包當然是排在了緩衝區的末尾了。
TCP的狀態機執行在快取的上層,即只要把FIN包pending排隊,就切換到了FIN_WAIT1,而不是說實際傳送了FIN包才切換。
因此,我們可有的等了,資料傳輸依然在正常有序進行,針對小包的ACK源源不斷從host1回來,這進一步促進host2傳送未竟的資料包,直到所有緩衝區的資料全部發送完畢…
不管怎樣,總是有個頭兒,只要有結束,就不需要擔心。我們可以簡單得出一個結論:
如果主動斷開端呼叫了close關掉了程序,它會進入FIN_WAIT1狀態,如果接收端的接收視窗呈現開啟狀態,此時它的TCP傳送佇列中的資料包還是會像正常一樣發往接收端,直到傳送完,最後傳送FIN包,收到FIN包ACK後進入FIN_WAIT2。
現在,我們進行實驗的下一步,把host1上的接收程序nc的接收邏輯徹底憋死。很簡單,host1上執行下面的命令即可:
killall -STOP nc
程序並沒有退出,只是暫停了,nc程序上下文的recv不再執行,然而軟中斷上下文的TCP協議的處理依然在進行。
這個時候,抓包就會發現只剩下指數時間退避的零視窗探測包了:
# 注意觀察探測包傳送時間的間隔05:15:56.444570 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1215715 ecr 238594487], length 005:15:56.444602 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238594664 ecr 1214601], length 005:15:57.757217 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1217027 ecr 238594664], length 005:15:57.757248 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238594992 ecr 1214601], length 005:16:00.283259 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1219552 ecr 238594992], length 005:16:00.283483 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238595624 ecr 1214601], length 005:16:05.234277 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1224503 ecr 238595624], length 005:16:05.234305 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238596861 ecr 1214601], length 005:16:15.032486 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1234301 ecr 238596861], length 005:16:15.032532 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238599311 ecr 1214601], length 005:16:34.629137 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1253794 ecr 238599311], length 005:16:34.629164 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238604210 ecr 1214601], length 005:17:13.757815 IP 1.1.1.2.39318 > 1.1.1.1.1234: Flags [.], ack 1, win 5840, options [nop,nop,TS val 1292784 ecr 238604210], length 005:17:13.757863 IP 1.1.1.1.1234 > 1.1.1.2.39318: Flags [.], ack 465, win 0, options [nop,nop,TS val 238613992 ecr 1214601], length 0
這個實驗的現象和實驗1的現象,僅有一個區別,那就是實驗1是阻塞了ACK,而本實驗則是FIN根本就還沒有傳送出去就進入了FIN_WAIT1,且針對RTO指數時間退避傳送的零視窗探測的ACK持續到來,簡單總結就是:
實驗1沒有ACK到來,實驗2有ACK到來。
在實驗結果之前,我們來看一段摘錄,來自RFC 1122:https://tools.ietf.org/html/rfc1122#page-92
4.2.2.17 Probing Zero Windows: RFC-793 Section 3.7, page 42
Probing of zero (offered) windows MUST be supported.
A TCP MAY keep its offered receive window closed
indefinitely. As long as the receiving TCP continues to
send acknowledgments in response to the probe segments, the
sending TCP MUST allow the connection to stay open.
緊接著後面是一段註解:
DISCUSSION:
It is extremely important to remember that ACK
(acknowledgment) segments that contain no data are not
reliably transmitted by TCP. If zero window probing is
not supported, a connection may hang forever when an
ACK segment that re-opens the window is lost.
.
The delay in opening a zero window generally occurs
when the receiving application stops taking data from
its TCP. For example, consider a printer daemon
application, stopped because the printer ran out of
paper.
只要有ACK到來,連線就要保持,這會帶來什麼問題呢?確實會帶來問題,但是在正視這些問題之前,Linux核心協議棧的實現者,也保持了緘默,我們來看一段實驗主機host1和host2所用的標準核心主線版本3.10的核心原始碼,來自tcp_probe_timer函式內部的註釋以及一小段程式碼:
/* *WARNING* RFC 1122 forbids this * * It doesn't AFAIK, because we kill the retransmit timer -AK * * FIXME: We ought not to do it, Solaris 2.5 actually has fixing * this behaviour in Solaris down as a bug fix. [AC] * * Let me to explain. icsk_probes_out is zeroed by incoming ACKs * even if they advertise zero window. Hence, connection is killed only * if we received no ACKs for normal connection timeout. It is not killed * only because window stays zero for some time, window may be zero * until armageddon and even later. We are in full accordance * with RFCs, only probe timer combines both retransmission timeout * and probe timeout in one bottle. --ANK */ ... max_probes = sysctl_tcp_retries2;
if (sock_flag(sk, SOCK_DEAD)) { // 如果是orphan連線的話 const int alive = ((icsk->icsk_rto << icsk->icsk_backoff) < TCP_RTO_MAX); // 即獲取tcp_orphan_retries引數,有微調,請詳審。本實驗引數預設值取0! max_probes = tcp_orphan_retries(sk, alive);
if (tcp_out_of_resources(sk, alive || icsk->icsk_probes_out <= max_probes)) return; } // 只有在icsk_probes_out,即未應答的probe次數超過探測最大容忍次數後,才會出錯清理連線。 if (icsk->icsk_probes_out > max_probes) { tcp_write_err(sk); } else { /* Only send another probe if we didn't close things up. */ tcp_send_probe0(sk); }
是的,從上面那一段註釋,我們看出了抱怨,一個FIN_WAIT1的連線可能會等到世界終結日之後,然而我們卻只能“in full accordance with RFCs”!
這也許暗示了某種魔咒般的結果,即FIN_WAIT1將會一直持續到終結世界的大決戰之日。然而非也,你會發現大概在傳送了9個零視窗探測包之後,連線就消失了。netstat -st的結果中,呈現:
1 connections aborted due to timeout
看來想製造點事端,並非想象般容易!
如上所述,我展示了標準主線的Linux 3.10核心的tcp_probe_timer函式,現在的問題是,為什麼下面的條件被滿足了呢?
if (icsk->icsk_probes_out > max_probes)
只有當這個條件被滿足,tcp_write_err才會被呼叫,進而:
tcp_done(sk);// 遞增計數,即netstat -st中的那條“1 connections aborted due to timeout”NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPABORTONTIMEOUT);
按照註釋和程式碼的確認,只要收到ACK,icsk_probes_out 欄位就將被清零,這是很明確的啊,我們在tcp_ack函式中便可看到無條件清零icsk_probes_out的動作:
static int tcp_ack(struct sock *sk, const struct sk_buff *skb, int flag){ ... sk->sk_err_soft = 0; icsk->icsk_probes_out = 0; tp->rcv_tstamp = tcp_time_stamp; ...}
從程式碼上看,只要零視窗探測持續傳送,不管退避到多久(最大TCP_RTO_MAX),只要對端會有ACK回來,icsk_probes_out 就會被清零,上述的條件就不會被滿足,連線就會一直在FIN_WAIT1狀態,而從我們抓包看,確實是零視窗探測有去必有回的!
預期會永遠僵在FIN_WAIT1狀態的連線在一段時間後竟然銷燬了。沒有符合預期,到底發生了呢?
如果我們看高版本4.14版的Linux核心,同樣是tcp_probe_timer函式,我們會看到一些不一樣的程式碼和註釋:
static void tcp_probe_timer(struct sock *sk){ ... /* RFC 1122 4.2.2.17 requires the sender to stay open indefinitely as * long as the receiver continues to respond probes. We support this by * default and reset icsk_probes_out with incoming ACKs. But if the * socket is orphaned or the user specifies TCP_USER_TIMEOUT, we * kill the socket when the retry count and the time exceeds the * corresponding system limit. We also implement similar policy when * we use RTO to probe window in tcp_retransmit_timer(). */ start_ts = tcp_skb_timestamp(tcp_send_head(sk)); if (!start_ts) tcp_send_head(sk)->skb_mstamp = tp->tcp_mstamp; else if (icsk->icsk_user_timeout && (s32)(tcp_time_stamp(tp) - start_ts) > jiffies_to_msecs(icsk->icsk_user_timeout)) goto abort;
max_probes = sock_net(sk)->ipv4.sysctl_tcp_retries2; if (sock_flag(sk, SOCK_DEAD)) { const bool alive = inet_csk_rto_backoff(icsk, TCP_RTO_MAX) < TCP_RTO_MAX;
max_probes = tcp_orphan_retries(sk, alive); // 如果處在FIN_WAIT1的連線持續時間超過了TCP_RTO_MAX(這是前提) // 如果退避傳送探測的次數已經超過了配置引數指定的次數(這是附加條件) if (!alive && icsk->icsk_backoff >= max_probes) goto abort; // 注意這個goto!直接銷燬連線。 if (tcp_out_of_resources(sk, true)) return; }
if (icsk->icsk_probes_out > max_probes) {abort: tcp_write_err(sk); } else { /* Only send another probe if we didn't close things up. */ tcp_send_probe0(sk); }}
我們來看這段程式碼的註釋,RFC1122的要求:
RFC 1122 4.2.2.17 requires the sender to stay open indefinitely as
long as the receiver continues to respond probes. We support this by
default and reset icsk_probes_out with incoming ACKs.
然後我們接著看這段註釋,有一個But轉折:
But if the socket is orphaned or the user specifies TCP_USER_TIMEOUT, we
kill the socket when the retry count and the time exceeds the corresponding system limit.
看起來,這段註釋是符合我們實驗的結論的!然而我們實驗的是3.10核心,而這個卻是4.X的核心啊!即Linux在高版本核心上確實進行了最佳化,這是針對資源利用的最佳化,並且避免了有針對性的DDoS。
答案揭曉了。
*我們實驗所使用的核心版本不是社群主線版本,而是Redhat的版本!***Redhat顯然會事先回移上游的patch,我們來確認一下我們所所用的實驗版本3.10.0-862.2.3.el7.x86_64的tcp_probe_timer的原始碼。
為此,我們到下面的地址去下載Redhat(Centos…)專門的原始碼,我們看看它和社群同版本原始碼是不是在關於probe處理上有所不同:
Index of /7.5.1804/updates/Source/SPackages
使用下面的命令解壓:
rpm2cpio ../kernel-3.10.0-862.2.3.el7.src.rpm | cpio -idmvxz linux-3.10.0-862.2.3.el7.tar.xz -dtar xvf linux-3.10.0-862.2.3.el7.tar
檢視net/ipv4/tcp_timer.c檔案,找到tcp_probe_timer函式:
看來是Redhat移植了4.X的patch,導致了原始碼的邏輯和社群版本的出現差異,這也就解釋了實驗現象!
那麼這個針對orphan connection的patch最初是來自何方呢?我們不得不去patchwork去溯源,以便得到更深入的Why。
在maillist,我找到了下面的連結:
http://lists.openwall.net/netdev/2014/09/23/8
Date: Mon, 22 Sep 2014 20:52:13 -0700
From: Yuchung Cheng [email protected]
To: davem@…emloft.net
Cc: edumazet@…gle.com, andrey.dmitrov@…etlabs.ru,
ncardwell@…gle.com, netdev@…r.kernel.org,
Yuchung Cheng [email protected]
Subject: [PATCH net-next] tcp: abort orphan sockets stalling on zero window probes
摘錄一段描述吧:
Currently we have two different policies for orphan socketsthat repeatedly stall on zero window ACKs. If a socket getsa zero window ACK when it is transmitting data, the RTO isused to probe the window. The socket is aborted after roughlytcp_orphan_retries() retries (as in tcp_write_timeout())..But if the socket was idle when it received the zero window ACK,and later wants to send more data, we use the probe timer toprobe the window. If the receiver always returns zero window ACKs,icsk_probes keeps getting reset in tcp_ack() and the orphan socketcan stall forever until the system reaches the orphan limit (ascommented in tcp_probe_timer()). This opens up a simple attackto create lots of hanging orphan sockets to burn the memoryand the CPU, as demonstrated in the recent netdev post “TCPconnection will hang in FIN_WAIT1 after closing if zero window isadvertised.” TCP connection will hang in FIN_WAIT1 after closing if zero window is advertised
該連結最後面給出了patch:
...+ max_probes = sysctl_tcp_retries2; if (sock_flag(sk, SOCK_DEAD)) { const int alive = inet_csk_rto_backoff(icsk, TCP_RTO_MAX) < TCP_RTO_MAX;
max_probes = tcp_orphan_retries(sk, alive);-+ if (!alive && icsk->icsk_backoff >= max_probes)+ goto abort; if (tcp_out_of_resources(sk, alive || icsk->icsk_probes_out <= max_probes)) return; }
if (icsk->icsk_probes_out > max_probes) {- tcp_write_err(sk);+abort: tcp_write_err(sk); } else {...
簡單說一下這個patch的意義。
在實驗2中,我用kill -STOP訊號故意憋死了nc接收程序,以重現現象,然而事實上在現實中,存在下面兩種不太友善情況:
接收端程序出現異常,或者接收端核心存在缺陷,導致程序掛死而軟中斷上下文的協議棧處理正常執行;
接收端就是一個惡意的DDoS程序,故意不接收資料以誘導傳送端在FIN_WAIT2狀態(甚至ESTAB狀態)傳送資料不成後傳送零視窗探測而不休止。
無論哪種情況,最主動斷開的傳送端來講,其後果都是消耗大量的資源,而orphan連線則佔著茅坑不拉屎。這比較悲哀。
現在給出本文的第三個結論:
如果主動斷開端呼叫了close關掉了程序,它會進入FIN_WAIT1狀態,如果接收端的接收視窗呈現關閉狀態(零視窗),此時它會不斷髮送零視窗探測包。傳送多少次呢?有兩種實現:
低版本核心(至少社群3.10及以下):永久嘗試,如果探測ACK每次都返回,則沒完沒了。
高版本核心(至少社群4.6及以上):限制嘗試tcp_orphan_retries次,不管是否收到探測ACK。
當然,其實還有關於非探測包的重傳限制,比如關於TCP_USER_TIMEOUT這個socket option的限制:
else if (icsk->icsk_user_timeout && (s32)(tcp_time_stamp(tp) - start_ts) > jiffies_to_msecs(icsk->icsk_user_timeout)) goto abort;
包括關於Keepalive的點點滴滴,本文就不多說了。
在此,先有個必要的總結。我老是說在學習網路協議的時候讀碼無益並不是說不要去閱讀解析Linux核心原始碼,而是一定要先有實驗設計的能力重現問題,然後再去核對RFC或者其它的協議標準,最後再去核對原始碼到底是怎麼實現的,這樣才能一氣呵成。否則將有可能陷入深淵。
以本文為例,我假設你手頭有3.10的原始碼,當你面對“FIN_WAIT1狀態的TCP連線在持續退避的零視窗探測期間並不會如預期那般永久持續下去”這個問題的時候,你讀原始碼是沒有任何用的,因為這個時候你只能靜靜地看著那些程式碼,然後糾結自己是不是哪裡理解錯了,很多人甚至很難能想到去對比不同版本的程式碼,因為版本太多了。
原始碼只是一種實現的方式,而已,真正重要的是協議的標準以及標準是實現的建議,此外,各個發行版廠商完全有自主的權力對社群原始碼做任何的定製和重構,不光是Redhat,即便你去看OpenWRT的程式碼,也是一樣,你會發現很多不一樣的東西。
我並不贊同幾乎每一個程式設計師都擁護的那種任何情況下原始碼至上,the whole world is cheap,show me the code的觀點,當一個邏輯流程擺在那裡沒有原始碼的時候,當然那絕對是原始碼至上,否則就是紙上談兵,邏輯至少要跑起來,而只有原始碼編譯後才能跑起來,流程圖和設計圖是無法執行的,這個時候,你需要放棄討論,潛心編碼。然而,當一個網路協議已經被以各種方式實現了而你只是為了排查一個問題或者確認一個邏輯的時候,程式碼就退居二三線了,這時候,請“show me the standard!”。
本文原本是想解釋完FIN_WAIT1能持續多久就結束的,但是這樣顯得有點遺憾,因為我想本文的這個FIN_WAIT1的論題可以引出一個更大的論 題,如果不繼續說一說,那便是不負責任的。
是什麼的?嗯,是TCP假連線的問題。那麼何謂TCP假連線?
所謂的TCP假連線就是TCP的一端已經逃逸出了TCP狀態機,而另一端卻不知道的連線。
我們再看完美的TCP標準RFC793上的TCP狀態圖:
除了TIME_WAIT到CLOSED這唯一的出口,你是找不到其它出口的,也就是說,一個TCP端一旦發起了建立連線請求,暫不考慮同時開啟同時關閉的情況,就一定要到其中一方的TIME_WAIT超時而結束。
然而,TCP的缺陷在於,TCP是一個端到端的協議,在協議層面上所有的端到端協議是需要底層的傳送協議作為其支撐的,一旦底層永久崩壞,端到端協議將會面臨狀態機僵住的場景,而狀態機僵住意味著對資源的永久消耗,因為連線再也釋放不掉了!
隨便舉一個例子,在兩端ESTAB狀態的時候,把IP動態路由協議停掉並把把網線剪斷,那麼TCP兩端將永遠處在ESTAB狀態,直到機器重啟。為了解決這個問題,TCP引入了Keepalive機制,一旦超過一定時間沒有互通有無,那麼就會主動銷燬這個連線,事實上,按照純粹的TCP狀態機而言,Keepalive機制是一種對TCP協議的汙染。
是不是Keepalive就能完全避免假連線,死連線存在了呢?非也,Keepalive只是一種使用者態按照自己的業務邏輯去檢測並避免假連線的手段,而我們仔細觀察TCP狀態機,很多的步驟遠不是使用者態程序可是touch的,比如本文講的FIN_WAIT1,一旦連線成為orphan的,將沒有任何程序與之關聯,雖然使用者態設定的Keepalive也可以繼續起作用,但萬一使用者態沒有設定Keepalive呢??這時怎麼辦?
我們執行下面的命令:
[root@localhost ~]# sysctl -a|grep retriesnet.ipv4.tcp_orphan_retries = 0net.ipv4.tcp_retries1 = 3net.ipv4.tcp_retries2 = 15net.ipv4.tcp_syn_retries = 6net.ipv4.tcp_synack_retries = 5net.ipv6.idgen_retries = 3
嗯,這些就是避免TCP協議本身的狀態機轉換僵死所引入的控制層Keepalive機制,詳細情況就自己去查閱Linux核心文件吧。
在具體實現上,防止狀態機僵死的方法分為兩類:
ESTABLISHED防止僵死的方法:使用使用者程序設定的Keepalive機制
非ESTABLISHED防止僵死的方法:使用各種retries核心引數設定的timeout機制