WEBKT

Linux内核源码剖析:Netfilter Conntrack 连接跟踪状态机是如何运转的?

4 0 0 0

在 Linux 网络协议栈中,Connection Tracking(简称 Conntrack,连接跟踪)是实现状态防火墙(Stateful Firewall)、网络地址转换(NAT)以及 Kubernetes 中 IPVS/Iptables 模式 Service 转发的核心基石。

Conntrack 的核心任务非常明确:拦截进出协议栈的每一个数据包,并将其归类到某个“连接”中,进而维护该连接的状态机。

本文将基于 Linux 内核主线源码(以近年 LTS 版本为基准),深入剖析 Conntrack 状态机在内核中的具体维护流程、核心数据结构以及关键的函数调用栈。


一、 核心数据结构:连接跟踪的骨架

在深入源码之前,必须先理清内核是如何抽象“连接”与“方向”的。

1. struct nf_conn — 连接跟踪表项

每一个被跟踪的连接,在内核中都对应一个 struct nf_conn 结构体实例。它记录了连接的生命周期、状态、NAT 转换信息以及关联的辅助模块(Helpers)。

// include/net/netfilter/nf_conntrack.h
struct nf_conn {
    /* 核心:用于哈希表检索的 tuple 节点,包含原向(ORIGINAL)和反向(REPLY) */
    struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];

    /* 连接状态位,如 IPS_CONFIRMED、IPS_SEEN_REPLY、IPS_ASSURED 等 */
    unsigned long status;

    /* 引用计数,决定何时释放该结构体 */
    refcount_t ct_general;

    /* 存放协议特有数据的联合体,比如 TCP 状态、窗口大小等 */
    union nf_conntrack_proto proto;

    /* 挂载超时定时器 */
    struct timer_list timeout;
    
    /* ... 略去锁定与辅助字段 */
};

2. struct nf_conntrack_tuple — 元组描述符

内核通过“五元组”唯一标识一个单向的数据流。

// include/net/netfilter/nf_conntrack_tuple.h
struct nf_conntrack_tuple {
    struct nf_conntrack_man src; /* 源 IP 及源端口/ICMP ID */
    struct {
        union nf_inet_addr u3;
        union nf_conntrack_man_proto u;
        u_int16_t protonum;      /* 传输层协议号 */
        u_int8_t dir;             /* 方向:IP_CT_DIR_ORIGINAL 或 IP_CT_DIR_REPLY */
    } dst;
};

注意,一个完整的双向连接(nf_conn)包含两个 tuplehash。例如,A 主机向 B 主机发起连接:

  • ORIGINAL 方向的 Tuple:A:port1 -> B:port2
  • REPLY 方向的 Tuple:B:port2 -> A:port1

二、 状态机的主入口:nf_conntrack_in

Conntrack 注册在 Netfilter 的多个 Hook 点上。最核心的两个入口是 PREROUTING(入站/转发数据包)和 LOCAL_OUT(本地出站数据包)。

它们都会调用核心入口函数 nf_conntrack_in

// net/netfilter/nf_conntrack_core.c
unsigned int
nf_conntrack_in(struct sk_buff *skb, const struct nf_hook_state *state)
{
    struct nf_conn *ct;
    enum ip_conntrack_info ctinfo;
    const struct nf_conntrack_l4proto *l4proto;
    /* ... */

    /* 1. 提取 L4 协议处理器(如 TCP, UDP, ICMP) */
    l4proto = nf_ct_l4proto_find(protonum);

    /* 2. 核心步骤:解析数据包并查找/创建对应的连接跟踪表项(nf_conn) */
    ct = resolve_normal_ct(state->net, tmpl, skb, dataoff,
                           pf, protonum, l4proto, &ctinfo);
    if (IS_ERR(ct))
        return NF_DROP;

    if (!ct) {
        /* 返回 NULL 表示该数据包不需要被跟踪(例如被 NOTRACK 规则匹配) */
        return NF_ACCEPT;
    }

    /* 3. 调用协议特定的 packet() 回调函数,更新连接的状态机 */
    ret = l4proto->packet(ct, skb, dataoff, ctinfo, state);
    if (ret < 0) {
        /* 状态机校验失败,释放引用计数 */
        nf_ct_put(ct);
        return -ret;
    }

    /* 4. 将关联的 ct 状态指针及信息挂载到 sk_buff 的 _nfct 字段上 */
    nf_ct_set(skb, ct, ctinfo);

    return ret;
}

