宿主机本地工具:在 Linux Bridge 的 VM TAP 接口上用 eBPF(tcx)统计每个 IPv4 的实时带宽与累计流量。领域术语见 CONTEXT.md,挂载机制决策见 docs/adr/0001-tcx-instead-of-clsact.md。
当前状态(T8 配置、CLI 与交付打磨):sudo ./vm-bandwidth-monitor 一条裸命令即可用——自动发现桥下全部 TAP 并双向挂载,每 5 秒重扫增删,每秒刷新一个三区终端界面(表头状态区 / 行表格 / 常驻总计条),可滚动翻页、/ 过滤、s 排序、p 暂停、i/t 切换列、? 看帮助;没有终端时退回每秒一段的 stdout 报告。桥名、两个周期、map 容量与列/排序默认值可用一个 TOML 文件改,没有文件就走内建默认。--filter 给的是界面启动时的过滤器,之后归键盘。
接口发现(见 CONTEXT.md 的 TAP 接口):桥成员取自 sysfs 的 brif/,TAP 身份看 tun_flags 的 IFF_TAP 位——不看接口名、不看 up/down 状态,纯数字名照常识别,veth 与物理口因没有 tun_flags 自然排除。挂载生命周期见 docs/adr/0004-sysfs-interface-discovery.md 与 docs/adr/0005-bridge-loss-keeps-attachments.md:
- 新 TAP 5 秒内纳入统计;TAP 消失即卸载并删除其内核条目(累计值由用户态的账本持有,见下)。
- TAP 重建拿到新 ifindex → 自动重挂;单接口挂载失败只影响该接口,写一行 stderr 并在下次扫描重试。
- 启动时桥不存在 → 报错退出;运行中桥消失 → 既有 TAP 继续计数、扫描不停,桥回归后自动恢复(原因写 stderr)。
计数口径(见 CONTEXT.md):
- TX(VM 发送,TAP ingress,按源地址归属):src 为
0.0.0.0的帧(DHCP DISCOVER)不计。 - RX(VM 接收,TAP egress,按目的地址归属):只计目的 MAC 为单播的帧,广播/组播因此不会产生 幽灵 IP 行。
- 字节数:TAP 所见的 L2 帧字节,GSO 分段前口径。
- 包数:线缆真实包——聚合帧按分段数累计,见 docs/adr/0003-gso-segment-counting.md。
- 非 IPv4 ethertype 一律放行且不计数。
快照核心(ledger):内核 map 是易失计数器,累计流量 由用户态账本持有,每次读到的计数都当成"比上次多了多少"折叠进去,而不是当成真值:
- 实时带宽 = 本轮增量 × 8 ÷ 实际采样间隔(不是标称的 1 秒——晚到的一轮是更长的一轮,不是更快的链路)。晚到只影响那一轮:下一轮从这一次交付起再算一个整周期,不会紧跟着补一个几毫秒的短轮次——那种轮次会把一个正常的帧画成一条快一个数量级的链路。
- 计数器回退(接口重建、内核条目被删、ifindex 复用)→ 该轮带宽为 0、累计不倒退;宁可少计一轮,不臆造一个跳变。
- 同一 IP 出现在多个 ifindex 上聚合为一行;接口列因此有三态:唯一 TAP 显名字、多个显
multi(n)、冻结行 显-。 - TAP 全部消失 → 该 IP 成为 冻结行,累计保持不动;VM 回来(哪怕是新 ifindex)在原行上继续累计。卸载前先折一次账,免得最后一轮计数随内核条目一起消失。
- Active IP = 本轮带宽非零,与已知 IP 数一起显示在表头。
- 带宽按十进制(bit/s–Gbit/s)、流量按二进制(B–TiB)、包数按十进制(无后缀,再往上
K/M/G/T/P/E)换算,均取三位有效数字。三者因此都是近似值:12.3K读不回 12345,总计条也不等于把印出来的各行手加起来——不变式是对数字本身说的,不是对它们的写法(见 CONTEXT.md 的 总计条)。
范围与过滤(filter,见 CONTEXT.md 的 IP 范围 与 过滤器):起-止 闭区间是唯一语法,单 IP 视为起止相同的退化区间;分隔符两侧可有空格。CIDR、IPv6、倒序、缺端点、多个 - 一律拒绝,并说清是哪一种错法——错的过滤器要报错,不能悄悄匹配不到东西:
$ vm-bandwidth-monitor br0 --filter 10.30.8.0/24
error: invalid value '10.30.8.0/24' for '--filter <起-止>': CIDR is not a range: write it as start-end
- 匹配只遍历已知 IP,不枚举区间内的地址:
10.0.0.0-10.255.255.255是一千六百万个地址、几行数据,代价按行算。 - 表尾常驻总计条,不变式是"永远等于当前显示的这些行之和":无过滤即全桥聚合,有过滤即范围汇总。零匹配输出
Matched 0与全零,与"桥上很安静"分得开——表头标出的过滤器说明它确实生效了。 - 表头的已知/Active IP 数始终是全桥口径:过滤收窄的是看什么,不是监控什么。采集完全不受过滤影响,清掉过滤即看到完整历史。
界面(view 定视图模型,tui 用 ratatui 摆它)分三个区,按 1 秒周期实时刷新:
- 表头状态区:一行——桥名、TAP 数、已知/Active IP 数、本轮实际间隔,加上生效的过滤器、滚动位置与暂停时的
PAUSED。界面不显示采集异常(桥消失、接口挂不上),它们走 stderr;界面拿走屏幕之后连 stderr 也不写,所以要看就2>somewhere。理由见 ADR-0008。 - 行表格:每个 IPv4 一行,默认按 IP 升序,当前排序列的表头带 ▲/▼。接口列三态:唯一 TAP 显接口名、多个显
multi(n)、冻结行显-。包数列默认关(#1 的[display]默认值),i/t运行时切换。 - 常驻总计条:永远等于当前显示那些行之和,并与表格逐列对齐——它不能比它所总计的表少一列。过滤输入行开着时占的是总计条下面那一行,不是总计条的位置:常驻的优先级高于一个按 Enter 就关掉的输入行。
按键(?/h 在界面里列出同一张表,controls::KEYS 是它唯一的出处):
| 按键 | 作用 |
|---|---|
q Ctrl-C |
退出并完整恢复终端状态(kill/pkill/systemctl stop 的 SIGTERM 与关掉终端窗口的 SIGHUP 同样恢复;kill -9、kill -3 不会——见 ADR-0006) |
? h |
帮助浮层 |
↑ ↓ j k |
逐行移动锚点(到窗口边缘才带着行走) |
PgUp PgDn |
整页翻(动的是窗口,锚点在窗口里的位置不变) |
g G Home End |
跳顶 / 跳底 |
/ |
打开过滤输入行(起-止 或单个 IP) |
Enter |
校验并应用输入行 |
Backspace |
删掉最后一个字符 |
Esc |
输入态放弃输入;否则关帮助浮层;否则清除过滤 |
s |
排序循环 ip↑ → rx↓ → tx↓ |
p |
暂停/恢复显示刷新(采集不停) |
i |
接口列开关 |
t |
包数列开关 |
r |
立即重扫接口并刷新(两个周期都从这一刻重新起算) |
几处值得说明的:
- 退出排在表首,不是随手排的:窗口不够高时浮层画不下整张表,画不下的从表尾丢——而按
?的人按定义就是不知道怎么出去的那个人。丢了多少会明写出来(+2 more),不会让人以为看到的就是全部;窗口拉高一点就都回来了。 - 选中行仅为滚动锚点,v1 无按行动作。
- 过滤是显示条件:全局唯一,表格与总计条读同一个值,所以两者不可能各说各话。非法输入(CIDR、倒序、缺端点、IPv6)在输入行原地报错并停在输入态,采集与界面都不中断——改对再按一次 Enter 就是了。
- 暂停停的是画面,不是采集:轮次照常关闭、累计照常增长,恢复后的那一轮和别的一轮一样长,不会把整段暂停平均成一个尖峰。暂停期间按
i/t/s照样生效——数字不动,看数字的方式不必跟着不动。PAUSED比按键多亮一会儿:恢复的那一下屏幕上还是暂停时那一轮的数字,标记要等下一轮关掉、新数字上屏才灭——旧数字配一个什么都不说的表头,和一台真没流量的机器长得一模一样。 - raw mode 下终端不再产生 SIGINT,所以
Ctrl-C也在按键表里,输入过滤器时同样有效。
需要 Docker(Debian 13 构建容器:stable 编用户态 musl 静态二进制,nightly + bpf-linker 编 eBPF)。宿主机上不需要装 Rust、LLVM 或任何内核头文件——工具链版本锁在容器里(见 docs/adr/0002-pinned-ebpf-toolchain.md):
./build.sh # → dist/x86_64-unknown-linux-musl/vm-bandwidth-monitor其他子命令在容器内执行:./build.sh check、./build.sh test。交叉编译改两个变量,例如在 Apple Silicon 上出 arm64 产物:
PLATFORM=linux/arm64 TARGET=aarch64-unknown-linux-musl ./build.sh产物是单个 musl 静态二进制:eBPF 目标码在编译期嵌进它,不依赖 libc,也不需要随附任何文件。scp 上去就是全部安装步骤:
scp dist/x86_64-unknown-linux-musl/vm-bandwidth-monitor root@host:/usr/local/bin/目标宿主机的要求只有两条:Linux 内核 ≥6.6(tcx,见 ADR-0001;本项目的目标环境是 Debian 13 / 6.12)与 root。不写任何文件、不装 systemd unit、不留残留——进程退出(含 SIGKILL)内核自动卸载全部 eBPF。唯一会被读到的外部文件是配置(见下):启动时看一眼 /etc/vm-bandwidth-monitor.toml,不存在就走内建默认——所以往一台装过旧版本的机器上 scp 时,记得那台机器上可能还留着一份配置。
以 root 运行。不给参数就是内建默认(br0):
sudo vm-bandwidth-monitor要看别的桥,把桥名写在命令行上——它覆盖配置文件里的 [network] bridge:
sudo vm-bandwidth-monitor vmbr1在终端里跑就是界面——三个区,每秒刷新一次:
bridge br0 TAP 2 known 3 active 2 every 1.00s row 1/3
IP ▲ RX TX RX TOTAL TX TOTAL TAP
10.30.8.5 1.20 Mbit/s 340 Kbit/s 1.00 GiB 320 MiB 100
10.30.8.6 0 bit/s 12.0 Kbit/s 1.50 KiB 96.0 KiB 101
10.30.8.7 0 bit/s 0 bit/s 640 KiB 2.00 MiB -
Matched 3 1.20 Mbit/s 352 Kbit/s 1.00 GiB 322 MiB
倒数第二行是 冻结行:那台 VM 已经停机,累计值留在原地等它回来。IP 上的 ▲ 说的是行按它升序排,s 一按就换成 RX ▼。
stdout 或 stdin 不是终端时(| tee、> log、systemd、容器)没有界面可画,退回每秒一段的报告——同一份数据、同一个总计条,只是印出来而不是画出来,并且把默认关掉的包数列也一并印全(见 docs/adr/0006-tui-only-on-a-terminal.md)。报告没有 ▲/▼:它永远按 IP 升序,没有 s 可按,一个不会变的指示只会给逐行解析它的人添个字符:
# bridge=br0 taps=100,101 known_ips=3 active_ips=2 interval=1.00s
IP RX TX RX TOTAL TX TOTAL RX PKTS TX PKTS TAP
10.30.8.5 1.20 Mbit/s 340 Kbit/s 1.00 GiB 320 MiB 12.3K 67.9K 100
10.30.8.6 0 bit/s 12.0 Kbit/s 1.50 KiB 96.0 KiB 12 820 101
10.30.8.7 0 bit/s 0 bit/s 640 KiB 2.00 MiB 800 1.60K -
Matched 3 1.20 Mbit/s 352 Kbit/s 1.00 GiB 322 MiB 13.2K 70.3K
加上 过滤器 就只看一段 IP 范围,总计条随之变成该范围的汇总:
sudo ./vm-bandwidth-monitor br0 --filter 10.30.8.1-10.30.8.6 # 闭区间,含两端
sudo ./vm-bandwidth-monitor br0 --filter 10.30.8.5 # 单 IP:起止相同的退化区间# bridge=br0 taps=100,101 known_ips=3 active_ips=2 interval=1.00s filter=10.30.8.1-10.30.8.6
IP RX TX RX TOTAL TX TOTAL RX PKTS TX PKTS TAP
10.30.8.5 1.20 Mbit/s 340 Kbit/s 1.00 GiB 320 MiB 12.3K 67.9K 100
10.30.8.6 0 bit/s 12.0 Kbit/s 1.50 KiB 96.0 KiB 12 820 101
Matched 2 1.20 Mbit/s 352 Kbit/s 1.00 GiB 320 MiB 12.4K 68.7K
上面那个 冻结行 10.30.8.7 落在区间外,所以既不显示、也不进汇总——它停机了,但它不在这段范围里才是它这次没出现的原因。
采集异常一律走 stderr,界面上看不到(桥消失、某个接口挂不上、某一轮读不到 map):写到 stderr,带时间戳、级别与 RUST_LOG(默认 info),而界面拿走屏幕之后就静音——理由只有一条,stderr 就是界面正在画的那块屏幕;2>somewhere 之后这条理由不成立,照写不误,重定向的人要的正是一份进程退出后还在的记录。界面起来之前的一律照写:启动就失败时(比如一个 TAP 都挂不上),每个接口的 errno 级原因都在 stderr 上,那一刻屏幕还没被拿走。取舍见 ADR-0008。方向以 VM 为参照:TX = VM 发送(TAP ingress,按源地址归属),RX = VM 接收(TAP egress,按目的地址归属)。进程退出(含 SIGKILL)后内核自动卸载全部 eBPF,不创建、不修改任何 qdisc。
程序读的是自己所在网络命名空间的 sysfs,所以要跟被监控的桥在同一个 netns 里跑——在宿主机上直接运行时天然满足。
每一项都有默认值,所以配置文件是"跟某个默认值意见不同"的地方,而不是一件必须存在的东西:没有文件就是内建默认,sudo vm-bandwidth-monitor 直接可用。默认路径 /etc/vm-bandwidth-monitor.toml,--config 可指定别处。取舍见 docs/adr/0007-configuration-refuses-rather-than-defaults.md。
[network]
bridge = "br0" # 被监控的桥
[collector]
refresh_interval_ms = 1000 # 关一轮、刷一屏的周期
interface_scan_interval_secs = 5 # 重扫桥成员、增删挂载的周期
map_max_entries = 8192 # 内核计数 map 容纳的 (TAP, IPv4) 对数
[display]
show_interface = true # 接口列
show_packets = false # 包数列
default_sort = "ip" # 开局排序:"ip" | "rx" | "tx"上面写的就是内建默认值,原样贴进文件等于什么都没改。优先级只有一条:命令行上的桥名覆盖 [network] bridge,其余全归文件。[display] 定的是开局——i/t/s 随时改得动,配置值是一次会话的起点,不是键盘管不着的开关。
几处值得说明的:
-
缺项即默认:只关心一件事就只写一件事,半张表不是半份配置。
-
写错的键会报错,不会被悄悄忽略——这是"每项都有默认值"的另一半:
show_packet = true若被无视,看起来会和被采纳一模一样。同理,坏 TOML、不认识的default_sort、为 0 或为负的周期与容量,一律启动即报错退出,并指出是哪个文件的第几行:$ sudo vm-bandwidth-monitor --config /etc/vm-bandwidth-monitor.toml Error: reading /etc/vm-bandwidth-monitor.toml Caused by: TOML parse error at line 12, column 16 | 12 | default_sort = "bytes" | ^^^^^^^ "bytes" is not a sort key: it is one of "ip", "rx" or "tx" -
--config指了就必须存在:能省的只有默认路径。指名道姓一个文件却读不到,回退到默认值就是在跑一份没人选过的配置,而且看起来和读到了一模一样。 -
map_max_entries是内核 map 真实容量,加载时下发,不用重新编译。map 是 LRU:满了之后数据包照常放行,内核淘汰最久没被计数的那个(TAP, IP)——活跃 VM 不会因为容量耗尽而停止被计数,累计流量在用户态,不随内核条目消失。默认 8192 对一台 18 VM 的宿主机有数倍余量(一台 VM 的一个 IP 占一格),真需要调大是 IP 数量级不同的场合。 -
--filter不在配置里:过滤器是一次会话的关注点,/和Esc随时改,写进文件只会让人以为它影响采集。 -
没有终端时
[display]不参与:没人能按i/t/s的那种运行里,报告永远印全部列、永远按 IP 升序(理由在report.rs,口径见 CONTEXT.md 的 总计条;有没有界面这件事本身见 ADR-0006)。[network]与[collector]照常生效——桥名、两个周期与 map 容量都在终端判定之前就用上了。
./build.sh test # 单元测试(容器内):快照核心、范围与过滤、单位换算、发现规则、挂载增删计划、视图模型与滚动
./test/smoke.sh # 端到端冒烟:特权容器里跑真二进制,验证发现、生命周期、过滤器 CLI、配置与界面
./test/rig.sh # 内核边界试验台:验证 T2 计数语义与 T3 挂载生命周期这三样都不需要真实 VM。规格 §9 的宿主机验收是手工的,清单见 docs/acceptance/t9-host-walkthrough.md。
快照核心是主测试缝:喂合成的"TAP 集合 + 计数快照"序列,断言导出的行——折叠、累计、带宽、冻结与回归、跨接口聚合全在这里覆盖,不需要 root,任意平台都能跑。范围解析的合法/非法两张表逐条成断言,过滤与总计条也在这条缝上(它们是行的纯函数,与内核无关)。界面不另开缝:视图模型(哪些列、每列写什么、总计条与表格逐列对齐、滚动锚点怎么走)在同一条缝上覆盖,渲染快照测试按 #1 的决定不做;界面真的画得出来、q 真的把终端还回去,由冒烟脚本在一个真 pty 上验。
试验台自己 unshare 出私有 netns(连同 mount 命名空间,好让 /sys 跟着走,见 ADR-0004),建桥、造 TAP、持 tap fd 注入构造帧(单播 / 广播 / 组播 / 0.0.0.0 / 非 IPv4 / 带 vnet_hdr 的 GSO 帧),断言直接打在内核 map 与内核挂载状态上。它不依赖容器,在 Debian 13 机器上 sudo ./vmbm-testrig 同样一条命令跑完。
冒烟脚本用的 TAP 没有 fd 持有者(因而无 carrier),验的是"哪些接口被挂上",打流计数由试验台负责。界面那一节用 script 造一个 pty(并先 stty 给它一个尺寸——没尺寸的界面什么都不画,反而会让断言空过),因为"有没有终端"正是界面与报告的分水岭。
vm-bandwidth-monitor-common— 内核/用户态共享的 map 键值结构(ABI)与计数语义纯函数vm-bandwidth-monitor-ebpf— tcx classifier 程序(bpfel-unknown-none)vm-bandwidth-monitor— 用户态:config(TOML 与内建默认)、discovery(sysfs 找 TAP)、Monitor(挂载、读 map)、Attachments(按桥成员增删挂载)、Ledger(快照核心:折叠计数、持有累计、算带宽)、filter(范围解析、过滤与总计条)、units(单位换算)、view(视图模型:三区、列与滚动锚点)、tui(ratatui 渲染与按键)、logging(warn 走 stderr,界面拿走屏幕后静音)与report(无终端时的逐行输出)test/rig— 内核边界试验台,走与二进制相同的挂载路径