为什么kill进程后socket一直处于FIN_WAIT_1状态
本文介紹一個因為conntrack內核參數設置和iptables規則設置的原因導致TCP連接不能正常關閉(socket一直處于FIN_WAIT_1狀態)的案例,并介紹conntrack相關代碼在conntrack表項超時后對新報文的處理邏輯。
案例現象
問題的現象:
ECS上有一個進程,建立了到另一個服務器的socket連接。 kill掉進程,發現tcpdump抓不到FIN包發出,導致服務器端的連接沒有正常關閉。
為什么有這種現象呢?
梳理
正常情況下kill進程后,用戶態調用close()系統調用來發起TCP FIN給對端,所以這肯定是個異?,F象。關鍵的信息是:
從這個現象描述中可以推斷問題出在位于用戶空間和網卡驅動中間的內核態中。但是是系統調用問題,還是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的辦法,示例如下:
# 記錄下new state的報文的日志 iptables -A INPUT -p tcp -m state --state NEW -j LOG --log-prefix "[iptables] INPUT NEW: "在這個案例中,通過計數和近一步的log,發現了是OUTPUT chain的最后一跳DROP規則被匹配上了,如下:
# iptables -A OUTPUT -m state --state INVALID -j DROP問題的真兇在此時被找到了:iptables規則丟棄了kill進程后發出的FIN包,導致對端收不到,連接無法正常關閉。
到了這里,離最終的root cause還有兩個疑問:
- 問題是否在全局必現?觸發的條件是什么?
- 為什么FIN包被認為是INVALID狀態?
何時觸發
先來看第一個問題:問題是否在全局必現?觸發的條件是什么?
對于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中被丟棄。
FIN包被認為是INVALID狀態?
對于一個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)存在。
- tcp_conntrack數組最外層的下標是0,表示ORIGINAL,是發出包的一端。
- 在案例的場景中,中間層的外標由get_conntrack_index決定。get_conntrack_index(th)根據報文中的FIN flag拿到枚舉類型tcp_bit_set (定義如下)的值TCP_FIN_SET。枚舉類型tcp_bit_set和下面將要介紹的tcp_conntracks數組的中間下標一一對應。
- 里層的下標為TCP為TCP_CONNTRACK_NONE,是枚舉類型tcp_conntrack中的0。
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]- 當報文帶有FIN時:tcp_conntracks0[0] = tcp_conntracks0[0] => INVALID狀態 // 本案例
- 當報文帶有RESET時:tcp_conntracks0[0] = tcp_conntracks0[0] => INVALID狀態
- 當報文帶有SYNACK時:tcp_conntracks0[0] = tcp_conntracks0[0] => INVALID狀態
- 當報文帶有SYN和ACK時, 對于conntrack模塊是NEW狀態
總結
當操作系統使用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連接不能正常關閉。
原文鏈接
本文為云棲社區原創內容,未經允許不得轉載。
總結
以上是生活随笔為你收集整理的为什么kill进程后socket一直处于FIN_WAIT_1状态的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于External-DNS的多集群Se
- 下一篇: Fun 3.0 发布——资源部署、依赖下