问题
KVM 虚拟机内的 NixOS 使用宿主机的 mihomo 作系统代理下载 Steam 游戏,观测到下载速率在 10~30 MB/s 之间波动。需要判定:这些字节究竟是走代理出去的,还是直连。
本次为只读分析:仅调用 mihomo 的只读 API(/connections、/proxies、/providers/proxies),未修改任何配置、未重启任何服务。
结论
100% 直连(DIRECT),经代理 0 字节。两个独立测量窗口的归因结果完全一致。
承运方全部是国内 CDN;分流由规则 GEOSITE,category-games@cn → DIRECT 与 GEOSITE,steam@cn → DIRECT 命中。走代理的只有商店/页面的少量接口流量,字节量可忽略。
判定方法
第一步:先确认「是否经过 mihomo」
比较宿主网卡全局吞吐与 mihomo 自身计数器:
| 宿主网卡 rx | 23.90 MB/s |
mihomo downloadTotal 增量 | 22.58 MB/s |
两者接近 ⇒ 虚拟机全部流量确实穿过 mihomo。所以「经不经 mihomo」不是问题,真正的问题是流量被哪条出口链终结。
第二步:用 mihomo 连接表 + chains 字段归因
GET http://127.0.0.1:9091/connections 返回的每条连接都带 chains 字段:
["DIRECT"]→ mihomo 用宿主机本机网络出境,即直连["节点名","组名","节点选择"]→ 经代理节点出境
⚠ 关键坑:必须「全速轮询」才能归因完整
归因靠「逐连接累加 download 增量」,而覆盖率高度依赖轮询频率:
| 轮询方式 | 覆盖率 | 表现 |
|---|---|---|
| 每 0.6 s 采样一次 | 6% | 只看到个别慢速长连接,结论完全失真 |
| 全速轮询(约 294 次/秒) | 100% | 归因总量与 mihomo 全局计数吻合 |
原因:Steam 下载跑在大量「采样间隔内开完就关」的短连接上,慢采样会把它们整段漏掉。必须去掉 sleep、连续轮询,覆盖率才能到 100%。
实测数据(两个独立窗口)
| 窗口 | mihomo 全局 ↓ | 承运主机 | 速率 | 出口链 |
|---|---|---|---|---|
| #1(20 s) | 21.84 MB/s | files.steam.nsclouds.cn | 19.82 MB/s | DIRECT |
| xz.pphimalayanrt.com | 2.00 MB/s | DIRECT | ||
| — 经代理合计 — | 0.00 MB/s | — | ||
| #2(10 s) | 23.87 MB/s | files.steam.nsclouds.cn | 22.02 MB/s | DIRECT |
| xz.pphimalayanrt.com | 1.83 MB/s | DIRECT | ||
| — 经代理合计 — | 0.00 MB/s | — |
归因总量 21.83 / 23.86 MB/s ≈ mihomo 全局计数 ⇒ 归因完整。两窗口均为 DIRECT ≈ 100%、PROXY = 0。
承运方与解析
| 承运域名 | 实测速率 | 解析结果 | 性质 |
|---|---|---|---|
files.steam.nsclouds.cn | 19.8~22.0 MB/s | 39.137.99.115(bjxlv4.y.ngaagslb.net) | 国内 CDN |
xz.pphimalayanrt.com | 1.8~2.0 MB/s | CNAME → m.alikunlun.com | 阿里云 CDN |
命中规则
- GEOSITE,category-games@cn,DIRECT
- GEOSITE,steam@cn,DIRECT
那走代理的是什么
Steam 的 *.steamstatic.com 系(clan. / store.fastly. / community.fastly. / shared.)走代理,但累计字节只有 0.3~2 MB、速率 ≈ 0 —— 属于商店页面 / 接口类流量。
⇒ 「内容下载直连、商店浏览走代理」的分流正在正常工作。
含义
- 下载速率不受机场带宽 / 代理节点限制,由国内 CDN 决定;10~30 MB/s 的波动是 CDN 侧行为。
- 不吃订阅流量配额 —— Steam 下载不计入套餐月流量。
- 虚拟机内「系统代理」只决定规则分流;命中
@cn规则族的流量实际仍走本机网络出境。
复现方法
全速轮询连接表并归因(只读,可随时重跑):
import json, urllib.request, time, collections
last = {}; agg = collections.defaultdict(int)
END = time.time() + 20
while time.time() < END:
d = json.load(urllib.request.urlopen("http://127.0.0.1:9091/connections", timeout=10))
for c in d.get("connections") or []:
cid = c["id"]; m = c.get("metadata", {})
host = m.get("host") or m.get("destinationIP")
ch = tuple(c.get("chains") or [])
kind = "DIRECT" if ch == ("DIRECT",) else "PROXY"
dl = c.get("download", 0)
if cid in last: agg[(host, kind)] += max(0, dl - last[cid])
last[cid] = dl
for (h, k), b in sorted(agg.items(), key=lambda kv: -kv[1])[:10]:
print(b, h, k)
注意:不要加 time.sleep(),否则覆盖率会掉到个位数百分比。
边界
- 判定依据是 mihomo 自报的
chains字段(["DIRECT"]= 用宿主机本机网络出境)。 - 测量窗口为瞬时段(20 s / 10 s),结论是「当下这一时刻的下载」;Steam 若切换到境外 CDN,分流结果可能不同,需重跑复现脚本确认。
- 本次只回答「走了哪条出口」,不涉及限速、QoS 或 CDN 选路优化。