Linux内核源码剖析:Netfilter Conntrack 连接跟踪状态机是如何运转的?
在 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;
}
阶段分析:
resolve_normal_ct:负责做 Hash 查表。如果命中了已有的连接,就增加引用计数并返回;如果是全新的流量,则在内存中初始化一个新的struct nf_conn表项。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_NONETCP_CONNTRACK_SYN_SENTTCP_CONNTRACK_SYN_RECVTCP_CONNTRACK_ESTABLISHEDTCP_CONNTRACK_FIN_WAITTCP_CONNTRACK_CLOSE_WAITTCP_CONNTRACK_LAST_ACKTCP_CONNTRACK_TIME_WAITTCP_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_confirm 或 ipv6_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;
}
六、 总结与工程实践
从源码级别的分析中,我们可以推导出许多日常运维和内核优化中的经验依据:
为什么并发高时 Conntrack 容易丢包?
新连接在__nf_conntrack_confirm时需要持有哈希表桶的自旋锁(double_lock)。如果哈希表的大小(nf_conntrack_buckets)设置过小,导致大量的连接 Hash 冲突并堆积在同一个桶里,锁竞争就会极为严重,产生明显的延迟甚至引发丢包。TCP ESTABLISHED 默认超时过长的隐患:
在内核默认配置中,nf_conntrack_tcp_timeout_established往往高达几小时甚至数天。在微服务、高频短链接的 K8s 环境中,这会导致大量的已经实际断开、但由于各种异常没有走完 FIN 四次挥手的连接残留在 Conntrack 表中,迅速占满nf_conntrack_max限制。调优生产环境时,强烈建议将该值缩减到 1200 秒(20 分钟)或更低。Deduplication 机制的代价:
在大流量并发场景下,高频触发__nf_conntrack_confirm中的double_lock是不可避免的。内核的这种延迟确认和双重加锁设计,虽然保证了协议栈状态机的极端绝对安全,但也注定了 Conntrack 模块在超大规模吞吐的网络密集型应用(例如四层负载均衡器)中会成为性能瓶颈。这也是为什么像 DPDK、eBPF (XDP) 等技术在绕过内核协议栈(Bypass Kernel)实现快速转发时,第一步往往就是剥离或重写 Conntrack 机制的原因。