阶段分析:

  1. resolve_normal_ct:负责做 Hash 查表。如果命中了已有的连接,就增加引用计数并返回;如果是全新的流量,则在内存中初始化一个新的 struct nf_conn 表项。
  2. l4proto->packet:这是状态机发生状态转移的驱动轮。不同的协议行为差异巨大,因此内核将其抽象为虚函数接口。对于 TCP,它指向 tcp_packet

三、 状态匹配与创建:resolve_normal_ct

resolve_normal_ct 的内部逻辑是理解 Conntrack 性能的关键。它采用了无锁(RCU)设计来应对高并发的网络流量。

// net/netfilter/nf_conntrack_core.c
static inline struct nf_conn *
resolve_normal_ct(struct net *net, struct nf_conn *tmpl,
                  struct sk_buff *skb, unsigned int dataoff,
                  u_int8_t pf, unsigned int protonum,
                  const struct nf_conntrack_l4proto *l4proto,
                  enum ip_conntrack_info *ctinfo)
{
    struct nf_conntrack_tuple tuple;
    struct nf_conntrack_tuple_hash *h;
    struct nf_conn *ct;

    /* 1. 根据 skb 的 IP 头部和传输层头部,提取出当前的 tuple */
    if (!nf_ct_get_tuple(skb, skb_network_offset(skb), dataoff,
                         pf, protonum, net, &tuple))
        return NULL;

    /* 2. 在全局全局哈希表(net->ct.hash)中寻找匹配的 tuple_hash */
    h = __nf_conntrack_find_get(net, &tuple);
    if (!h) {
        /* 3. 未命中:这是一个新连接的数据包,调用 init_conntrack 创建新表项 */
        h = init_conntrack(net, tmpl, &tuple, l4proto, skb, dataoff);
        if (!h)
            return NULL;
        if (IS_ERR(h))
            return (void *)h;
    }

    /* 4. 获取对应的实际连接结构体 nf_conn */
    ct = nf_ct_tuplehash_to_ctrack(h);

    /* 5. 确定当前数据包的方向及 ctinfo 状态 */
    if (NF_CT_DIRECTION(h) == IP_CT_DIR_REPLY) {
        *ctinfo = IP_CT_ESTABLISHED_REPLY; // 已收到回复的方向
    } else {
        /* 原向数据流,需判断当前连接是否处于回复状态 */
        if (test_bit(IPS_SEEN_REPLY_BIT, &ct->status))
            *ctinfo = IP_CT_ESTABLISHED;   // 双向已建立
        else if (test_bit(IPS_EXPECTED_BIT, &ct->status))
            *ctinfo = IP_CT_RELATED;       // 相关联的连接(如 FTP 数据通道)
        else
            *ctinfo = IP_CT_NEW;           // 全新连接的原向首包
    }

    return ct;
}

关键设计点:

  • RCU 锁: __nf_conntrack_find_get 运行在 RCU 读锁定区间,绝大多数已经处于 ESTABLISHED 状态的连接在此处可以并发无锁地快速完成检索。
  • 未确认状态: 此时新创建的 nf_conn 并没有立刻放入全局哈希表中,它只是一个本地临时变量,在通过所有 Netfilter Hook 链(直到 POSTROUTING / LOCAL_IN 阶段)并确认数据包未被 Drop 后,才会由 nf_conntrack_confirm 函数安全地插入哈希表中。这有效地避免了半连接攻击或丢弃包对哈希表的无端污染。

四、 协议特定状态机:以 TCP 状态演进为例

TCP 连接跟踪是 Conntrack 中最复杂的部分。它的实现全部位于 net/netfilter/nf_conntrack_proto_tcp.c 中。

1. 状态二维转移矩阵

内核通过一个三维数组 tcp_conntracks 定义了 TCP 在收到特定数据包时的状态转移逻辑。

// net/netfilter/nf_conntrack_proto_tcp.c
static const u8 tcp_conntracks[2][6][TCP_CONNTRACK_MAX] = {
    // 0: 原向(ORIGINAL)发送数据包导致的状态变化
    // 1: 反向(REPLY)发送数据包导致的状态变化
};

