我之前被 snat 的逻辑限制住了。
关于 harpin-snat
就是 vpc 内的资源访问了 nat gw (pod)上的一个 eip:经过了 snat 和 dnat,然后又回到 vpc 内部。
hairpin 的含义就是源和目的本质上都是在 vpc 内,但是经过了 eip。
本质就一句话
Hairpin = VPC 内部流量经 NAT GW 上的 EIP 绕了一圈又回到 VPC 内部。
无论源和目的是同子网还是跨子网、走的是 FIP 还是 DNAT 还是 LB,底层都是同一件事:
VPC 内 Pod ──→ NAT GW 上的 EIP ──DNAT──→ VPC 内 Pod
只要 EIP 在这个 NAT GW 上,就可能发生 hairpin。
旧方案为什么有遗漏
旧方案把 hairpin 规则绑在了 add_snat / del_snat 上——只有创建 SNAT 规则时才顺便创建 hairpin 规则。但 EIP 被 NAT GW 持有不一定是因为 SNAT,FIP(纯 1:1 NAT)和 LB 也会把 EIP 挂到 NAT GW 上。SNAT 规则只是 EIP 存在的充分不必要条件,用它做触发点必然遗漏。
新方案为什么完备
Hairpin 规则直接绑在 add_eip / del_eip 上。EIP 是 hairpin 问题的充要条件——有 EIP 才可能 hairpin,没 EIP 就不可能 hairpin。所以:
- EIP 加上 → hairpin 规则加上
- EIP 删掉 → hairpin 规则删掉
不需要关心这个 EIP 是被 FIP、SNAT 还是 LB 使用的。一个触发点覆盖所有情况。
mangle 表是 iptables 五张表中专门修改报文元数据(不改包内容)的表
⏺ mangle 表详解
结论先行
mangle 表是 iptables 五张表中专门用于修改报文元数据(不改内容)的表,这里用它给从 VPC 接口进入的报文打 mark,供后续 nat 表做 hairpin SNAT 决策。这是标准做法,没有额外开销。
---
iptables 五表及 mangle 的位置
报文进入
│
▼
┌─────────┐
│ raw │ ← 连接追踪前,极少用
├─────────┤
│ conntrack│ ← 连接追踪(自动)
├─────────┤
│ mangle │ ← PREROUTING: 修改报文元数据(mark/TTL/TOS/DSCP)
├─────────┤
│ nat │ ← PREROUTING: DNAT
├─────────┤
│ filter │ ← FORWARD/INPUT: 放行/拒绝
├─────────┤
│ nat │ ← POSTROUTING: SNAT
├─────────┤
│ mangle │ ← POSTROUTING(也可用,但此 PR 没用)
└─────────┘
│
▼
报文离开
关键点:mangle PREROUTING 在 nat PREROUTING(DNAT)之前执行。这意味着报文被 DNAT 改写目的地址之前,mark 就已经打上了。
这个 PR 加了什么
# init 阶段
$iptables_cmd -t mangle -N VPC_MARK # 创建自定义链
$iptables_cmd -t mangle -A PREROUTING -j VPC_MARK # 挂到 PREROUTING
$iptables_cmd -t mangle -A VPC_MARK -i "$VPC_INTERFACE" -j MARK --set-xmark 0x1/0x1 # 打标记
含义:所有从 VPC 接口(面向内部 Pod/VM 的接口)进入 NAT 网关的报文,都被打上 fwmark 0x1。
从外部网络接口进入的报文不经过这条规则,mark 为 0。
为什么需要这个 mark
看 hairpin SNAT 规则的完整条件:
-m mark --mark 0x1/0x1 # ① 来自 VPC 内部
-o $VPC_INTERFACE # ② 要从 VPC 接口出去(回送内部)
-m conntrack --ctstate DNAT # ③ 被 DNAT 过(原始目标不是内部 IP)
--ctorigdst $eip # ④ 原始目标是这个 EIP
-j SNAT --to-source $eip # → SNAT 源地址为 EIP
如果没有 ① 的 mark 条件,从外部网络进来的正常 DNAT 流量(外部客户端 → EIP → 内部 Pod)也会被错误地 SNAT。这些流量本来就是正常的、不需要 hairpin 处理的——它们的回程路径天然经过 NAT
网关(因为外部客户端不在 VPC 内)。
用一张表对比:
┌──────────────────────────────┬──────────┬──────┬───────────┬───────────────────┐
│ 流量方向 │ 入接口 │ mark │ 是否 DNAT │ 需要 hairpin? │
├──────────────────────────────┼──────────┼──────┼───────────┼───────────────────┤
│ 内部 Pod → EIP → 内部 Pod │ VPC │ 0x1 │ 是 │ 是 ← 只 SNAT 这个 │
├──────────────────────────────┼──────────┼──────┼───────────┼───────────────────┤
│ 外部客户端 → EIP → 内部 Pod │ External │ 0 │ 是 │ 否 │
├──────────────────────────────┼──────────┼──────┼───────────┼───────────────────┤
│ 内部 Pod → 外部(普通 SNAT) │ VPC │ 0x1 │ 否 │ 否 │
└──────────────────────────────┴──────────┴──────┴───────────┴───────────────────┘
mark 是区分前两种流量的唯一手段——它们在 nat POSTROUTING 阶段看起来几乎一样(都是 DNAT 过的、都要从 VPC 接口出去),但来源不同。
mangle 表的通用能力
┌──────────┬──────────────────────────────┬─────────────────────────────┐
│ 操作 │ 用途 │ 示例 │
├──────────┼──────────────────────────────┼─────────────────────────────┤
│ MARK │ 给报文打内核 fwmark │ 策略路由、条件 NAT(本 PR) │
├──────────┼──────────────────────────────┼─────────────────────────────┤
│ CONNMARK │ 标记整个连接(比 MARK 持久) │ 连接级别的路由决策 │
├──────────┼──────────────────────────────┼─────────────────────────────┤
│ TOS/DSCP │ 修改服务类型字段 │ QoS 优先级 │
├──────────┼──────────────────────────────┼─────────────────────────────┤
│ TTL │ 修改 TTL │ 防止 traceroute 探测 │
├──────────┼──────────────────────────────┼─────────────────────────────┤
│ TCPMSS │ 修改 TCP MSS │ 解决 PMTU 黑洞问题 │
└──────────┴──────────────────────────────┴─────────────────────────────┘
这些操作都不改报文的源/目的 IP(那是 nat 表的工作),只改元数据或内核状态。
性能影响
几乎为零。-i "$VPC_INTERFACE" 只匹配特定接口的报文,--set-xmark 是纯内核态操作(设置 skb->mark 字段),不涉及内存拷贝或报文修改。这是 Linux 网络栈中最轻量的操作之一。
当前的改动是面向 目的 eip 的,我之前的思路是按照源地址的。
⏺ 对,这就是这个 PR 的核心改进点。
之前的方案:基于源 CIDR(你的思路)
# 旧规则:按源/目的子网匹配
-A HAIRPIN_SNAT -s 10.0.1.0/24 -d 10.0.1.0/24 -m conntrack --ctstate DNAT -j SNAT --to-source $eip
局限:
1. 只能处理同子网 hairpin——-s CIDR -d CIDR 要求源和目的在同一子网
2. 跨子网不生效——VPC 内两个子网(如 10.0.1.0/24 和 10.0.2.0/24)共用一个 NAT 网关时,子网 A 的 Pod 通过 EIP 访问子网 B 的 Pod,源和目的 CIDR 不同,匹配不上
3. 依赖 SNAT 规则存在——hairpin 规则的生命周期绑定在 SNAT 规则上,但 FIP(只有 DNAT,不一定有对应 SNAT)和 LoadBalancer 场景下可能没有 SNAT 规则,就没有 hairpin
4. 需要 is_internal_cidr() 判断——额外维护一个函数来决定是否需要 hairpin
新方案:基于目的 EIP
# 新规则:按 mark + 原始目的 EIP 匹配
-m mark --mark 0x1/0x1 -o $VPC_INTERFACE -m conntrack --ctstate DNAT --ctorigdst $eip -j SNAT --to-source $eip
关键转换:不再关心"流量从哪个子网来",只关心"流量原始目标是不是 EIP"。
这样一条规则就覆盖了所有场景:
┌───────────────────────────────────────┬────────────────────────┬───────────────────────┐
│ 场景 │ 旧方案 │ 新方案 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ 同子网 Pod → FIP/EIP → 同子网 Pod │ 需要 SNAT 规则触发 │ mark + ctorigdst 匹配 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ 跨子网 Pod → FIP/EIP → 另一子网 Pod │ 不支持(CIDR 不同) │ mark + ctorigdst 匹配 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ Pod → LoadBalancer VIP(DNAT 到内部) │ 不支持(无 SNAT 规则) │ mark + ctorigdst 匹配 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ Pod → FIP(无对应 SNAT 规则) │ 不支持 │ mark + ctorigdst 匹配 │
└───────────────────────────────────────┴────────────────────────┴───────────────────────┘
思路差异的本质
- 按源 CIDR:从"谁发的"角度出发,需要枚举所有可能的源-目的组合
- 按目的 EIP:从"问题根因"出发——hairpin 问题只在流量目标是 EIP 时才发生,与源地址无关
后者更接近问题的本质,所以一条规则就够了,不需要 is_internal_cidr 这类辅助判断。
❯ 这样一条规则就覆盖了所有场景:
┌───────────────────────────────────────┬────────────────────────┬───────────────────────┐
│ 场景 │ 旧方案 │ 新方案 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ 同子网 Pod → FIP/EIP → 同子网 Pod │ 需要 SNAT 规则触发 │ mark + ctorigdst 匹配 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ 跨子网 Pod → FIP/EIP → 另一子网 Pod │ 不支持(CIDR 不同) │ mark + ctorigdst 匹配 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ Pod → LoadBalancer VIP(DNAT 到内部) │ 不支持(无 SNAT 规则) │ mark + ctorigdst 匹配 │
├───────────────────────────────────────┼────────────────────────┼───────────────────────┤
│ Pod → FIP(无对应 SNAT 规则) │ 不支持 │ mark + ctorigdst 匹配 │
└───────────────────────────────────────┴────────────────────────┴───────────────────────┘