Skip to content

Repository files navigation

vm-bandwidth-monitor

宿主机本地工具:在 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.mddocs/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 -9kill -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 给它一个尺寸——没尺寸的界面什么都不画,反而会让断言空过),因为"有没有终端"正是界面与报告的分水岭。

Workspace 结构

  • 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 — 内核边界试验台,走与二进制相同的挂载路径

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages