容器 A 的 IP 是 10.0.0.2,属于私有网段。它去 ping 8.8.8.8,包却能正常回来——可公网回包的目的地址写的是容器私有 IP,互联网路由根本到不了 10.0.0.0/24。这中间的"偷梁换柱"就是 NAT,而 NAT 是挂在 Netfilter 钩子框架上的。
前面三篇讲了命名空间隔离(netns)、点对点通道(veth pair)、二层交换(Linux Bridge)。这一篇把栈再往上推一层:包离开桥、进入 IP 路由与 Netfilter 之后,内核究竟做了什么,才让一个私有地址的容器能借宿主的公网 IP 出网。
读完你应该能回答:为什么 iptables -t nat -A POSTROUTING -j MASQUERADE一条规则就够了,它背后改了什么、又靠什么把回包正确送回容器。
一、Netfilter 的五道钩子
Netfilter 是内核协议栈里一组"埋点"。IPv4 在五个固定位置各留了一个钩子点,用枚举 NF_INET_*标识(定义于 include/uapi/linux/netfilter.h):
| | |
|---|
NF_INET_PRE_ROUTING | | net/ipv4/ip_input.c |
NF_INET_LOCAL_IN | | ip_local_deliver() |
NF_INET_FORWARD | | net/ipv4/ip_forward.c |
NF_INET_LOCAL_OUT | | __ip_local_out() |
NF_INET_POST_ROUTING | | net/ipv4/ip_output.c |
每个钩子点不是单一函数,而是一串按优先级排序的回调。一个模块(iptables / nftables / conntrack / NAT)想在某个钩子点插手,就填一个 struct nf_hook_ops:
structnf_hook_ops { nf_hookfn *hook; // 回调函数structnet_device *dev;// 限定网卡(可空)void *priv;u_int8_t pf; // 协议族,PF_INETunsignedint hooknum; // NF_INET_*int priority; // NF_IP_PRI_*,数值小先执行};
注册走 nf_register_net_hook(net, ops)(net/netfilter/core.c)。内核用 RCU 保护的数组 struct nf_hook_entries存每个钩子点的所有 ops,按 priority升序排好。
包走到埋点时,协议栈不是直接调钩子,而是走 NF_HOOK宏;若钩子点有注册的 ops,就进入 nf_hook_slow(),逐个调用:
unsignedintnf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state){for (i = 0; i < entries->num_hook_entries; i++) { verdict = nf_hook_entry_hookfn(&entries->hooks[i], skb, state);switch (verdict) {case NF_ACCEPT: continue; // 交给下一个钩子case NF_DROP: kfree_skb(skb); return NF_DROP; // 丢包case NF_STOLEN: return NF_STOLEN; // 包被钩子接管,协议栈不再处理case NF_QUEUE: ... // 交用户态(nfqueue)case NF_REPEAT: i = -1; continue;// 从头再跑一遍 } }return NF_ACCEPT;}
关键点:**所有 iptables 链、conntrack、NAT,本质都是往这五个钩子点注册 nf_hook_ops**。优先级(include/uapi/linux/netfilter_ipv4.h的 NF_IP_PRI_*)决定了谁先谁后:
NF_IP_PRI_CONNTRACK_DEFRAG -400 // 分片重组NF_IP_PRI_RAW -300NF_IP_PRI_CONNTRACK -200 // conntrack 建/匹配NF_IP_PRI_MANGLE -150NF_IP_PRI_NAT_DST -100 // DNATNF_IP_PRI_FILTER 0 // filter 过滤NF_IP_PRI_SECURITY 50NF_IP_PRI_NAT_SRC 100 // SNAT / MASQUERADENF_IP_PRI_CONNTRACK_CONFIRM INT_MAX // 连接确认提交
下面这张图把五个钩子点在协议栈里的位置标出来:
Netfilter 五钩子在协议栈中的位置二、连接跟踪 conntrack:NAT 的"状态表"
NAT 是有状态的。SNAT 把 10.0.0.2:1234 → 8.8.8.8:53改成了 公网IP:54321,回包从 8.8.8.8:53 → 公网IP:54321回来时,内核必须知道"这其实该还原成 10.0.0.2:1234"。记录这个映射的,就是 conntrack。
conntrack 在 NF_INET_PRE_ROUTING(优先级 -200,比 NAT 早)就对每个包做查询或新建,核心入口是 nf_conntrack_in()(net/netfilter/nf_conntrack_core.c)。一条连接用 struct nf_conn表示(include/net/netfilter/nf_conntrack.h),核心是两个方向的 tuple:
structnf_conntrack_tuple {structnf_conntrack_mansrc;// 源:IP + 端口(manipulable)structnf_conntrack_tuple_dstdst;// 目的:IP + 端口// 隐含:协议号(TCP/UDP/ICMP)};// nf_conn 里:structnf_conntrack_tuple_hashtuplehash[IP_CT_DIR_MAX];// IP_CT_DIR_ORIGINAL = 原方向,IP_CT_DIR_REPLY = 反方向
tuple 本质就是五元组(源 IP、源端口、目的 IP、目的端口、协议),分原方向/反方向两条。查表时把包的五元组算成 tuple,去哈希表 nf_conntrack_hash里找;找不到且是合法新流就建一条,status打上 IPS_CONFIRMED等标志。
conntrack 的连接状态(enum ip_conntrack_status/ enum ip_conntrack_info)常见的有:
NEW:看到首个包,还没确认(PRE_ROUTING 阶段)ESTABLISHED:收到反方向包,双向都见过了RELATED:由已有连接派生(如 FTP 被动模式数据通道、ICMP 差错)INVALID:无法归入任何连接,通常被 DROP
连接确认由 nf_confirm()完成:转发/出网包在 NF_INET_POST_ROUTING(NF_IP_PRI_CONNTRACK_CONFIRM,优先级 INT_MAX,该钩子点最后一个)确认;本地投递包则在 NF_INET_LOCAL_IN同优先级确认,把新连接正式写进哈希表。这样设计是为了:万一包在确认之前被 DROP,就不会留下一条"幽灵连接"。
三、NAT 怎么挂在钩子上
NAT 同样是一组 nf_hook_ops,只是它改 skb,并把映射记进 nf_conn。
- SNAT(改源地址):注册在
NF_INET_POST_ROUTING,优先级 NF_IP_PRI_NAT_SRC = 100。容器出网用的就是它。 - DNAT(改目的地址):注册在
NF_INET_PRE_ROUTING与 NF_INET_LOCAL_OUT,优先级 NF_IP_PRI_NAT_DST = -100。外界访问宿主持有公网 IP、实际转发到容器时用到。
iptables 的 nat表规则最终由内核 NAT 模块在对应钩子点执行:它根据规则的 target 调 nf_nat_manip_pkt()真正改写 skb的源/目的 IP 与端口,并把"改了什么"存进 nf_conn的 NAT 扩展(struct nf_conn_nat里的 manip/ range)。之后这条流的每一个后续包(回包)都由 conntrack 匹配到同一条 nf_conn,在反向钩子点自动逆改写——你不必为回包再写一条规则。
MASQUERADE与 SNAT的区别只在"源 IP 取哪来":
SNAT --to-source 1.2.3.4:把源写死成 1.2.3.4,适合静态公网 IP。MASQUERADE:动态取出口网卡当前的 IP作为源,出口 IP 一变(DHCP / PPPoE 重拨)无需改规则,容器出网场景几乎都用它。
结论:容器互 ping 时帧在桥内 L2 直通、根本不进 IP 栈,所以默认碰不到任何 Netfilter 钩子;只有当包要"出桥进路由、做 NAT"时,才进入这一层。
下面这张图给出"出网 SNAT + 回程 DNAT"在钩子点上的配合:
出网 SNAT 与回程 DNAT 在钩子点的配合四、br_netfilter:让桥上的包也能过 iptables
第三篇留了个接口:容器出网那一下 NAT 在哪做?答案是 br_netfilter模块。
默认情况下,桥上的 IP 包在 br_handle_frame里就 L2 转发走了,连 IP 协议栈都没进,iptables 自然无从下手。但当容器要"出网"——包从容器经 veth 进 br0,再从宿主物理网卡 eth0出去——这条路径需要路由 + NAT,就必须让桥上的 IP 包也撞上 Netfilter 钩子。
br_netfilter(net/bridge/br_netfilter_hooks.c)做的事:在桥转发路径里调 br_nf_hook_thresh(),对桥上的 IP 包**再次调用 NF_HOOK()**(PREROUTING / POSTROUTING 等 IP 层钩子)。于是 iptables -t nat -A POSTROUTING ... -j MASQUERADE才能作用于"从桥出去"的包。
代价是那条著名的 sysctl:net.bridge.bridge-nf-call-iptables。它打开后,桥上所有 IP 包都过一遍 iptables,会引入额外的 conntrack 开销,还容易因 MTU / 分片处理与 bridge 行为耦合而出问题——这也是为什么很多精简容器网络会选择绕过它,改用纯路由或 eBPF 做 SNAT。
五、完整走查:容器 A 出网全过程
把前面四篇串起来,容器 A(10.0.0.2)ping 8.8.8.8的完整路径:
- 容器 A 构造 ICMP,从
eth0(veth 对端)发出 → veth 把 skb经 peer指针 forward 给宿主侧 vethA。 vethA是 br0的 slave,rx_handler = br_handle_frame截到包,在桥内 L2 转发,决定从宿主物理网卡方向出去。- 包进入 IP 栈
ip_rcv()→ PREROUTING:conntrack 建立 NEW连接(tuple 原方向 10.0.0.2 → 8.8.8.8)。 - 路由子系统判定这是转发包(目的不是本机)→ FORWARD。
ip_output()→ POSTROUTING:conntrack nf_confirm()确认写入;MASQUERADE把源 IP 10.0.0.2改成 eth0的公网 IP、源端口映射到 54321。- 回包
8.8.8.8 → 公网IP:54321进 eth0→ PREROUTING:conntrack 匹配到 ESTABLISHED流,DNAT 逆改写目的为 10.0.0.2:1234。 - 路由 → FORWARD→ 进
br0→ 桥内转发到 vethA→ 回到容器 A。
整条链路里,conntrack 是"胶水":它在最前面建连接,在最后面确认,并让回包自动逆 NAT——你只写了 POSTROUTING 一条 MASQUERADE,回程却无需再配。
容器 A 出网全过程时序六、动手验证清单
找一台跑了容器(bridge 网络)的机器:
# 1. 看 NAT 规则,确认 MASQUERADE 在 POSTROUTINGiptables -t nat -L POSTROUTING -n -v# 2. 看连接跟踪表,容器出网后会看到一条 ESTABLISHEDconntrack -L -s 10.0.0.2# 3. 确认 IP 转发已开(FORWARD 路径的前提)cat /proc/sys/net/ipv4/ip_forward# 4. 确认桥上的包会进 iptables(容器出网依赖)cat /proc/sys/net/bridge/bridge-nf-call-iptables# 5. 抓宿主机 eth0,验证出网包源 IP 已是公网 IPtcpdump -i eth0 -n 'icmp' &# 容器内:ping 8.8.8.8
小结
这一篇把"容器怎么出网"收了口:Netfilter 在协议栈五个埋点(NF_INET_*)挂一串按优先级排序的 nf_hook_ops;conntrack 在 PREROUTING 建连接、在 POSTROUTING 确认,用双向 tuple 记住五元组映射;NAT 在 POST_ROUTING 用 nf_nat_manip_pkt改写 skb 源地址并把映射存进 nf_conn,回包靠 conntrack 自动逆改写;br_netfilter让桥上的 IP 包也能撞上这些钩子,于是"从桥出去"的容器流量才做得了 MASQUERADE。
一句话:NAT 不神秘,它就是 conntrack 状态表 + 钩子点上的 skb 改写;你写的每一条 -j MASQUERADE,背后都是 PRE_ROUTING 建表、POST_ROUTING 改包、回程自动还原这三步。