SNAT 前(源头治理)|(边界治理)后限速

0 阅读9分钟

从网络架构的第一性原理出发, “为什么不普遍在 SNAT 之前限速” 并不是因为“不能”,而是因为在传统的 Linux 网络栈设计中,限速的目标内核钩子的位置存在天然的逻辑错位。

结论

不在 SNAT 之前限速,主要基于以下三个核心逻辑:

  1. 全局资源竞争原则:物理网卡(如 bond0)是最后的瓶颈点,在 SNAT 后限速可以直接保护物理带宽不被单机流量撑爆。
  2. 内核路径(Path of Packet)限制:标准的 tc egress 钩子位于 Netfilter 之后,此时 SNAT 已经完成,原始信息已丢失。
  3. 分布式 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)架构 下,这种做法正在被颠覆

事实:为什么现在提倡“提前限速”?
  1. CPU 保护(Noisy Neighbor) :在 bond0 限速只能保护带宽,但如果一个 Pod 疯狂发包,即使包被 bond0 丢弃,宿主机的 CPU 也会因为处理这些包(走完整个协议栈、完成 SNAT)而过载。
  2. 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 MetereBPF 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 发生地)。**


第一性原理分析:内核包走势与顺序

  1. 容器包进入主机:通过 veth 进入宿主机协议栈。
  2. 路由决策:确定出接口为 bond0
  3. Netfilter POSTROUTING (Mangle)此处是最后一次看到原始 Source IP 的机会。我们在这里识别“源为集群 CIDR,目的非集群 CIDR”并打上 fwmark
  4. Netfilter POSTROUTING (NAT) :执行 SNAT,源 IP 变为 bond0 的 IP。
  5. 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 精细化限速高性能大规模集群

事实与风险提示

  • 事实iptablesnat 表是“流敏感”的,只有首包会经过规则匹配,后续包通过 conntrack 自动处理。但 mangle 表的 POSTROUTING每个数据包都会执行。在高并发(PPS 极高)场景下,这会带来一定的 CPU 开销。
  • 准确性说明:如果你的 Calico 启用了 eBPF 模式(取代了 iptables),那么上述 iptables 规则将失效。在 eBPF 模式下,你应该直接在 calico-felix 配置中调整流量策略,或者直接操作 tc-bpf

限速的常见方式

要在 SNAT 之前进行限速本质上是要在数据包的源 IP 地址被修改为网关/主机 IP 之前,捕获并控制该流量。

核心结论

在 Linux 内核网络栈中,tcEgress(出口) 队列(qdisc)发生在 Netfilter 的 POSTROUTING 之后。这意味着当数据包到达物理网卡的 tc egress 时,SNAT 已经完成,源 IP 已变。

要实现在 SNAT 之前限速,通常有三种第一性原理路径

  1. 在源头接口的 Ingress 处限速如果流量来自虚拟网卡(veth/tap),在其进入宿主机网桥前的 Ingress 钩子处截获。
  2. 利用 Netfilter Mark (fwmark) 传递状态PREROUTING 链(NAT 前)根据原始 IP 打标记,tc 在出口根据标记限速。
  3. 使用 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,可以通过“打标签”的方式跨链传递身份。

实现步骤:

  1. 在 Mangle 表打标记:在 PREROUTING 链(此时源 IP 还是 10.177.0.3)给数据包打上标记。
  2. 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 内核网络路径:

  1. Packet In -> PREROUTING (DNAT在这里) -> Routing Decision
  2. Forward -> FORWARD
  3. Packet Out -> POSTROUTING (SNAT在这里) -> TC Egress (qdisc) -> Driver

结论:在包离开网卡前的最后一环才是 tc。如果你在 tc egress 直接匹配 IP,你匹配到的是 SNAT 转化后的 144.79.143.3。因此,要么提前处理(方案一),要么带标记处理(方案二)。