1. 讲在最前:为什么要在 CentOS 上做限流
先说个真实场景。前阵子给一台 CentOS 7.9 的服务器做维护,这台机器跑着几个 Java 服务和一台 Nginx 反代。下午高峰期 CPU 不高、内存也够,但用户就是反馈“卡成幻灯片”。登上去一看,网卡流量直接打满了 300Mbps,三个服务都在抢带宽,数据库备份和日志同步又在里面凑热闹。你很难说是哪个进程的问题,因为谁都在跑,谁都缺带宽。这时候最直接的办法就是限流——不是杀进程,也不是拔网线,而是让每个服务各拿各的带宽,互不踩踏。
有人会问,限流不是应该做在应用层吗?比如 Nginx 的 limit_req、Sentinel 的 QPS 限流、AOP 注解限流。这些确实能挡住集中在某一个接口上的突发请求,但它们管不住流量本身。如果一台 CentOS 服务器上有多个业务共用一个网卡,或者某个服务起了一堆连接疯狂下载,应用层限流完全看不到这些。真正的兜底方案是在 Linux 内核的网络协议栈里下手,用 tc(Traffic Control,流量控制)对网络接口做带宽整形。它不管你是 HTTP、MySQL 还是 rsync,只要是走了这个网卡的包,就得按你设定的规则排队。
tc 是 Linux 自带的流量控制工具,在 CentOS 上开箱即用,不需要额外装什么重的依赖。它最大的价值是可以在不重启服务、不打断连接的情况下,动态修改某一类流量的带宽上限。想限下行带宽就限下行,想限上行就限上行,想按 IP 限就按 IP 限,想按端口限就按端口限。这种灵活性是 iptables 的 limit 模块做不到的——iptables 那个 limit 是限制报文速率用的,主要防 SYN Flood,和 tc 的带宽整形完全两码事。
这篇我把自己在 CentOS 7 和 CentOS Stream 9 上实际用 tc 做限流的经验完整整理一遍,覆盖了原理、命令、脚本、常见坑,以及一些网上的文档里很少写清楚的细节。不管你是要限制某个容器跑到外面的流量,还是想给家宽做一套按 IP 分配的限速规则,这套思路都通用。
2. tc 限流的原理:搞清楚 HTB 和令牌桶再动手
2.1 一句话理解流量控制
Linux 内核把网卡收发的数据包交给一套队列规则(qdisc,Queueing Discipline)来管理。默认情况下,网卡的 qdisc 是 pfifo_fast,就是先到先走,大家挤在一起,谁也别想有保障。tc 干的事情就是替换掉这个默认 qdisc,换成我们自定义的规则,比如 HTB(Hierarchy Token Bucket,层级令牌桶)。数据包到了以后先按类别分类,再按各类的速率限制放进队列,最后由内核按优先级和速率发包。
tc 的配置对象主要有三个:
- qdisc:队列规则,负责决定包怎么排队、怎么发送。常见的有 pfifo_fast、htb、tbf、netem。
- class:类别,挂在 qdisc 下面,每个类别可以有自己的带宽上限和优先级。
- filter:过滤器,决定哪些包进入哪个类别。filter 的匹配依据可以是 IP、端口、协议、mark 标记等。
这三者的关系可以这样理解:qdisc 是一个大水管的总阀门,class 是下面分出来的若干根小水管,filter 是分拣员,把不同来源的水引入对应的小水管。
2.2 HTB 和 tbf 怎么选:都试过之后我的结论
HTB 是现在做多级限速的主流方案。它支持在一个根 qdisc 下挂多个 class,每个 class 可以独立设置 rate(保证带宽)和 ceil(最大带宽)。如果 A 类闲着不用带宽,B 类可以临时借用,这就是 HTB 最有价值的地方——带宽限制只在拥挤的时候生效,不限制闲置时的突发能力。
TBF(Token Bucket Filter,令牌桶过滤器)则简单得多,就是给一个网卡设置一个固定的速率上限,没有子类,没有优先级。适合那种“不管谁来,整体不能超过 xx Mbps”的场景。比如服务器只有一条 10Mbps 出口,想给它全局限个速,直接用 tbf 就够了。
如果要做精细化限速,比如一个 IP 一个带宽、或者按端口区分服务优先级,必须用 HTB。这是我踩过几次坑后的结论。TBF 看着简单,但一旦需要调整某个子服务的速度,就得把整个命令翻出来改一遍,而 HTB 只需要动某一个 class 的参数。
注意:tc 限速对 eth0 这种物理网卡是限制出方向(egress)的。所谓“下行”限速,实际上要在入口网卡上做一个 ifb(Intermediate Functional Block)来实现。多数教程只讲 egress,这个细节后面单独说。
2.3 令牌桶的关键参数:rate、ceil、burst
用 HTB 时最核心的就是 rate、ceil 和 burst 这几个词,理解它们的区别能让限流结果更可控。
- rate:每个 class 保证的带宽。就算网络再忙,这个速度也是留给它的。
- ceil:这个 class 能借到的最大带宽。当别的 class 有空闲带宽时可以突破 rate 冲上去,但超过 rate 的部分是不保证稳定的。
- burst:允许突发的字节数。TCP 启动时有个慢热过程,如果没有 burst,刚开始的几个包就会因为超出速率被丢掉。burst 的设置对 SSH、HTTP 这类交互式请求影响很大。
再补一个后面实际配置会遇到的数字:在 HTB 中计算 burst 时,一般建议取 rate 的千分之一到百分之一之间。比如 rate 是 1Mbps,burst 可以设成 10Kb 到 100Kb,太小会频繁丢包,太大又起不到突发抑制的作用。
这里有个隐藏坑:tc 的速率单位是 bit(小写 b),而文件中看到的大写 B 是 Byte。1Mbps = 1000kbit,而不是 1MB/s。很多人在换算时弄混了 rate=1000kbit 和 rate=1000KByte,结果速度完全不达标,查了半天网络问题,最后发现是单位搞错了。
3. 实际配置方案:从零开始的 tc 限流命令
3.1 安装依赖和基本检查
CentOS 上 tc 命令来自 iproute2,一般最小化安装就有。如果没有,先装一下:
yum install -y iproute2然后确认网卡名称。CentOS 7 通常是 eth0,CentOS Stream 8/9 可能是 ens160 或 ens192,云服务器上也可能叫 eth0。用 ip addr 看一下实际名字:
ip addr show我这边实验机器是 ens160。下面的命令都以 ens160 为例。
注意:tc 命令作用于网卡时,如果网卡已经被 NetworkManager 管理,修改后不会自动持久化,重启或重载网络服务后规则会丢。后面讲持久化方案时会提到。
3.2 先做一个简单的全局限速
限速最入门的需求是“这台机器出口带宽最高 50Mbps”。直接挂一个 tbf 就行:
tc qdisc add dev ens160 root tbf rate 50mbit burst 10kb latency 50ms这条命令给 ens160 的根队列换成了 tbf,速率 50Mbps,突发 10KB。命令执行后不会有任何输出,没有消息就是好消息。想看配置结果:
tc qdisc show dev ens160看到类似这样的输出说明生效了:
qdisc tbf 8001: root refcnt 2 rate 50Mbit burst 10Kb lat 50.0ms清掉这条规则也很关键,因为改错配置的时候你要能恢复默认状态:
tc qdisc del dev ens160 root3.3 用 HTB 做多类别限速:限制某个 IP 的带宽
单机限流用场景有限,实际碰得最多的还是“限制某个内网 IP 不要占满全部带宽”。比如这台 CentOS 上跑了多个服务,有一个下载服务是 192.168.1.100,它经常跑出异常流量拖死整个网卡,现在要把它限到 10Mbps。
第一步,给 ens160 创建一个 HTB 根节点,总速率假设是 1000Mbps(也就是理论上的网卡速率,或者你的实际出口带宽):
tc qdisc add dev ens160 root handle 1: htb default 30这里 handle 1: 是给根队列一个编号,default 30 表示没有匹配到任何 filter 的流量会走 class 1:30。这个一定要设好,不然所有流量会被丢掉,服务器直接断网。
第二步,创建默认类和高优类:
tc class add dev ens160 parent 1: classid 1:1 htb rate 1000mbit ceil 1000mbit tc class add dev ens160 parent 1:1 classid 1:30 htb rate 900mbit ceil 1000mbit tc class add dev ens160 parent 1:1 classid 1:10 htb rate 10mbit ceil 10mbitclass 1:1 是所有子类的父节点,class 1:30 是默认流量,class 1:10 是我们要限速的受限流量。第三步把 192.168.1.100 的包分拣到 class 1:10:
tc filter add dev ens160 parent 1: protocol ip prio 1 u32 match ip dst 192.168.1.100 flowid 1:10注意这里用的是 dst,因为要从这台服务器角度限制它发送给这个 IP 的数据。反过来要限制它上传(服务器从这个 IP 收数据),需要改 src 并用 ifb,这个后面铺开讲。
到这里就能测一下了。在这个 CentOS 上往 192.168.1.100 打流量,测速结果应该稳定在 10Mbps 上下,动态变化的流量不会超过 10Mbps。
如果发现别的服务也被拖慢了,检查一下 filter 的优先级是否正确。例如若还有服务从同一个 IP 访问,但是希望不受限制,可以给默认类也加一条精确匹配:
tc filter add dev ens160 parent 1: protocol ip prio 2 u32 match ip dst 192.168.1.200 flowid 1:30但其实默认类已经处理了所有未匹配的流量,除非你有更复杂的匹配需求,否则不需要单独加。
3.4 限制多个 IP 的最快方式:脚本批量生成
到了生产环境,不可能手动一条条敲 filter。我写了个简单的 shell 命令来批量限速,这里把思路放出来。
假设有个 ip 列表文件 /opt/limit_ip.txt,每行一个 IP:
192.168.1.100 192.168.1.101 192.168.1.102批量创建 class 和 filter:
i=10 for ip in $(cat /opt/limit_ip.txt); do tc class add dev ens160 parent 1:1 classid 1:$i htb rate 10mbit ceil 20mbit tc filter add dev ens160 parent 1: protocol ip prio 1 u32 match ip dst $ip flowid 1:$i i=$((i+1)) done这是一个很实用的基准方案,再往上层加业务逻辑可以写成一个配置文件驱动。个人经验是:classid 不要用连续的大数字混在一堆,建议定义一个对应关系表,比如 1:10 对应 10Mbps、1:11 对应 20Mbps,方便后面维护。
3.5 限制端口与协议:给 MySQL 或备份通道限速
有些服务占用带宽不是按 IP 来的,而是按端口。比如这台 CentOS 上有 MySQL 在同步 binlog,或者有一个备份程序向异地仓库推送数据,它可能不按 IP 走,或者目标 IP 不能动,这时候就按端口过滤。
限制 ssh 流量最高 1Mbps,还是用前面的 HTB 结构:
tc filter add dev ens160 parent 1: protocol ip prio 1 u32 match ip dport 22 0xffff flowid 1:20这里补充一点:u32 匹配端口时,dport 和 sport 的写法是不同的。限制发往 3306 的数据要在匹配 dst IP 后再加一个 dport 匹配。
一个更稳的方案是先用 iptables 打 mark,然后用 tc filter 根据 mark 分类。这样的好处是 iptables 的匹配能力远比 u32 强,支持 conntrack 状态、服务名等,同时 filter 规则不用频繁改。
iptables -t mangle -A OUTPUT -p tcp --dport 3306 -j MARK --set-mark 2 tc filter add dev ens160 parent 1: protocol ip prio 1 handle 2 fw flowid 1:20第二行的 handle 2 fw 表示按防火墙标记为 2 的包来分类,flowid 1:20 是预设好的慢速类。
特别提醒:iptables 的 MARK 要在 OUTPUT 链上打,而不是 FORWARD 链。tc filter 工作在 IP 协议栈的队列处,它看的是已经完成的 skb mark。如果打在 FORWARD 链上,包的 mark 可能没被 tc 看到,限流会失效,这个问题当时排查了很久。
3.6 实现下行限速:ifb 网卡的作用
前面所有配置都是限制 egress。现实中更常见的是限制“服务器下载速度”。比如 CentOS 上跑了一个监控系统,需要限制从源站拉镜像的下载带宽,不能把整个机房出口撑满。默认的 tc 对 eth0 的入口收包只能丢不能管速率,所以要用 ifb 把入口流量重定向到一个虚拟设备上再限。
第一步,加载模块并创建 ifb:
modprobe ifb numifbs=1 ip link set dev ifb0 up第二步,把 eth0 的入口流量重定向到 ifb0:
tc qdisc add dev ens160 handle ffff: ingress tc filter add dev ens160 parent ffff: protocol ip u32 match u32 0 0 action mirred egress redirect dev ifb0第三步,在 ifb0 上做和上面一模一样的 HTB 配置,但方向的含义反转了。此时在 ifb0 上 rate 10mbit 限制的就是 ens160 实际收包的速度:
tc qdisc add dev ifb0 root handle 1: htb default 30 tc class add dev ifb0 parent 1: classid 1:1 htb rate 1000mbit tc class add dev ifb0 parent 1:1 classid 1:10 htb rate 10mbit ceil 10mbit tc filter add dev ifb0 parent 1: protocol ip prio 1 u32 match ip src 192.168.1.100 flowid 1:10ifb 是一个坑点比较多的工具,最大的问题是加载模块后 ifb0 不一定自动 up,忘了 up 的话包会进去之后直接被丢掉,然后服务器入口速度直接归零。其次是如果虚拟化平台里网卡本身不支持 offload 特性,mirred 重定向可能不稳定。
4. 让限流规则动起来:动态调整与实时监控
4.1 动态修改带宽:不需要删掉重建
有同学可能觉得,要改变一个 class 的带宽,是不是要把整个规则链删掉再重来一遍。完全不需要。tc 提供了 change 命令,可以原地调整参数而不用中断现有连接。
tc class change dev ens160 classid 1:10 htb rate 5mbit ceil 10mbit这条命令把 1:10 的保证速率从 10Mbps 降到 5Mbps,瞬间生效。这个特性非常实用。比如你白天给某个服务 30Mbps,晚上高峰期需要压到 5Mbps,写一个 crontab 脚本在特定时间点 change 一下就行,不用像以前那样每次敲一堆删除再重建的命令。
不过要记得一个细节:change 命令只改 class 的速率参数,不会动 filter。也就是说 filter 还是保留的,绑定关系不会断。这在我实测中很舒服。
4.2 查看当前规则的几条命令
限流配好之后,总得知道它到底有没有生效。通则不痛。
- 查看所有 qdisc:
tc qdisc show,能确认根规则是 htb 还是 tbf。 - 查看所有 class:
tc class show dev ens160,能看到每个 class 的 rate 和 ceil,以及实时的 bytes 和 pkt 计数。 - 查看所有 filter:
tc filter show dev ens160,确认流量分类规则有没有挂上。
经验之谈,判断限流是否在跑的最快方式不是看规则,而是看 class 的 bytes 是否在增长。如果 class 1:10 的 bytes 一两分钟不见涨,说明根本没有包走进这个类里,filter 可能写错了,而不是规则没生效。
watch -n 1 'tc -s class show dev ens160'-s 选项特别重要,它会显示每个 class 的收发统计、dropped、overlimits 这些计数器。排查丢包时,这些数字比什么工具都直接。
4.3 删除和清理规则的正确姿势
完整清理一个网卡上的所有 tc 规则:
tc qdisc del dev ens160 root如果之前配了 ifb 和 ingress:
tc qdisc del dev ens160 ingress tc qdisc del dev ifb0 root我遇到过一种情况:删了根规则后,流量控制恢复正常了,但发现 iptables 的 mark 还在,结果后续别的程序又被 mark 干扰。所以清理 tc 规则时,也要回头检查一下有没有连带打 mark 的 iptables 规则需要一起清理。
开发调试时我习惯先执行tc qdisc del dev ens160 root 2>/dev/null,把所有规则清干净再把新规则加进去。这样也能避免重复添加 root qdisc 导致的 RTNETLINK answers: File exists 报错。
5. 实战踩坑录:这几个问题能让你排队到半夜
5.1 限速完全无效
现象:tc 配置看起来都对,但实际测速没变化。
排查步骤:
- 确认 tc 规则在哪个网卡。如果有 bond 网卡,eth0 上的规则不生效是正常的,要加在 bond0 上。
- 确认网卡有没有被 NetworkManager 重建。有些版本的 CentOS 在网卡 link 状态变化时会触发重载配置,把 tc 规则冲掉。
- 确认流量是不是走了别的路径。比如服务绑定在 lo 上,或者有多个路由出口,这时候你在 eth0 上做限制毫无意义。
我自己最常犯的错是:服务器上有两个网卡 eth0 和 eth1,物理线路从 eth0 出,但程序监听在 eth1。流量根本没走 eth0,自然限了个寂寞。
查看当前路由确认流量实际走哪个接口:
ip route5.2 设置后服务器断网
这是最紧张的故障。执行完 tc qdisc add 后 SSH 立即卡死,很多人的第一反应是赶紧重启,其实还有救。
断网的原因通常是 default 类没设对,或者 filter 把 SSH 的包也丢进了一个不存在或超低速的类。补充解法:在执行任何 tc 操作前,先写一条定时清空命令保命。
(sleep 60; tc qdisc del dev ens160 root) &这样就算配置失误导致断网,一分钟内也会自动恢复。等确认规则没问题后,这条命令自然失效。有人说这是“炸了也能救回来”的兜底方案,我强烈建议生产环境用这条。
5.3 burst 设置引起的 SSH 卡顿
有次给一台机器限 2Mbps,burst 设了 1kb。结果 SSH 操作明显一顿一顿的,敲个命令都要等一两秒才回显。查了半天发现是 burst 太小,导致 SSH 的 TCP 握手和窗口更新包都被丢了。
TCP 在交互式连接中会有很多小包,这类包如果 burst 给得太小,很容易被误杀。解决方法是给 SSH 单独开一个 class,或者把 burst 调大到 rate 的 10% 左右。
对于交互式远程管理,我建议给 SSH 单独建一个高优先级 class。比如所有流量默认 10Mbps,SSH 单独 1Mbps 不参与共享:
tc filter add dev ens160 parent 1: protocol ip prio 0 u32 match ip dport 22 0xffff flowid 1:9prio 0 优先级最高,先于其他规则匹配,保证 SSH 始终可用。这样即使总带宽打满了,远程管理也不会卡死。
5.4 容器或虚拟机的流量限不住
KVM 虚拟机或 Docker 容器里的流量经过宿主机的网桥(如 virbr0 或 docker0),直接在物理网卡 ens160 上配置 tc,经常会发现对虚拟机内测速不起作用。
原因是虚拟机流量由宿主机的 vnet/eth 对接到网桥,再通过物理网卡出去。filter 在处理网桥流量时会走 br0 的逻辑,而且容器网络用的是 veth pair,包经过的路径是 veth -> docker0 -> ens160,如果你只在 ens160 上做 u32 dst 匹配,容器的包不带目标 IP(或带的是容器内部 IP),可能匹配不上。
这个时候有两类解决办法:
- 在宿主机的网桥上做限速:对虚拟机的 vnet 接口直接挂 tc,比如限 vnet0 的速率,不碰物理网卡。
- 在 docker0 网桥上做整网段限速:直接匹配容器网段,比如 172.17.0.0/16。
这里有个实践结论:对虚拟机的 vnet 接口做限制,比对物理网卡做过滤要好控制得多,毕竟隔离性好,不会影响宿主机上其他服务。
5.5 限速规则重启后消失
这是 tc 和 iptables 的一大区别:tc 规则不持久化。服务器重启之后,所有 qdisc、class、filter 全部清空。
解决办法是写一个开机自启脚本,systemd 服务或 rc.local 都行。我习惯写一个脚本 /usr/local/bin/tc-limit.sh,把前面所有命令按顺序整理好,然后用 systemd service 拉起。
chmod +x /usr/local/bin/tc-limit.sh echo "/usr/local/bin/tc-limit.sh" >> /etc/rc.local chmod +x /etc/rc.localrc.local 比较直接,但不推荐在 Systemd 系统上依赖它,因为 rc-local.service 可能没启用。更稳的是写一个 unit:
[Unit] Description=TC bandwidth limit rules After=network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/local/bin/tc-limit.sh [Install] WantedBy=multi-user.target保存到 /etc/systemd/system/tc-limit.service,然后:
systemctl daemon-reload systemctl enable tc-limit systemctl start tc-limit测试阶段可以先 start 看看脚本有没有报错。
6. 进阶玩法:tc 结合脚本做的几个实用场景
6.1 按时间段自动切换带宽
限流不是静态配一次就完事了。比如互联网出口带宽晚上八点到十一点是高峰期,我想把内网视频服务临时压到低速,其他时间恢复。
写一个 crontab 就行:
# 每天20:00把视频服务 class 1:50 压到2Mbps 0 20 * * * tc class change dev ens160 classid 1:50 htb rate 2mbit ceil 5mbit # 每天23:00恢复10Mbps 0 23 * * * tc class change dev ens160 classid 1:50 htb rate 10mbit ceil 20mbitcrontab 里写命令时建议用绝对路径,因为 cron 环境变量很少,直接写 tc 可能找不到命令。用which tc查一下路径,一般是 /usr/sbin/tc。
6.2 检测大流量 IP 并自动限速
生产环境里经常有某些业务突然产生大量下载流量,把其他服务拖垮。结合 iftop 或 nethogs 找到大流量 IP,再用脚本自动把它塞进限速类。
我写过一个粗略的巡检脚本逻辑:
- 用 iftop -t -s 5 采集一段时间的流量统计,拿到 TOP IP。
- 判断这个 IP 的流量是否超过阈值。
- 如果超了,调 tc filter 把这个 IP 导进慢速类。
- 记录日志到文件,方便回溯。
注意这一步要判断 IP 是不是基础设施(比如 DNS、网关、监控服务器),否则容易误伤。
LOG=/var/log/tc-auto-limit.log THRESHOLD_MBPS=80 TOP_IP=$(iftop -i ens160 -t -s 5 -n -N | awk '/^ [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $2, $7}' | sort -k2 -rn | head -1 | awk '{print $1}') # 这里简化了逻辑:实际应计算带宽,超出才限速 tc filter add dev ens160 parent 1: protocol ip prio 1 u32 match ip dst $TOP_IP flowid 1:80 echo "$(date) limit $TOP_IP" >> $LOG这种自动化的思路在小型集群里非常有用。真实环境里的流量模型往往是波动的,静态限速只能限制峰值,动态限速才能真正保护其他业务。
6.3 tc 配合 ipset 做动态黑名单限流
ipset 是个好搭档。一个常见场景是限制某些恶意 IP 的访问带宽,直接丢弃显得太狠,不加限制又怕拖垮服务。用 ipset 维护一个恶意 IP 集合,然后用 tc filter 匹配这个集合。
先把 IP 加入 ipset:
ipset create badip hash:ip ipset add badip 1.2.3.4然后给 tc 加一条匹配 ipset 的 filter:
tc filter add dev ens160 parent 1: protocol ip prio 1 ipset match set badip dst flowid 1:60这句代码我实际用下来是可以的,但要注意 ipset 需要和 iptables 配合才能准确判断是哪个 IP 的流量。更重要的是,ipset 默认超时时间很长,如果恶意 IP 不再活跃,要定时清理 ipset 条目,否则会一直占用慢速类资源。
6.4 与 Sentinel 等应用层限流的配合定位
最近看到不少关于 Sentinel 限流和熔断降级的讨论。它在 Java 应用层做 QPS 控制很成熟,但如果有人问“为什么配置了 Sentinel 限流,带宽还是被占满”,答案就落在网络层。
Sentinel 限的是应用本身的 QPS,它默认认为业务服务和外部流量之间是直连的。如果前面挂了 Nginx 反向代理,或者有多个服务共享同一个网卡,Sentinel 只能管到应用自己,管不到操作系统发送队列里的其他包。
我处理过的一个案例:Nginx 上配了 Sentinel 的 QPS 限流规则,接口并发控制得很好,但静态资源被人用多线程下载器拉取,带宽被刷满。最后解决办法是在 Nginx 服务器上对下载请求的流量做 tc 限速(比如限制 50Mbps),应用层 QPS 和网络层带宽双管齐下,服务才稳住。
所以在排查限流问题时,要分清楚每一层都做了什么:
| 层面 | 工具 | 控制目标 |
|---|---|---|
| 应用层 | Sentinel、Nginx limit_req | QPS、并发数 |
| 传输层 | iptables limit | 包速率、连接数 |
| 网络层 | tc HTB/TBF | 带宽占用、优先级 |
三层不是替代关系,是互补关系。如果带宽被打满但 QPS 不高,说明攻击或占用在应用层之下,直接回到 tc。如果 QPS 很高但带宽没满,说明请求很轻但量大,限连接数或 QPS 会更有用。
6.5 用 tc 给 KVM 虚机单独限速
用 KVM 虚拟化时,每个虚机的虚拟网卡在宿主机上对应一个 vnetX 接口。想给某个虚机限速,只需要在这个 vnetX 上配 tc,而不需要动物理网卡:
tc qdisc add dev vnet0 root handle 1: htb default 10 tc class add dev vnet0 parent 1: classid 1:1 htb rate 100mbit tc class add dev vnet0 parent 1:1 classid 1:10 htb rate 20mbit ceil 20mbit这样虚机无论如何跑,出方向最多 20Mbps。注意这仍然是出方向,如果还要限制虚机的下载方向,就要在 vnet0 上挂 ifb。这套方案对 OpenStack 环境也适用,只是要注意 OpenStack 的 agent 可能会时不时刷新网卡上的 qdisc,需要持久化脚本每隔几分钟校验一下规则还在不在。
7. 需要注意的几个底层细节
7.1 tc 的速率单位换算
这是新手最容易踩的坑。tc 中:
- rate 10mbit 表示 10 兆比特每秒,实际带宽是约 10Mbps。
- rate 10Mbit 同理,大小写不敏感,但要注意 M 是 1024 还是 1000。
- rate 10kbyte 或 10KB 表示字节速率,和比特相差 8 倍。
在上面的测试中,如果 rate 设成 1024kbps 而实际上你希望是 1Mbps,偏差其实很大。更稳妥的方法是统一用 mbit,比如 5mbit、10mbit,写起来简单,算起来也直观。
7.2 网卡对 tc 的影响
有些网卡驱动和 tc 配合不好。特别是具备 TCP Segmentation Offload(TSO)或 Generic Receive Offload(GRO)的网卡,tc 看到的可能是已经分段/合并的大包,导致速率计算出现偏差。
如果发现 tc 限流值极不准确,考虑临时关闭网卡 offload 试试:
ethtool -K ens160 tso off gso off gro off关闭 offload 后对转发性能会有轻微影响,但这种影响在我们内部环境中几乎不可感知。在调试问题期间关闭,找到原因后再决定是否重新开启。
7.3 虚拟化环境中的网卡名称不固定
云服务器或虚拟机在重启后网卡名称可能从 ens160 变成 ens192。如果你把 tc 规则写死了网卡名,重启后脚本就会报错。
稳妥的做法是在脚本中动态获取第一个物理网卡名:
DEV=$(ip route | grep default | awk '{print $5}' | head -n1)或者根据 MAC 地址绑定的 interface 文件来固定设备名。CentOS 7 中可以通过修改 /etc/sysconfig/network-scripts/ifcfg-* 来固定设备名,但改动网卡名有系统风险,需要一个一个排查依赖。个人建议是在启动脚本里加一层判断,找不到网卡时直接报错退出而不是继续执行后面的命令,这样才能尽早发现问题。
7.4 tc 规则的叠加性
tc 规则是增量式的。如果你在一个网卡上 add 两次根 qdisc 会报错,但在同一个根 qdisc 下加多个 class 和 filter 是完全允许的。这也意味着你可以在运行过程中不断加新规则,而不影响已有的限速。
不过要注意,filter 是按 prio 依次尝试的。如果两条 filter 的 prio 相同且都匹配同一个包,内核会根据 filter 的添加顺序来决定先走后走,所以设计 filter 时要给特殊流量安排更高的 prio(数字更小),默认类放最后。
8. 末尾再补充一些心得
这台 CentOS 7.9 服务器上我最终保留的限速方案不复杂:一个 HTB 根节点,下面分了三类——SSH 管理流量高优 10Mbps、数据库同步 20Mbps、其余默认 100Mbps,再配一个定时清理脚本检查规则是否存在。整体跑下来,即使文件同步服务开始疯狂拉数据,SSH 也没有出现卡顿的情况。
弄完 tc 限速后,有几个连带的好处是意料之外的。比如有次某个 Java 服务出现了内存暴涨,伴随着大量线程建立连接,以前这种异常流量会直接打满出口带宽导致其他服务雪崩。有了 tc 的限制,异常流量被压到了很低的速率,故障被局限在单个服务范围内,整个服务器没有出现大的抖动。这种“反而成了保护机制”的效果,是我在配置之前真没想到的。
最后分享一个排查技巧:如果你不确定 tc 规则是否生效,最简单的方法是看 /proc/net/psched,这是一个内核提供的信息文件,能看到当前的 qdisc 状态。配合 tcpdump 抓包判断,基本上所有限流失效的问题都能定位到。
如果在限流过程中碰到什么奇怪的坑,记住一个原则:先把 tc 规则删干净,再一条一条加回去确认。大多数问题不是内核不支持,而是之前的残留规则叠加在一起导致的。