一个 Pod 吃掉整节点带宽?K8s 限速 annotation 无效——TC/CNI 流量整形真相
场景:CI/CD Runner Pod 与生产服务混部,构建任务下载依赖占满节点出口带宽 路径:Pod 延迟确认 → 逐层追带宽(Pod→Node→CNI→tc)→ 排查命令 → annotation 无效真相 → 限速修复 + Check-list 时长:~12 分钟 以下排查基于 K8s v1.28、Calico v3.27、Linux kernel 5.15
上篇讲了 CoreDNS 自动扩缩容参数设反导致 DNS 间歇超时,这篇来看另一个更容易被忽视的节点级问题——带宽。
我盯着监控面板看了 10 分钟——CPU 25%,内存 30%,Pod 全 Running——但用户说页面打不开。一个 CI runner Pod 跑构建任务时疯狂下载依赖包,同一节点的 Web 服务 P99 从 20ms 飙到 5s。"资源不够吧?"kubectl top pod 一看 CPU 内存都正常。问题不在计算资源,在网络带宽——K8s 有 CPU/Memory 调度,但没有"带宽调度"这个概念。
场景:CI/CD Runner Pod 与生产服务混部,构建任务下载依赖占满节点出口带宽 路径:Pod 延迟确认 → 逐层追带宽(Pod→Node→CNI→tc)→ 排查命令 → annotation 无效真相 → 限速修复 + Check-list
构建任务一启动,同节点上其他 Pod 的接口响应就飙升。
# 同节点跨 Pod 延迟测试
$ kubectl exec -n production web-pod-7f8c9 -- ping -c 5 web-pod-7f8c9-peer
PING web-pod-7f8c9-peer (10.244.1.5): 56 data bytes
64 bytes from 10.244.1.5: icmp_seq=1 ttl=64 time=3420 ms
64 bytes from 10.244.1.5: icmp_seq=2 ttl=64 time=4100 ms
64 bytes from 10.244.1.5: icmp_seq=3 ttl=64 time=2890 ms
--- web-pod-7f8c9-peer ping statistics ---
3 packets transmitted, 3 received, 0% loss, 3000+ms avg
同节点 Pod 之间 ping 延迟 > 3s,正常应该 < 1ms。这不是 Pod 本身的问题——是共享网卡被打满了。
kubectl describe pod runner 看不到任何异常事件,Pod 状态 Running。因为带宽不是 K8s 调度的资源,scheduler 不感知、kubelet 不报告、Events 不记录——带宽问题在内核层面,K8s 控制面完全不可见。

分层:从 Pod 网卡到内核 tc,逐层追流量
K8s 为什么不调度带宽?因为带宽不像 CPU 和内存那样可以通过 cgroup 做硬隔离和配额——带宽是共享介质,同一张网卡上的所有流量走同一个队列,内核无法像 cgroup 那样按 Pod 颗粒度做资源划分。所以 K8s 干脆不碰带宽,把这个难题留给了 CNI 插件和内核 TC(Traffic Control,流量控制)子系统。
每张网卡上都有一组 qdisc(queueing discipline,排队规则),决定数据包的发送顺序和速率。
Pod 层:谁在吃带宽?
进入 Pod 网络命名空间看实际吞吐:
$ kubectl exec -n ci runner-pod-5j2k3 -- nethogs eth0
NetHogs version 0.8.5
PID USER PROGRAM SENT RECEIVED
12345 root pip install 85.3 MB 12.1 MB
12346 root wget -O /tmp/cache.tar.gz 120.5 MB 3.2 MB
TOTAL: 205.8 MB 15.3 MB
一个 pip install + wget 就把 pod 出口带宽吃到了接近 200Mbps。如果节点的出口带宽是 1Gbps,单个 Pod 理论上能吃到 1Gbps。
Node 层:tc qdisc 看看限速了没有
退出 Pod 视角,在宿主机上看网卡队列规则:
$ tc -s qdisc show dev eth0
qdisc pfifo_fast 0: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
Sent 892143875 bytes 1234567 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
默认 qdisc 是 pfifo_fast——先入先出,不做任何限速。节点上有多少 Pod 共用 eth0,谁抢到带宽就是谁的。CI/CD Pod 发得多,生产 Pod 的包就被挤到队列后面。

CNI 层:bandwidth annotation 实现了吗?
K8s 提供了两个带宽 annotation:kubernetes.io/egress-bandwidth 和 kubernetes.io/ingress-bandwidth。在 Pod YAML 加上:
metadata:
annotations:
kubernetes.io/egress-bandwidth: "10M"
kubernetes.io/ingress-bandwidth: "10M"
但是——这两行 annotation 不是 K8s 核心功能。它们只是一个"建议",需要 CNI 插件来实现。kubelet 读到 annotation 后会通过 CNI 接口传递给 CNI 插件。CNI 插件(如 Calico)的 bandwidth 组件会创建 htb qdisc 并挂到 Pod 的 veth 上。
验证 CNI 是否实现了 bandwidth:
# 在宿主机上看 Pod 的 veth pair
$ ip link show | grep veth
veth123abc@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast ...
$ tc class show dev veth123abc
# 没有输出 —— CNI 没注入限速规则!
如果你的 CNI 没实现 bandwidth plugin(Flannel、kube-router 默认不支持,Calico 默认也不开),加 annotation 等于没加——内核 tc 上根本不生成限速规则。

