本文介紹一個由於conntrack內核參數設置和iptables規則設置的緣由致使TCP鏈接不能正常關閉(socket一直處於FIN_WAIT_1狀態)的案例,並介紹conntrack相關代碼在conntrack表項超時後對新報文的處理邏輯。html
問題的現象:linux
ECS上有一個進程,創建了到另外一個服務器的socket鏈接。 kill掉進程,發現tcpdump抓不到FIN包發出,致使服務器端的鏈接沒有正常關閉。
爲何有這種現象呢?c#
正常狀況下kill進程後,用戶態調用close()系統調用來發起TCP FIN給對端,因此這確定是個異常現象。關鍵的信息是:數組
從這個現象描述中能夠推斷問題出在位於用戶空間和網卡驅動中間的內核態中。可是是系統調用問題,仍是FIN已經構造後出的問題,還不肯定。這時候比較簡單有效的判斷的方法是看socket的狀態。socket處於TIME_WAIT_1狀態,這個信息頗有用,能夠判斷系統調用是正常的,由於按照TCP狀態機,FIN發出來後socket會進入TIME_WAIT_1狀態,在收到對端ACK後進入TIME_WAIT_2狀態。關於socket的另外一個信息是:這個socket長時間處於TIME_WAIT_1狀態,這也反向證實了在網卡上沒有抓到FIN包的陳述是合理。FIN包沒出虛機網卡,對端收不到FIN,因此天然沒有機會回ACK。服務器
問題梳理到了這裏,基本上能夠進一步聚焦了,在沒有大bug的狀況下,須要重點看下iptables(netfilter), tc等機制對報文的影響。果真在ECS中有許多iptables規則。利用iptables -nvL能夠打出每條rule匹配到的計數,或者利用寫log的辦法,示例以下:socket
# 記錄下new state的報文的日誌 iptables -A INPUT -p tcp -m state --state NEW -j LOG --log-prefix "[iptables] INPUT NEW: "
在這個案例中,經過計數和近一步的log,發現了是OUTPUT chain的最後一跳DROP規則被匹配上了,以下:tcp
# iptables -A OUTPUT -m state --state INVALID -j DROP
問題的真兇在此時被找到了:iptables規則丟棄了kill進程後發出的FIN包,致使對端收不到,鏈接沒法正常關閉。ide
到了這裏,離最終的root cause還有兩個疑問:函數
先來看第一個問題:問題是否在全局必現?觸發的條件是什麼?測試
對於ECS上與服務器創建TCP鏈接的進程,問題實際上不是每次必現的。建議用netcat來作測試,驗證下是不是全局影響。經過測試,有以下發現:
看下conntrack相關的內核參數設置,發現ECS環境的conntrack參數中有一個顯著的調整:
net.netfilter.nf_conntrack_tcp_timeout_established = 120
這個值默認值是5天,阿里雲官網文檔推薦的調優值是1200秒,而如今這個ECS環境中的設置是120秒,是一個很是短的值。
看到這裏,能夠認定是通過nf_conntrack_tcp_timeout_established 120秒後,conntrack中的鏈接跟蹤記錄已經被刪除,此時對這個鏈接發起主動的FIN,在netfilter中回被斷定成INVALID狀態。而客戶在iptables filter表的OUTPUT chain中對INVALID鏈接狀態的報文采起的是drop行爲,最終致使FIN報文在netfilter filter表OUTPUT chain中被丟棄。
對於一個TCP鏈接,在conntrack中沒有鏈接跟蹤表項,一端FIN掉鏈接的時候的時候被認爲是INVALID狀態是很符合邏輯的事情。可是沒有發現任何文檔清楚地描述這個場景:當用戶空間TCP socket仍然存在,可是conntrack表項已經不存在時,對一個「新」的報文,conntrack模塊認爲它是什麼狀態。
全部文檔描述conntrack的NEW, ESTABLISHED, RELATED, INVALID狀態時大同小異,比較詳細的描述如文檔:
The NEW state tells us that the packet is the first packet that we see. This means that the first packet that the conntrack module sees, within a specific connection, will be matched. For example, if we see a SYN packet and it is the first packet in a connection that we see, it will match. However, the packet may as well not be a SYN packet and still be considered NEW. This may lead to certain problems in some instances, but it may also be extremely helpful when we need to pick up lost connections from other firewalls, or when a connection has already timed out, but in reality is not closed.
如上對於NEW狀態的描述爲:conntrack module看見的一個報文就是NEW狀態,例如TCP的SYN報文,有時候非SYN也被認爲是NEW狀態。
在本案例的場景裏,conntrack表項已通過期了,此時無論從用戶態發什麼報文到conntrack模塊時,都算是conntrack模塊看見的第一個報文,那麼conntrack都認爲是NEW狀態嗎?好比SYN, SYNACK, FIN, RST,這些明顯有不一樣的語義,實踐經驗FIN, RST這些直接放成INVALID是沒毛病的,到這裏仍是來複現下並看看代碼的邏輯吧。
iptables規則設置
用以下腳原本設置下iptables規則:
#!/bin/sh iptables -P INPUT ACCEPT iptables -F iptables -X iptables -Z # 在日誌裏記錄INPUT chain裏過來的每一個報文的狀態 iptables -A INPUT -p tcp -m state --state NEW -j LOG --log-prefix "[iptables] INPUT NEW: " iptables -A INPUT -p TCP -m state --state ESTABLISHED -j LOG --log-prefix "[iptables] INPUT ESTABLISHED: " iptables -A INPUT -p TCP -m state --state RELATED -j LOG --log-prefix "[iptables] INPUT RELATED: " iptables -A INPUT -p TCP -m state --state INVALID -j LOG --log-prefix "[iptables] INPUT INVALID: " iptables -A INPUT -i lo -j ACCEPT iptables -A INPUT -p tcp --dport 22 -j ACCEPT iptables -A INPUT -p tcp --dport 21 -j ACCEPT iptables -A INPUT -p tcp --dport 80 -j ACCEPT iptables -A INPUT -p tcp --dport 443 -j ACCEPT iptables -A INPUT -p tcp --dport 8088 -m state --state NEW -j ACCEPT iptables -A INPUT -p icmp --icmp-type 8 -j ACCEPT iptables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT # 在日誌裏記錄OUTPUT chain裏過來的每一個報文的狀態 iptables -A OUTPUT -p tcp -m state --state NEW -j LOG --log-prefix "[iptables] OUTPUT NEW: " iptables -A OUTPUT -p TCP -m state --state ESTABLISHED -j LOG --log-prefix "[iptables] OUTPUT ESTABLISHED: " iptables -A OUTPUT -p TCP -m state --state RELATED -j LOG --log-prefix "[iptables] OUTPUT RELATED: " iptables -A OUTPUT -p TCP -m state --state INVALID -j LOG --log-prefix "[iptables] OUTPUT INVALID: " # iptables -A OUTPUT -m state --state INVALID -j DROP iptables -P INPUT DROP iptables -P OUTPUT ACCEPT iptables -P FORWARD DROP service iptables save systemctl restart iptables.service
利用iptables -nvL看規則以下:
注:測試時並無顯示地drop掉OUTPUT chain的INVALID狀態的報文,也能復現相似的問題,由於在INPUT方向對端回的FIN一樣也是INVALID狀態的報文,會被INPUT chain默認的DROP規則丟棄掉。
將conntrack tcp timeout設置得短點:sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=20
利用nc測試,第一次創建鏈接完idle 20秒,conntrack中ESTABLISHED的表項消失 (能夠利用iptstate或者conntrack tool查看):
直接kill進程發FIN, 對於conntrack的狀態是INVALID。
接續發數據,對於conntrack的狀態是NEW。
代碼邏輯
nf_conntrack模塊的報文能夠從nf_conntrack_in函數看起,對於conntrack表項中不存在的新表項的邏輯:
nf_conntrack_in @net/netfilter/nf_conntrack_core.c |--> resolve_normal_ct @net/netfilter/nf_conntrack_core.c // 利用__nf_conntrack_find_get查找對應的鏈接跟蹤表項,沒找到則init新的conntrack表項 |--> init_conntrack @net/netfilter/nf_conntrack_core.c // 初始化conntrack表項 |--> tcp_new @net/netfilter/nf_conntrack_proto_tcp.c // 到TCP協議的處理邏輯,called when a new connection for this protocol found。在這裏根據tcp_conntracks數組決定狀態。
reslove_normal_ct
在reslove_normal_ct中, 邏輯是先找利用__nf_conntrack_find_get查找對應的鏈接跟蹤表項。在本文的場景中conntrack表項已經超時,因此不存在。代碼邏輯進入init_conntrack,來初始化一個表項。
/* look for tuple match */ hash = hash_conntrack_raw(&tuple, zone); h = __nf_conntrack_find_get(net, zone, &tuple, hash); if (!h) { h = init_conntrack(net, tmpl, &tuple, l3proto, l4proto, skb, dataoff, hash); if (!h) return NULL; if (IS_ERR(h)) return (void *)h; }
init_conntrack
在init_conntrack的以下邏輯裏會利用nf_conntrack_l4proto的new來讀取和校驗一個對於conntrack模塊是新鏈接的報文內容。若是返回值是false,則進入以下if statement來結束這個初始化conntrack表項的過程。在案例的場景確實會在這裏就結束conntrack表項的初始化。
對於這個「新」的TCP報文的驗證,也就是咱們關心的對於一個conntrack表項不存在(超時)的TCP鏈接,會在new(tcp_new)的邏輯中判斷。
if (!l4proto->new(ct, skb, dataoff, timeouts)) { nf_conntrack_free(ct); pr_debug("init conntrack: can't track with proto module\n"); return NULL; }
tcp_new
在tcp_new的以下邏輯中,關鍵的邏輯是對new_state的賦值,當new_state >= TCP_CONNTRACK_MAX時,會返回false退出。對於FIN包,new_state的賦值會是TCP_CONNTRACK_MAX (sIV),具體邏輯看以下分析。
/* Called when a new connection for this protocol found. */ static bool tcp_new(struct nf_conn *ct, const struct sk_buff *skb, unsigned int dataoff, unsigned int *timeouts) { enum tcp_conntrack new_state; const struct tcphdr *th; struct tcphdr _tcph; struct net *net = nf_ct_net(ct); struct nf_tcp_net *tn = tcp_pernet(net); const struct ip_ct_tcp_state *sender = &ct->proto.tcp.seen[0]; const struct ip_ct_tcp_state *receiver = &ct->proto.tcp.seen[1]; th = skb_header_pointer(skb, dataoff, sizeof(_tcph), &_tcph); BUG_ON(th == NULL); /* Don't need lock here: this conntrack not in circulation yet */ // 這裏get_conntrack_index拿到的是TCP_FIN_SET,是枚舉類型tcp_bit_set的值 new_state = tcp_conntracks[0][get_conntrack_index(th)][TCP_CONNTRACK_NONE]; /* Invalid: delete conntrack */ if (new_state >= TCP_CONNTRACK_MAX) { pr_debug("nf_ct_tcp: invalid new deleting.\n"); return false; } ...... }
tcp_conntracks是一個三維數組,做爲TCP狀態轉換表(TCP state transition table)存在。
/* What TCP flags are set from RST/SYN/FIN/ACK. */ enum tcp_bit_set { TCP_SYN_SET, TCP_SYNACK_SET, TCP_FIN_SET, TCP_ACK_SET, TCP_RST_SET, TCP_NON
tcp_conntracks數組
數組的內容以下,在源碼裏有很是多的註釋說明狀態的轉換,這裏先略去,具體可參考數組定義。這裏只關注在conntrack表項超時後,收到第一個報文時對報文狀態的定義。
static const u8 tcp_conntracks[2][6][TCP_CONNTRACK_MAX] = { { /* ORIGINAL */ /*syn*/ { sSS, sSS, sIG, sIG, sIG, sIG, sIG, sSS, sSS, sS2 }, /*synack*/ { sIV, sIV, sSR, sIV, sIV, sIV, sIV, sIV, sIV, sSR }, /*fin*/ { sIV, sIV, sFW, sFW, sLA, sLA, sLA, sTW, sCL, sIV }, /*ack*/ { sES, sIV, sES, sES, sCW, sCW, sTW, sTW, sCL, sIV }, /*rst*/ { sIV, sCL, sCL, sCL, sCL, sCL, sCL, sCL, sCL, sCL }, /*none*/ { sIV, sIV, sIV, sIV, sIV, sIV, sIV, sIV, sIV, sIV } }, { /* REPLY */ /*syn*/ { sIV, sS2, sIV, sIV, sIV, sIV, sIV, sIV, sIV, sS2 }, /*synack*/ { sIV, sSR, sIG, sIG, sIG, sIG, sIG, sIG, sIG, sSR }, /*fin*/ { sIV, sIV, sFW, sFW, sLA, sLA, sLA, sTW, sCL, sIV }, /*ack*/ { sIV, sIG, sSR, sES, sCW, sCW, sTW, sTW, sCL, sIG }, /*rst*/ { sIV, sCL, sCL, sCL, sCL, sCL, sCL, sCL, sCL, sCL }, /*none*/ { sIV, sIV, sIV, sIV, sIV, sIV, sIV, sIV, sIV, sIV } } };
根據上面的分析,對conntrack模塊的新報文來講,取值以下:
tcp_conntracks[0][get_conntrack_index(th)][TCP_CONNTRACK_NONE] =>tcp_conntracks[0][get_conntrack_index(th)][0]
當操做系統使用iptables時(或者在其餘場景中使用netfilter提供的hook點),大部分關於nf_conntrack_tcp_timeout_established的優化都是建議把默認的5天調小,以免conntrack表滿的狀況,這個是推薦的最佳實踐。可是從另外一個角度,到底設置到多小比較好?除非你能明確地知道你的iptables規則對每個報文的過濾行爲,不然不建議設置到幾百秒及如下級別。
當把nf_conntrack_tcp_timeout_established設置得很短時,對於超時的conntrack表項,關閉鏈接時的FIN或者RST(linger enable)很容易被iptables規則丟棄,在本文案例中iptables的filter表規則中的每一個chain都顯示地丟棄了INVALID狀態報文,即便不顯示丟棄,一般設置規則的時候INPUT chain的默認規則也不會容許INVALID狀態的包進入,採起丟棄行爲。最終的影響就是讓用戶態的socket停在諸如FIN_WAIT_1和LAST_ACK等不太常見的狀態,形成TCP鏈接不能正常關閉。
本文爲雲棲社區原創內容,未經容許不得轉載。