这里的 6 代表 TCP 报文的核心分类索引(通过解析 TCP Flags 得到):

  • TCP_SYN_SET (SYN)
  • TCP_SYNACK_SET (SYN-ACK)
  • TCP_FIN_SET (FIN)
  • TCP_RST_SET (RST)
  • TCP_ACK_SET (ACK)
  • TCP_NONE_SET

TCP_CONNTRACK_MAX 代表当前 Conntrack 中记录的 TCP 内部状态(注意,Conntrack 的内部 TCP 状态与标准 TCP 状态机并不完全一致):

  • TCP_CONNTRACK_NONE
  • TCP_CONNTRACK_SYN_SENT
  • TCP_CONNTRACK_SYN_RECV
  • TCP_CONNTRACK_ESTABLISHED
  • TCP_CONNTRACK_FIN_WAIT
  • TCP_CONNTRACK_CLOSE_WAIT
  • TCP_CONNTRACK_LAST_ACK
  • TCP_CONNTRACK_TIME_WAIT
  • TCP_CONNTRACK_CLOSE

2. 状态转移函数:tcp_packet

每次收到 TCP 数据包,内核都会进入 tcp_packet 进行状态决策:

// net/netfilter/nf_conntrack_proto_tcp.c
static int tcp_packet(struct nf_conn *ct, const struct sk_buff *skb,
                      unsigned int dataoff, enum ip_conntrack_info ctinfo,
                      const struct nf_hook_state *state)
{
    struct net *net = state->net;
    struct nf_tcp_net *tn = nf_tcp_pernet(net);
    struct tcphdr _tcph;
    const struct tcphdr *th;
    /* ... */

    /* 1. 安全读取 TCP 头部 */
    th = skb_header_pointer(skb, dataoff, sizeof(_tcph), &_tcph);

    /* 2. 获取当前数据包的方向 */
    dir = CTINFO2DIR(ctinfo);

    /* 3. 获取当前 Conntrack 连接记录所处的 TCP 状态 */
    old_state = ct->proto.tcp.state;

    /* 4. 根据 TCP Flags 计算本次报文的分类索引 */
    index = get_conntrack_index(th);

    /* 5. 状态转移核心:通过二维矩阵获取新状态 */
    new_state = tcp_conntracks[dir][index][old_state];

    switch (new_state) {
    case TCP_CONNTRACK_SYN_SENT:
        if (old_state == TCP_CONNTRACK_SYN_SENT) {
            /* 连续发送 SYN,说明是重传 */
            if (dir == IP_CT_DIR_REPLY) {
                /* 如果在 Reply 方向收到 SYN,说明是两端同时打开(Simultaneous open) */
                new_state = TCP_CONNTRACK_SYN_RECV;
            }
        }
        break;

    case TCP_CONNTRACK_ESTABLISHED:
        /* 当连接正式进入 ESTABLISHED 状态时,设置 IPS_ASSURED 标志位 */
        if (old_state == TCP_CONNTRACK_SYN_RECV && dir == IP_CT_DIR_REPLY) {
            /* 经典的 TCP 三次握手:
               1. Client -> SYN (ct: SYN_SENT)
               2. Server -> SYN-ACK (ct: SYN_RECV)
               3. Client -> ACK (ct: 进入 ESTABLISHED) */
        }
        break;
        /* ... 处理 FIN、RST 等复杂状态 */
    }

    /* 6. 更新连接跟踪表项中的 TCP 状态 */
    ct->proto.tcp.state = new_state;

    /* 7. 根据当前所处的新状态,更新对应的生存超时时间(Timeout)
       例如:ESTABLISHED 状态通常默认为 5 天(或由 sysctl 调小),
       而 SYN_SENT 状态只有 120 秒 */
    nf_ct_refresh_acct(ct, ctinfo, skb, tn->timeouts[new_state]);

    return NF_ACCEPT;
}

五、 终局之战:连接确认 nf_conntrack_confirm

当一个全新的数据包顺利通过了防火墙的所有规则,来到了发送前的最后一站(在 POSTROUTING 链上挂载的 ipv4_confirmipv6_confirm),就需要正式确立该连接。