内核层:htb qdisc 才是限速的关键
如果 CNI 实现了 bandwidth,它会在 Pod 的 veth 上创建一个 htb(Hierarchy Token Bucket)qdisc:
$ tc class show dev veth123abc
class htb 1:1 root rate 10Mbit ceil 10Mbit burst 15Kb cburst 15Kb
htb 是一个层级令牌桶——它允许创建多个 class,每个 class 有自己的 rate(保证带宽)和 ceil(最大带宽)。CNI bandwidth 插件会在 Pod 的 veth 根上挂一个 htb,限制这端流量不超过 annotation 设定的值。
当 Pod 发送数据时,内核 TC 子系统会在 htb qdisc 检查令牌桶——令牌不够就丢包或延迟。这才是有带宽限制的效果。
路径:下次先跑这套排查命令
诊断带宽问题不要每次都从 kubectl 开始——先到节点上跑 tc。以下三步,每一步对应一个明确的诊断结论。
Step 1 — 先看有限速吗?
在节点上直接看网卡队列规则。这条命令告诉你"节点层面有没有人在管带宽":
$ tc -s qdisc show dev eth0
# 输出 A:默认队列 → 无限速,所有 Pod 共享带宽
qdisc pfifo_fast 0: root refcnt 2 bands 3 priomap 1 2 2 2 ...
Sent 892 MB 1234567 pkt (dropped 0, overlimits 0)
# 输出 B:htb 队列 → CNI bandwidth 已生效
qdisc htb 1: root refcnt 2 r2q 10 default 0 direct_packets_stat 0
Sent 567 MB 10000 pkt (dropped 0, overlimits 456)
pfifo_fast 意味着内核在做先入先出,不做任何限速——谁发得多谁占优。htb 证明已经有人(CNI)注入了限速规则,这时应该看具体限速值够不够。
Step 2 — 谁在吃带宽?
节点上跑 nethogs,10 秒内锁定肇事进程:
$ sudo nethogs eth0
NetHogs version 0.8.5
PID USER PROGRAM SENT RECEIVED
12345 root pip install 85.3 MB 12.1 MB
12346 root wget -O /tmp/cache.tar.gz 120.5 MB 3.2 MB
如果 PID 是个构建工具(pip/wget/curl),说明是 CI/CD 类任务在抢带宽。如果 PID 是业务进程,检查是不是数据同步逻辑出了问题——不同原因对应不同解决方案(应用层限速 vs 节点层隔离)。
Step 3 — Pod veth 上有限速吗?
这是最关键的诊断:加了 annotation 到底有没有生效?验证方法——找到 Pod 对应的 veth pair,看它的 tc class:
$ POD_IFINDEX=$(kubectl exec runner-pod-5j2k3 -- cat /sys/class/net/eth0/ifindex)
1234
$ ip link | grep "if1234"
veth1234@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast
$ tc class show dev veth1234
# 空输出 → CNI 没注入规则,annotation 无效
# 如果 CNI bandwidth 生效,会看到:
class htb 1:1 root rate 10Mbit ceil 10Mbit burst 15Kb cburst 15Kb
这里有一个常见的误解:有人会用 kubectl describe pod 查看 annotations 字段,看到 kubernetes.io/egress-bandwidth: 10M 就认为限速生效了。不对——annotation 只代表 kubelet 收到了这个标记,不代表 CNI 把它翻译成了 tc 规则。真正有效的证明是 tc class show 输出了 htb class。
三步诊断对照表
| 现象 | Step 1 eth0 qdisc | Step 3 veth class | 结论 |
|---|---|---|---|
| 带宽打满 | pfifo_fast 或 htb | 空 | CNI 没开启 bandwidth → 修 CNI 配置 |
| 带宽打满 | pfifo_fast | htb 有速率值 | CNI 配了但限速值太高 → 调小 rate |
| 带宽打满 | htb | htb | 节点带宽不够 → 隔离/扩容 |
| 带宽正常 | 任意 | 任意 | 不是带宽问题 → 排查别的方向 |
三步走完,你不仅能定位根因,还能直接得出结论是修 CNI 配置还是调参数——不需要猜。
定位:加了 annotation ≠ 限速生效
❌ 大多数人的做法:在 Pod YAML 加上 kubernetes.io/egress-bandwidth: "10M",认为带宽就限住了。发现没效果,怀疑 K8s 版本问题、kubelet 配置没开、或者干脆 reboot 节点。
✅ 正确的排查思路:先确认 CNI 是否实现了 bandwidth plugin。
检查三件事:
| 检查项 | 方法 | 通过标准 |
|---|---|---|
| CNI 插件支持 | cat /etc/cni/net.d/*.conf |
看配置文件中是否引用了 bandwidth 插件 |
| Calico 的配置 | calicoctl get felixconfig -o yaml |
bandwidth 功能已开启 |
| 运行时验证 | 创建带 annotation 的 Pod 后,宿主机上 tc class show dev vethXXX |
出现 htb class |
如果你的 CNI 不支持 bandwidth(Flannel、Cilium 默认,或 Calico 没开),加 annotation 就是白加——内核 tc 上根本不生成规则。
以 Calico 为例,启用 bandwidth 插件需要在 FelixConfiguration 中开启:
apiVersion: projectcalico.org/v3
kind: FelixConfiguration
metadata:
name: default
spec:
bandwidthRateLimiting: true
这条配置让 Calico 在创建 Pod veth pair 时,挂上一个 htb qdisc 并应用 annotation 指定的速率限制。
区别的本质:annotation 是"告诉 kubelet 我想要限速"——但真正执行限速的是 CNI 的 bandwidth 组件 + 内核 tc 的 htb qdisc。中间链路断了,annotation 就是一行注释。

标点:三种限速方案 + Check-list
方案 A:CNI bandwidth plugin(推荐)
前提是你的 CNI 支持 bandwidth 插件。Calico 开启后,所有 Pod 的 annotation 自动生效——最干净,不需要碰节点配置。
方案 B:节点级 tc 脚本
CNI 不支持时,直接在宿主机的 bridge 上挂 tc 规则。用 cgroup classid 匹配(与 CNI bandwidth 同样的方式),Pod 重建后 classid 不变——比 IP 匹配更鲁棒:
# 找到 Pod 的 cgroup classid
POD_CLASSID=$(cat /proc/$(kubectl exec <pod> -- cat /proc/1/status | grep PPid | awk '{print $2}')/cgroup | grep net_cls | cut -d: -f3)
# 输出如 0x100001
# 创建 htb qdisc + filter 按 classid 匹配
tc qdisc add dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:1 htb rate 1Gbit
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 100Mbit ceil 100Mbit
tc filter add dev eth0 protocol ip parent 1:0 prio 1 cgroup matchall flowid 1:10
方案 B 的缺点:脚本要管理 Pod 生命周期,重连后自动重建。
方案 C:应用层限速
在代码层用 token bucket 算法限速(如 Go 的 rate.Limiter、Java 的 RateLimiter)。最灵活但最侵入——改代码、发版、排查,周期太长。
Check-list
- [ ] nethogs / iftop 确认哪条链路带宽被打满
- [ ]
tc -s qdisc show dev <iface>确认当前有无限速规则 - [ ]
cat /etc/cni/net.d/*.conf检查 CNI 是否引用了 bandwidth 插件 - [ ] Calico 环境:
calicoctl get felixconfig -o yaml | grep bandwidthRateLimiting - [ ] 创建带 annotation 的 Pod 后,宿主机
tc class show dev vethXXX验证生效 - [ ] 验证限速后:
iperf3 -c <target-ip>确认速率在限制范围内
K8s 不是没有告诉你带宽被谁吃了——tc 已经记了每张网卡的队列情况,只是你没去查
下篇我们聊 Cilium/eBPF CNI 模式下的网络排障特有工具链——当你用 Cilium 时,tc 和 iptables 都看不到真实流量,得用 cilium monitor 和 bpftrace 代替。
附:完整命令清单
# 带宽排查
nethogs eth0 # 节点上按进程看带宽
iftop -i eth0 # 节点上按连接看带宽
tc -s qdisc show dev eth0 # 看队列规则(pfifo_fast=无限速,htb=有限速)
tc class show dev eth0 # 看限速规则的具体速率值
tc class show dev vethXXX # 看 Pod veth 上的限速规则
# Pod 网络诊断
kubectl exec <pod> -- nethogs eth0 # Pod 内看带宽占用
kubectl exec <pod> -- ss -i # Pod 内看 socket 延迟
kubectl exec <pod> -- ping <peer> # 跨 Pod 延迟
# CNI 配置检查
cat /etc/cni/net.d/*.conf # CNI netconf 插件链
calicoctl get felixconfig -o yaml | grep bandwidth # Calico bandwidth 开关
# 临时注入带宽限制
tc qdisc add dev vethXXX root tbf rate 10mbit burst 32kbit latency 50ms
# 节点实时监控
sar -n DEV 1 5 # 网卡吞吐量
watch -n 1 'tc -s qdisc show dev eth0' # 实时看队列统计