天天看點

為什麼kill程序後socket一直處于FIN_WAIT_1狀态

本文介紹一個因為conntrack核心參數設定和iptables規則設定的原因導緻TCP連接配接不能正常關閉(socket一直處于FIN_WAIT_1狀态)的案例,并介紹conntrack相關代碼在conntrack表項逾時後對新封包的處理邏輯。

案例現象

問題的現象:

ECS上有一個程序,建立了到另一個伺服器的socket連接配接。

kill掉程序,發現tcpdump抓不到FIN包發出,導緻伺服器端的連接配接沒有正常關閉。

為什麼有這種現象呢?

梳理

正常情況下kill程序後,使用者态調用close()系統調用來發起TCP FIN給對端,是以這肯定是個異常現象。關鍵的資訊是:

  1. 使用者态kill程序。
  2. ECS網卡層面沒有抓到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的辦法,示例如下:

# 記錄下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來做測試,驗證下是否是全局影響。通過測試,有如下發現:

  1. 利用netcat做類似的操作,也能複現同樣的問題,說明這個确實是全局影響,與特定程序或者連接配接無關。
  2. 連接配接時間比較長時能複現,時間比較短時kill程序時能正常發FIN。

看下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看規則如下:

為什麼kill程式後socket一直處于FIN_WAIT_1狀态

注:測試時并沒有顯示地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數組的中間下标一一對應。

    enum tcp_bit_set {

    TCP_SYN_SET,

    TCP_SYNACK_SET,

    TCP_FIN_SET,

    TCP_ACK_SET,

    TCP_RST_SET,

    TCP_NON

  • 裡層的下标為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連接配接不能正常關閉。

繼續閱讀