从网络架构的第一性原理出发, “为什么不普遍在 SNAT 之前限速” 并不是因为“不能”,而是因为在传统的 Linux 网络栈设计中,限速的目标与内核钩子的位置存在天然的逻辑错位。
结论
不在 SNAT 之前限速,主要基于以下三个核心逻辑:
- 全局资源竞争原则:物理网卡(如
bond0)是最后的瓶颈点,在 SNAT 后限速可以直接保护物理带宽不被单机流量撑爆。 - 内核路径(Path of Packet)限制:标准的
tc egress钩子位于 Netfilter 之后,此时 SNAT 已经完成,原始信息已丢失。 - 分布式 vs 集中式管理:在 SNAT 前限速(如在 veth 上)属于**“源头治理”,适合多租户隔离;在 SNAT 后限速属于“边界治理”**,适合保护基础设施。
1. 物理瓶颈的“最后一公里”逻辑
在第一性原理中,限速(Shaping)必须发生在瓶颈点。
- SNAT 之前:流量分散在成百上千个 Pod 的
veth接口上。虽然你可以限制单个 Pod 的速率,但你无法通过简单的规则防止所有 Pod 的流量总和超过bond0的物理上限(例如 10Gbps)。 - SNAT 之后:所有流量聚合在
bond0。在这里限速,是保护物理链路最直接、最准确的位置。
2. 内核处理路径的“单向性”
数据包在 Linux 内核中的流动具有严格的先后顺序:
- TC Egress (qdisc) 位于网卡驱动层的前一步,它是内核网络栈的出口总闸门。
- SNAT (Netfilter POSTROUTING) 发生在三层路由之后,但在进入总闸门之前。
- 矛盾点:当你站在“总闸门”(
bond0的 qdisc)时,回头看包,包头已经被 NAT 修改过了。如果你想在总闸门识别原始 IP,就必须依赖 fwmark 这种跨层传递的“纸条”。
也就是说,snat 前后,限速的对象不是同一个网卡。
3. 计算开销与上下文丢失
- 上下文丢失:在
bond0上,内核只需处理一个网卡的队列。如果在 SNAT 前限速,内核需要管理成千上万个虚拟网卡(veth)的队列状态。 - 内存碎片化:每个限速队列(qdisc)都需要占用内核内存。维护 1000 个 Pod 的独立队列,比维护一个物理网卡的分类队列(HTB Class)开销更大。
事实与推测:现状正在发生变化
虽然传统做法偏向 SNAT 后限速,但在 云原生(K8s)架构 下,这种做法正在被颠覆:
事实:为什么现在提倡“提前限速”?
- CPU 保护(Noisy Neighbor) :在
bond0限速只能保护带宽,但如果一个 Pod 疯狂发包,即使包被bond0丢弃,宿主机的 CPU 也会因为处理这些包(走完整个协议栈、完成 SNAT)而过载。 - eBPF 的崛起:利用 eBPF (Cilium/Calico eBPF),我们可以在
tc ingress(数据包刚从容器出来进入主机的那一刻)甚至XDP层级就直接识别并丢弃过量流量。这实现了极早期的 SNAT 前限速,完全避免了后续协议栈的浪费。
逻辑对比表
| 维度 | SNAT 后限速 (传统 tc) | SNAT 前限速 (Kube-OVN/eBPF) |
|---|---|---|
| 保护目标 | 物理链路带宽 | 宿主机 CPU + 物理链路 |
| 精确度 | 高(针对总出口) | 高(针对单个租户/Pod) |
| 实现难度 | 简单(单网卡配置) | 复杂(需同步成千上万个规则) |
| 性能损耗 | 较低(聚合处理) | 极低(如果使用 eBPF/XDP) |
总结
不在 SNAT 之前限速,是因为 Linux 经典的 TC 工具集(u32 匹配等)在设计之初,主要关注的是如何管理单一网卡的出口调度。
但在你熟悉的 Kube-OVN 或 Cilium 领域,通过 OVS Meter 或 eBPF Map,我们其实一直在“明知不可为而为之”——即在 SNAT 之前、在流量最靠近源头的地方将其拦截。
calico 默认 snat 场景
比如 calico 默认 snat 场景,出了 host veth 之后,在 走默认路由的 bond0 的snat 之前识别源ip属于集群内网段,目的ip部署于集群内网段,进行限速
在 Calico 默认的 SNAT 场景下(通常使用 iptables 模式),要实现在 bond0 上针对原始容器 IP 限速,最核心的问题是:Linux 流量控制(TC)在内核路径中位于 SNAT 之后。
结论
在 bond0 的出口(Egress)直接匹配 Source IP 是无效的,因为此时包头已被修改为主机 IP。
推荐方案:利用 Netfilter Mangle 表的 POSTROUTING 链打标记(Mark),然后在 bond0 上根据标记限速。
这是因为在内核路径中,mangle 表的 POSTROUTING 钩子触发顺序优于** nat 表的 POSTROUTING(SNAT 发生地)。**
第一性原理分析:内核包走势与顺序
- 容器包进入主机:通过
veth进入宿主机协议栈。 - 路由决策:确定出接口为
bond0。 - Netfilter POSTROUTING (Mangle) :此处是最后一次看到原始 Source IP 的机会。我们在这里识别“源为集群 CIDR,目的非集群 CIDR”并打上
fwmark。 - Netfilter POSTROUTING (NAT) :执行 SNAT,源 IP 变为
bond0的 IP。 - TC Egress (qdisc) :包到达网卡队列。
tc无法读取原始 IP,但可以读取包头中的fwmark。
具体实现步骤
假设集群 CIDR 为 10.244.0.0/16,你需要限制该网段访问外网的速率为 100mbit。
1. 在 Mangle 表识别并标记流量
在 SNAT 发生前,根据原始五元组信息打上标记(例如标记为 0x64,即十进制 100)。
Bash
# 识别:源 IP 是集群内部,目的 IP 不是集群内部,且从 bond0 发出
iptables -t mangle -A POSTROUTING -o bond0 \
-s 10.244.0.0/16 ! -d 10.244.0.0/16 \ # 可以基于具体的 源 ip 打上不同(类)的 mark
-j MARK --set-mark 100
2. 在物理网卡 bond0 上配置 HTB 队列
建立分类树,将标记为 100 的流量送入特定的限速通道。
Bash
# 1. 在 bond0 创建 HTB 根节点
tc qdisc add dev bond0 root handle 1: htb default 30
# 2. 创建一个限速类(例如限制到 100mbit)
tc class add dev bond0 parent 1: classid 1:10 htb rate 100mbit ceil 100mbit
# 3. 创建过滤器:匹配 fwmark 100,并将其定向到 class 1:10
tc filter add dev bond0 protocol ip parent 1:0 prio 1 handle 100 fw flowid 1:10
方案优劣对比
| 维度 | Mangle + TC 方案 | Veth Ingress 方案 | eBPF (Cilium) 方案 |
|---|---|---|---|
| 限速位置 | 物理网卡总出口 | 容器虚拟网卡入口 | 内核 XDP/TC 钩子 |
| 识别原始 IP | 支持(通过 Mangle 提前捕获) | 支持 | 支持 |
| 性能损耗 | 中(需经过 Netfilter 全路径) | 低 | 极低 |
| 适用场景 | 需要在物理出口做聚合限速 | 针对单个 Pod 精细化限速 | 高性能大规模集群 |
事实与风险提示
- 事实:
iptables的nat表是“流敏感”的,只有首包会经过规则匹配,后续包通过conntrack自动处理。但mangle表的POSTROUTING对每个数据包都会执行。在高并发(PPS 极高)场景下,这会带来一定的 CPU 开销。 - 准确性说明:如果你的 Calico 启用了 eBPF 模式(取代了 iptables),那么上述
iptables规则将失效。在 eBPF 模式下,你应该直接在calico-felix配置中调整流量策略,或者直接操作tc-bpf。
限速的常见方式
要在 SNAT 之前进行限速,本质上是要在数据包的源 IP 地址被修改为网关/主机 IP 之前,捕获并控制该流量。
核心结论
在 Linux 内核网络栈中,tc 的 Egress(出口) 队列(qdisc)发生在 Netfilter 的 POSTROUTING 之后。这意味着当数据包到达物理网卡的 tc egress 时,SNAT 已经完成,源 IP 已变。
要实现在 SNAT 之前限速,通常有三种第一性原理路径:
- 在源头接口的 Ingress 处限速:如果流量来自虚拟网卡(veth/tap),在其进入宿主机网桥前的 Ingress 钩子处截获。
- 利用 Netfilter Mark (fwmark) 传递状态:在
PREROUTING链(NAT 前)根据原始 IP 打标记,tc在出口根据标记限速。 - 使用 IFB (Intermediate Functional Block) :将物理网卡的 Ingress 流量重定向到虚拟设备,在重定向过程中保留原始信息。
方案一:在虚拟网卡(veth)的 Ingress 处限速 (推荐用于 K8s/容器)
这是 Kube-OVN 或 Cilium 常见的做法。数据包从 Pod 发出进入宿主机时,首先经过宿主机端的 veth 接口。
- 逻辑:Pod 的 Egress = 宿主机端 veth 的 Ingress。
- 优点:完全在 SNAT 逻辑之前,且不影响宿主机其他流量。
Bash
# 1. 在 veth 接口添加 ingress qdisc
tc qdisc add dev veth_pod0 ingress
# 2. 使用 filter 和 police 模块限速 (例如限制为 10mbit)
tc filter add dev veth_pod0 parent ffff: \
protocol ip prio 1 u32 match ip src 10.177.0.3/32 \
police rate 10mbit burst 100kb drop flowid :1
方案二:利用 fwmark 绕过 SNAT 后的识别难题
如果必须在物理网卡(如 eth0)的出口限速,但此时源 IP 已经变成了主机的外网 IP,可以通过“打标签”的方式跨链传递身份。
实现步骤:
- 在 Mangle 表打标记:在
PREROUTING链(此时源 IP 还是 10.177.0.3)给数据包打上标记。 - TC 根据标记分类:在出口网卡根据标记进行带宽塑造。
Bash
# 1. 标记来自特定内网 IP 的包 (NAT 前)
iptables -t mangle -A PREROUTING -s 10.177.0.3 -j MARK --set-mark 10
# 2. 在物理网卡创建 HTB 树
tc qdisc add dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbit
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 10mbit
# 3. 将标记为 10 的包映射到 1:10 分类
tc filter add dev eth0 protocol ip parent 1:0 prio 1 handle 10 fw flowid 1:10
方案三:使用 IFB 设备
如果你需要对进入物理网卡的流量(Ingress)进行复杂的队列管理(如使用 FQ_CoDel 或 HTB,这些通常只能用于 Egress),可以使用 IFB。
Bash
# 加载 ifb 模块并启动
modprobe ifb numifbs=1
ip link set dev ifb0 up
# 将 eth0 的 ingress 流量重定向到 ifb0
tc qdisc add dev eth0 handle ffff: ingress
tc filter add dev eth0 parent ffff: protocol ip u32 match u32 0 0 \
action mirred egress redirect dev ifb0
# 在 ifb0 上进行正常的限速逻辑(此时也是 SNAT 前)
tc qdisc add dev ifb0 root htb ...
第一性原理分析:为什么 tc egress 拿不到原始 IP?
根据 Linux 内核网络路径:
- Packet In ->
PREROUTING(DNAT在这里) ->Routing Decision - Forward ->
FORWARD链 - Packet Out ->
POSTROUTING(SNAT在这里) -> TC Egress (qdisc) ->Driver
结论:在包离开网卡前的最后一环才是 tc。如果你在 tc egress 直接匹配 IP,你匹配到的是 SNAT 转化后的 144.79.143.3。因此,要么提前处理(方案一),要么带标记处理(方案二)。