// include/net/netfilter/nf_conntrack_core.h
static inline int nf_conntrack_confirm(struct sk_buff *skb)
{
    struct nf_conn *ct = (struct nf_conn *)skb_nfct(skb);

    /* 如果连接非空,且尚未被 Confirm */
    if (ct && !nf_ct_is_confirmed(ct))
        return __nf_conntrack_confirm(skb);

    return NF_ACCEPT;
}

__nf_conntrack_confirm 内部,内核做出了最关键的防并发冲突处理:

// net/netfilter/nf_conntrack_core.c
int __nf_conntrack_confirm(struct sk_buff *skb)
{
    struct nf_conn *ct;
    struct net *net;
    unsigned int hash, reply_hash;
    /* ... */

    ct = nf_ct_get(skb, &ctinfo);
    net = nf_ct_net(ct);

    /* 1. 再次确认是否已经被并发的其他线程确认过了 */
    if (nf_ct_is_confirmed(ct))
        return NF_ACCEPT;

    /* 2. 计算原向和反向 tuple 的哈希值 */
    hash = hash_conntrack_raw(&ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple, net);
    reply_hash = hash_conntrack_raw(&ct->tuplehash[IP_CT_DIR_REPLY].tuple, net);

    /* 3. 锁定全局 Conn 哈希表的对应桶(Bucket Lock) */
    nf_ct_double_lock(net, hash, reply_hash);

    /* 4. 关键:做最后的防冲突冲突检测(Deduplication)
          在高并发场景下,可能两个核同时处理了同一条流的两个并发包。
          在这里,如果发现全局哈希表里已经有了相同的 tuple,就判定为冲突 */
    if (nf_ct_key_equal(&h->tuple, &ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple, net)) {
        /* 发现冲突,丢弃当前的临时 ct,改用已经存在的那个 ct */
        nf_ct_double_unlock(net, hash, reply_hash);
        return NF_DROP; 
    }

    /* 5. 确认插入:将两个方向的 tuple 链表节点正式加入到全局哈希表中 */
    hlist_nulls_add_head_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode,
                       &net->ct.hash[hash]);
    hlist_nulls_add_head_rcu(&ct->tuplehash[IP_CT_DIR_REPLY].hnnode,
                       &net->ct.hash[reply_hash]);

    /* 设置已确认标志位 */
    set_bit(IPS_CONFIRMED_BIT, &ct->status);

    nf_ct_double_unlock(net, hash, reply_hash);

    /* 激活超时定时器,开始倒计时 */
    add_timer(&ct->timeout);

    return NF_ACCEPT;
}

六、 总结与工程实践

从源码级别的分析中,我们可以推导出许多日常运维和内核优化中的经验依据:

  1. 为什么并发高时 Conntrack 容易丢包?
    新连接在 __nf_conntrack_confirm 时需要持有哈希表桶的自旋锁(double_lock)。如果哈希表的大小(nf_conntrack_buckets)设置过小,导致大量的连接 Hash 冲突并堆积在同一个桶里,锁竞争就会极为严重,产生明显的延迟甚至引发丢包。

  2. TCP ESTABLISHED 默认超时过长的隐患:
    在内核默认配置中,nf_conntrack_tcp_timeout_established 往往高达几小时甚至数天。在微服务、高频短链接的 K8s 环境中,这会导致大量的已经实际断开、但由于各种异常没有走完 FIN 四次挥手的连接残留在 Conntrack 表中,迅速占满 nf_conntrack_max 限制。调优生产环境时,强烈建议将该值缩减到 1200 秒(20 分钟)或更低。

  3. Deduplication 机制的代价:
    在大流量并发场景下,高频触发 __nf_conntrack_confirm 中的 double_lock 是不可避免的。内核的这种延迟确认和双重加锁设计,虽然保证了协议栈状态机的极端绝对安全,但也注定了 Conntrack 模块在超大规模吞吐的网络密集型应用(例如四层负载均衡器)中会成为性能瓶颈。这也是为什么像 DPDK、eBPF (XDP) 等技术在绕过内核协议栈(Bypass Kernel)实现快速转发时,第一步往往就是剥离或重写 Conntrack 机制的原因。

内核探路者 Linux内核NetfilterConntrack

评论点评