为什么kill进程后socket一直处于FINWAIT1状态

3次阅读

共计 8459 个字符,预计需要花费 22 分钟才能阅读完成。

本文介绍一个因为 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 看规则如下:

注:测试时并没有显示地 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 数组的中间下标一一对应。
/* 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 为 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 连接不能正常关闭。


本文作者:怀知

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

正文完
 0