路由子系统 决定了包往哪走,但”这个包能不能走”的决策权在 Netfilter。iptables、nftables、conntrack、NAT——这些耳熟能详的工具,底层都建立在同一个内核框架上。
Netfilter 的设计哲学是钩子(hook):在收发包路径的五个关键位置插入回调链,每个包依次经过所有注册的回调,由它们决定放行、丢弃还是修改。如果你读过 k8s-network 系列的 Netfilter 与 iptables,那篇文章从用户态视角讲了五链四表的用法。本文深入内核,拆解三个核心实现:钩子注册与遍历、连接跟踪(conntrack)、NAT 转换路径。
一、五个钩子点
Netfilter 在 IPv4
收发路径中插入五个钩子点(include/uapi/linux/netfilter.h):
┌────────────────┐
│ NF_INET_ │
│ PRE_ROUTING │ ← 进入本机的包(路由前)
│ (hook 0) │
└───────┬────────┘
│
路由决策
╱ ╲
本机接收 ╱ ╲ 转发
┌──────────────┐ ┌──────────────┐
│ NF_INET_ │ │ NF_INET_ │
│ LOCAL_IN │ │ FORWARD │
│ (hook 1) │ │ (hook 2) │
└──────┬───────┘ └──────┬───────┘
│ │
本地进程 │
│ │
┌──────┴───────┐ │
│ NF_INET_ │ │
│ LOCAL_OUT │ │
│ (hook 3) │ │
└──────┬───────┘ │
│ │
└──────────┬──────────────┘
│
┌───────┴────────┐
│ NF_INET_ │
│ POST_ROUTING │ ← 离开本机的包(路由后)
│ (hook 4) │
└────────────────┘
每个钩子点可以注册多个回调,按优先级排序执行。IPv4
的标准优先级定义在
include/uapi/linux/netfilter_ipv4.h:30:
| 优先级 | 常量 | 用途 |
|---|---|---|
| -450 | NF_IP_PRI_RAW_BEFORE_DEFRAG |
分片重组前的 raw 规则 |
| -400 | NF_IP_PRI_CONNTRACK_DEFRAG |
分片重组(conntrack 依赖) |
| -300 | NF_IP_PRI_RAW |
raw 表(NOTRACK 在这里生效) |
| -200 | NF_IP_PRI_CONNTRACK |
连接跟踪查找/创建 |
| -150 | NF_IP_PRI_MANGLE |
mangle 表(修改包头) |
| -100 | NF_IP_PRI_NAT_DST |
DNAT(目的地址转换) |
| 0 | NF_IP_PRI_FILTER |
filter 表(放行/丢弃) |
| 50 | NF_IP_PRI_SECURITY |
SELinux 安全策略 |
| 100 | NF_IP_PRI_NAT_SRC |
SNAT(源地址转换) |
| 300 | NF_IP_PRI_CONNTRACK_HELPER |
应用层协议助手 |
| INT_MAX | NF_IP_PRI_CONNTRACK_CONFIRM |
连接确认 |
关键顺序:raw(-300)在 conntrack(-200)之前——NOTRACK 规则可以在连接跟踪之前标记包,让它完全跳过 conntrack。DNAT(-100)在路由之前,SNAT(100)在路由之后——因为 DNAT 改变目的地址会影响路由决策。
二、钩子注册与遍历
nf_hook_ops:注册结构
每个模块通过
struct nf_hook_ops(include/linux/netfilter.h:97)注册钩子:
struct nf_hook_ops {
nf_hookfn *hook; /* 回调函数 */
struct net_device *dev; /* 绑定设备(可选) */
void *priv; /* 私有数据 */
u8 pf; /* 协议族:NFPROTO_IPV4/IPV6 */
unsigned int hooknum; /* 钩子点编号(0-4) */
int priority; /* 优先级(小优先) */
};回调函数签名:
typedef unsigned int nf_hookfn(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state);返回值(verdict)决定包的命运:
| Verdict | 值 | 含义 |
|---|---|---|
NF_DROP |
0 | 丢弃,释放 skb |
NF_ACCEPT |
1 | 继续下一个钩子 |
NF_STOLEN |
2 | 钩子接管 skb,框架不再处理 |
NF_QUEUE |
3 | 排入用户态队列(NFQUEUE) |
NF_REPEAT |
4 | 再次调用当前钩子 |
nf_hook_entries:运行时数据结构
注册后的钩子被编译为
struct nf_hook_entries(netfilter.h:119)——一个紧凑的数组,按优先级排序:
struct nf_hook_entries {
u16 num_hook_entries; /* 钩子数量 */
struct nf_hook_entry hooks[]; /* 钩子数组 */
/* 尾部隐含:
* const struct nf_hook_ops *orig_ops[];
* struct nf_hook_entries_rcu_head head;
*/
};
struct nf_hook_entry {
nf_hookfn *hook; /* 函数指针 */
void *priv; /* 私有数据 */
};为什么不直接用链表? 因为数组的 cache
locality
远好于链表——每个包都要遍历所有钩子,紧凑数组可以预取到 L1
cache。nf_hook_entry 只有两个指针(16
字节),一条 cache line 可以容纳 4 个钩子。
NF_HOOK 宏:遍历入口
NF_HOOK()(netfilter.h)是协议栈调用
Netfilter 的入口:
static inline int NF_HOOK(uint8_t pf, unsigned int hook,
struct net *net, struct sock *sk,
struct sk_buff *skb,
struct net_device *in, struct net_device *out,
int (*okfn)(struct net *, struct sock *,
struct sk_buff *))Jump Label
快路径:当某个钩子点没有注册任何回调时,static_key
机制让 NF_HOOK 几乎零开销——编译器把检查替换为
NOP 指令,只在注册/注销时修改:
#ifdef CONFIG_JUMP_LABEL
extern struct static_key nf_hooks_needed[NFPROTO_NUMPROTO][NF_MAX_HOOKS];
/* 如果 static_key 为 false,直接调用 okfn(),1-2 个 CPU 周期 */
if (!static_key_false(&nf_hooks_needed[pf][hook]))
return okfn(net, sk, skb);
#endif遍历时,nf_hook_slow() 逐个调用
hooks[i].hook(),直到所有钩子返回
NF_ACCEPT 或某个钩子返回
NF_DROP。整个遍历在
rcu_read_lock()
保护下进行——允许并发注册/注销。
三、连接跟踪(conntrack)
nf_conn:连接对象
连接跟踪是 Netfilter 最重的子系统——它为每条连接维护一个
struct nf_conn(include/net/netfilter/nf_conntrack.h:75)对象:
struct nf_conn {
struct nf_conntrack ct_general; /* 引用计数 */
spinlock_t lock; /* 每连接锁 */
u32 timeout; /* 超时(jiffies32) */
/* 双向元组哈希 */
struct nf_conntrack_tuple_hash
tuplehash[IP_CT_DIR_MAX]; /* [0]=原方向, [1]=回复方向 */
unsigned long status; /* 连接状态位图 */
possible_net_t ct_net; /* 网络命名空间 */
struct nf_conn *master; /* 父连接(用于 expected) */
u_int32_t mark; /* 连接标记(iptables -j CONNMARK) */
u_int32_t secmark; /* 安全标记 */
struct nf_ct_ext *ext; /* 扩展(NAT、helper 等) */
union nf_conntrack_proto proto; /* L4 协议特定数据 */
};连接元组
struct nf_conntrack_tuple(nf_conntrack.h:37)唯一标识一条流:
struct nf_conntrack_tuple {
struct nf_conntrack_man src; /* 源:IP + 端口/类型 + L3 协议 */
struct {
union nf_inet_addr u3; /* 目的 IP */
union {
__be16 all;
struct { __be16 port; } tcp, udp, sctp;
struct { u_int8_t type, code; } icmp;
} u;
u_int8_t protonum; /* L4 协议号 */
u_int8_t dir; /* 方向 */
} dst;
};每个 nf_conn 存储两个方向的元组: -
tuplehash[IP_CT_DIR_ORIGINAL]:客户端 → 服务器
- tuplehash[IP_CT_DIR_REPLY]:服务器 →
客户端
两个方向的元组都插入同一个全局哈希表——查找时,不管包从哪个方向来,都能找到对应的连接。
哈希表实现
conntrack 哈希表是一个开链哈希表,大小由
nf_conntrack_hashsize 控制(默认约
65536):
哈希表大小:nf_conntrack_hashsize(可调)
最大连接数:nf_conntrack_max = hashsize × 2(默认)
桶锁数量:CONNTRACK_LOCKS = 1024(减少锁竞争)
查找路径:
nf_conntrack_find_get(net, zone, &tuple)
→ hash = hash_conntrack_raw(&tuple, zone, net)
→ bucket = hash % hashsize
→ 在 bucket 链中比较 tuple
→ 找到 → 增加引用计数,返回
→ 未找到 → 返回 NULL(需要创建新连接)
1024 个桶锁(不是每桶一锁)——锁粒度足够细以支持高并发,但不会浪费过多内存。
conntrack 在收发包路径中的位置
收包路径(PRE_ROUTING):
ip_rcv() → NF_HOOK(NF_INET_PRE_ROUTING, ...)
├── priority -400: conntrack_defrag → 重组 IP 分片
├── priority -200: nf_conntrack_in()
│ ├── 提取五元组 → nf_conntrack_tuple
│ ├── 哈希查找已有连接
│ │ ├── 找到 → skb->_nfct = ct(关联连接)
│ │ └── 未找到 → 创建 unconfirmed 连接
│ └── 更新 L4 状态(TCP 状态跟踪)
└── priority INT_MAX: nf_conntrack_confirm()
└── 将 unconfirmed 连接插入哈希表
发包路径(LOCAL_OUT + POST_ROUTING):
类似,但方向为 ORIGINAL
Unconfirmed
连接:新创建的连接不立即插入哈希表——等包完整通过所有钩子(没被
DROP)后,在 POST_ROUTING 的
nf_conntrack_confirm() 才确认。这避免了 DROP
包留下垃圾条目。
连接状态
| 状态 | 含义 | 典型场景 |
|---|---|---|
| NEW | 首包,连接刚创建 | SYN 包 |
| ESTABLISHED | 双向都有包 | SYN+ACK 后 |
| RELATED | 与已有连接关联的新连接 | FTP 数据连接 |
| INVALID | 不属于任何已知连接 | 状态不一致的包 |
GC 与超时
conntrack 条目通过超时自动过期。不同协议有不同的默认超时:
| 协议/状态 | 默认超时 | sysctl |
|---|---|---|
| TCP ESTABLISHED | 5 天 | nf_conntrack_tcp_timeout_established |
| TCP TIME_WAIT | 120 秒 | nf_conntrack_tcp_timeout_time_wait |
| UDP | 30 秒 | nf_conntrack_udp_timeout |
| UDP stream | 120 秒 | nf_conntrack_udp_timeout_stream |
| ICMP | 30 秒 | nf_conntrack_icmp_timeout |
GC 通过工作队列定期扫描过期条目。当表接近
nf_conntrack_max 时,GC
变得更激进——优先淘汰最近未使用的连接。
四、NAT 转换路径
NAT 类型
| 类型 | Hook 点 | 操作 |
|---|---|---|
| DNAT | PRE_ROUTING / LOCAL_OUT | 修改目的 IP/端口 |
| SNAT | POST_ROUTING / LOCAL_IN | 修改源 IP/端口 |
| MASQUERADE | POST_ROUTING | SNAT + 自动选择出接口 IP |
| REDIRECT | PRE_ROUTING | DNAT 到本机端口 |
NAT 与 conntrack 的关系
NAT 依赖 conntrack——只有首包执行 NAT 规则查找和转换,后续包根据 conntrack 条目自动转换:
首包(NEW 状态):
nf_nat_setup_info(ct, range, maniptype)
├── 选择新的 IP/端口
│ └── 避免与已有 NAT 映射冲突
├── 修改 ct->tuplehash[REPLY] 的元组
│ └── 回复方向的源 → 成为 DNAT 的原始目的
└── 标记 ct->status |= IPS_NAT_DONE_MASK
后续包(ESTABLISHED 状态):
nf_nat_packet(ct, ctinfo, hooknum, skb)
├── 根据方向和类型确定修改哪些字段
└── nf_nat_manip_pkt(skb, ct, mtype, dir)
└── 直接修改包头(IP + L4 checksum)
HOOK2MANIP 宏
#define HOOK2MANIP(hooknum) \
((hooknum) != NF_INET_POST_ROUTING && \
(hooknum) != NF_INET_LOCAL_IN)| hooknum | HOOK2MANIP | 含义 |
|---|---|---|
| PRE_ROUTING | 1 (DST) | DNAT:修改目的 |
| LOCAL_IN | 0 (SRC) | SNAT 回程:修改源 |
| FORWARD | 1 (DST) | — |
| LOCAL_OUT | 1 (DST) | DNAT:本机重定向 |
| POST_ROUTING | 0 (SRC) | SNAT:修改源 |
MASQUERADE 的特殊性
MASQUERADE 是 SNAT 的动态版本——源 IP
自动选择出接口的当前地址。当接口 IP 变化(DHCP
重新分配)时,MASQUERADE
自动失效旧连接。nf_conn_nat->masq_index
记录绑定的接口索引。
五、nf_tables 架构
与 iptables 的核心差异
iptables 的规则存储为链表,每条规则是一个
struct ipt_entry 加 match/target 数组。问题:
1.
规则更新需要整表替换(iptables-restore),O(n)
复杂度 2. 内存布局不紧凑,cache 命中率低 3. match/target
是独立内核模块,函数调用开销大
nf_tables 的架构改进:
表和链
struct nft_table(include/net/netfilter/nf_tables.h:1283):
struct nft_table {
struct rhltable chains_ht; /* 链的哈希表——O(1) 查找 */
struct list_head chains; /* 链列表 */
struct list_head sets; /* 集合列表 */
u64 hgenerator; /* 句柄自增器 */
u16 family:6, flags:8, genmask:2; /* 协议族和代际掩码 */
};struct nft_chain(nf_tables.h:1113):
struct nft_chain {
struct nft_rule_blob __rcu *blob_gen_0; /* 第 0 代规则 blob */
struct nft_rule_blob __rcu *blob_gen_1; /* 第 1 代规则 blob */
struct list_head rules; /* 控制面规则列表 */
u64 handle:42, genmask:2;
};Generation-based 无锁更新
nftables 的核心创新:双代(generation)更新。
数据面(RCU 读) 控制面(更新)
────────────── ──────────────
当前代=0 读 blob_gen_0 修改 blob_gen_1
│ │
切换代: 当前代=1 │
│ │
读 blob_gen_1 修改 blob_gen_0(旧代回收)
规则更新时: 1. 在非当前代的 blob
上修改规则 2. 原子切换当前代(genmask 翻转) 3.
等待旧代的所有 RCU 读者退出 4. 回收旧代内存
数据面始终读当前代——无锁、无等待。对比 iptables 的全表替换,这是质的飞跃。
Blob 化规则存储
struct nft_rule_blob(nf_tables.h:1088)把一条链的所有规则紧凑排列在连续内存中:
struct nft_rule_blob {
unsigned long size; /* 总大小 */
unsigned char data[]; /* 连续的 nft_rule_dp 数组 */
};
struct nft_rule_dp {
u64 is_last:1, dlen:12, handle:42; /* 紧凑位域 */
unsigned char data[]; /* 表达式字节码 */
};所有规则在一块连续内存中——遍历时 CPU prefetcher 可以高效预取,cache 命中率远高于链表。
表达式求值
nftables 的规则由表达式(expression)序列组成:
struct nft_expr {
const struct nft_expr_ops *ops; /* 表达式类型 */
unsigned char data[]; /* 类型特定数据 */
};表达式在寄存器(struct nft_regs,20
个 u32 寄存器)上操作:
规则求值示例:ip daddr 10.0.0.0/24 tcp dport 80 accept
nft_payload_eval() → 从包头加载 dst IP 到 reg[0]
nft_cmp_eval() → 比较 reg[0] 与 10.0.0.0/24
nft_payload_eval() → 从包头加载 dst port 到 reg[0]
nft_cmp_eval() → 比较 reg[0] 与 80
nft_immediate_eval() → 设置 verdict = NF_ACCEPT
集合(Set)
nftables 的 set 支持多种后端实现——根据元素数量和类型自动选择:
| 后端 | 用途 | 复杂度 |
|---|---|---|
| hash | 精确匹配大集合 | O(1) |
| rbtree | 区间匹配(端口范围、IP 范围) | O(log n) |
| bitmap | 小范围连续值(0-65535 端口) | O(1) |
| pipapo | 多维匹配(IP + 端口组合) | O(n × fields) |
六、性能调优要点
conntrack 表大小
# 查看当前连接数
conntrack -C
cat /proc/sys/net/netfilter/nf_conntrack_count
# 查看最大值
cat /proc/sys/net/netfilter/nf_conntrack_max
# 调整(每条约 288 字节)
sysctl -w net.netfilter.nf_conntrack_max=262144
sysctl -w net.netfilter.nf_conntrack_hashsize=65536经验法则:nf_conntrack_max = hashsize × 2,hashsize
取 2 的幂。
NOTRACK 旁路
对于不需要连接跟踪的高吞吐流量(如 DNS 转发、负载均衡器直连),用 raw 表跳过 conntrack:
# iptables
iptables -t raw -A PREROUTING -d 10.0.0.0/8 -p udp --dport 53 -j NOTRACK
# nftables
nft add rule ip raw prerouting ip daddr 10.0.0.0/8 udp dport 53 notrackraw 表在优先级 -300 执行,conntrack 在 -200——NOTRACK 标记的包完全跳过连接跟踪和 NAT,大幅降低 CPU 开销。
Jump Label 零开销
如果某个钩子点完全没有注册任何回调(比如没有 Netfilter
规则的纯路由器),static_key 让
NF_HOOK 编译为一条 NOP + 直接调用
okfn()——1-2 个 CPU 周期,几乎不存在。
七、可观测性实战
conntrack 事件监控
# 实时监控连接创建/销毁
conntrack -E
# 按协议统计
conntrack -L -p tcp | wc -l
conntrack -L -p udp | wc -lconntrack 表满检测
# 追踪 nf_conntrack_max 限制导致的丢包
bpftrace -e '
tracepoint:net:nf_conntrack_drop {
printf("conntrack DROP! reason=%d\n", args->reason);
@drops = count();
}
interval:s:10 { print(@drops); clear(@drops); }'
# 或监控内核日志
dmesg | grep "nf_conntrack: table full"Netfilter 钩子延迟
# 测量 PRE_ROUTING 钩子链的总耗时
bpftrace -e '
kprobe:nf_hook_slow {
@start[tid] = nsecs;
}
kretprobe:nf_hook_slow /@start[tid]/ {
@hook_ns = hist(nsecs - @start[tid]);
delete(@start[tid]);
}'NAT 映射统计
# 查看 NAT 映射
conntrack -L --src-nat
conntrack -L --dst-nat
# 统计 NAT 端口分配失败
bpftrace -e '
kretprobe:nf_nat_setup_info /retval != 0/ {
printf("NAT setup failed: %d\n", retval);
@nat_failures = count();
}'nftables 规则命中统计
# 查看每条规则的计数器
nft list ruleset -a
nft list chain ip filter input
# 追踪规则匹配
nft add rule ip filter input counter八、关键参数速查
| 参数 | 默认值 | 含义 | 调优建议 |
|---|---|---|---|
nf_conntrack_max |
~65536 | 最大连接跟踪数 | 高并发增大到 262144+ |
nf_conntrack_hashsize |
~16384 | 哈希桶数 | max / 2,取 2 的幂 |
nf_conntrack_tcp_timeout_established |
432000 (5天) | TCP 连接超时 | 短连接场景可减小 |
nf_conntrack_udp_timeout |
30 | UDP 首包超时 | DNS 场景可减小 |
nf_conntrack_udp_timeout_stream |
120 | UDP 双向超时 | 按需调整 |
nf_conntrack_tcp_timeout_time_wait |
120 | TIME_WAIT 超时 | 通常不需调整 |
nf_conntrack_expect_max |
256 | 最大 expect 条目 | FTP/SIP 场景增大 |
nf_conntrack_tcp_loose |
1 | 宽松 TCP 跟踪 | 严格环境设为 0 |
nf_conntrack_acct |
0 | 启用流量计账 | 按需开启 |
参考文献
- Linux
内核源码,
include/linux/netfilter.h,6.8(nf_hook_state 于第 78 行,nf_hook_ops 于第 97 行,nf_hook_entries 于第 119 行) - Linux
内核源码,
include/net/netfilter/nf_conntrack.h,6.8(nf_conn 于第 75 行) - Linux
内核源码,
include/net/netfilter/nf_nat.h,6.8(HOOK2MANIP 于第 18 行) - Linux
内核源码,
include/net/netfilter/nf_tables.h,6.8(nft_table 于第 1283 行,nft_chain 于第 1113 行) - Linux
内核源码,
include/uapi/linux/netfilter_ipv4.h,6.8(优先级常量于第 30 行) - Pablo Neira Ayuso, “nftables - the new packet classification framework”, Netfilter Workshop, 2014
- Linux
内核文档,
Documentation/networking/nf_conntrack-sysctl.rst
下一篇:Traffic Control 深度拆解:qdisc、class 与 filter
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Kubernetes 网络深度系列】Netfilter 与 iptables:Linux 防火墙的灵魂
Netfilter 五个 hook 点、四表五链的真实遍历顺序、conntrack 状态机与性能开销、SNAT/DNAT/MASQUERADE 辨析,再到 nftables 替代方案和 eBPF 数据面——从内核视角拆解 Linux 防火墙。
【Linux 网络子系统深度拆解】IP 层内核实现:路由查找、分片与转发
IP 层是 Linux 网络栈的中枢——收包时决定本地投递还是转发,发包时查路由、过 Netfilter、做分片。本文从 Linux 6.6 内核源码出发,拆解 ip_rcv → 路由决策 → ip_local_deliver / ip_forward 的完整路径,深入 FIB 表的 LC-trie 实现、策略路由 ip rule 选表机制、IP 分片/重组状态机、PMTU 发现与 FNHE 缓存,以及 Netfilter 五个钩子点的实际调用时机。
【Kubernetes 网络深度系列】Linux 网络栈全景:一个包从网卡到用户态的完整旅程
从 NIC 驱动到用户态 read(),一个网络包在 Linux 内核中到底经历了什么?本文拆解 sk_buff、NAPI、softirq、netfilter 的完整收包路径,并用 bpftrace 实测追踪每一跳的延迟。
【Kubernetes 网络深度系列】eBPF 与 Netfilter 的安全能力对比:谁是更好的防火墙
iptables、nftables、eBPF 三种防火墙机制的架构差异、性能对比与迁移路径