Files
MoShiro/docs/design/measurement.md
T
justme c9beeee8e3 docs: describe the unified Agent topology and link controls
Align architecture, protocol, API, storage, security and deployment documentation with default mutual measurements and the Agent link board.
2026-10-21 11:55:55 +08:00

196 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务与采集引擎
## 1. 原则
- 只采集原始数据:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。
- 一处声明,处处生效:采集方法(Kind)由积木组合声明而成。Agent 共用同一套积木(`backend/shared/MoShiro.Measurement`)。
- 密度即参数:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。
## 2. 任务模型
同一个 Agent 节点 的 IPv4、IPv6 路径并行执行相同的持续线路任务模板,各自记录、上传、分析。IPv6 只在管理端配置了地址且 Agent 上报支持时建立;两端的控制和数据通道固定使用同一地址族。按需测试通过所选线路 id 指定地址族,重负载并发限制仍按 Agent 的本机策略执行。两个地址族分别使用有界发件箱(拨号方 每路径 8 MB,Agent 的共同上传队列 64 MB)。
```text
Task = { id, scope, kind, target, params, schedule, enabled }
scope line : 拨号方 ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板)
target : Agent → 目标 IP 的单边任务 target = ip[:port](受 Agent 本地 allowed_target_cidrs 限制)
kind Kind 目录中的 id,例如 udp_echo / tcp_echo / udp_train …
params 按 Kind 的 ParamSpec 校验(默认值、上下限);未指定的参数取默认值
schedule continuous 持续运行,按时间切块
every(N) 每 N 分钟运行一次(N ≥ 60,适用于重负载 Kind)
once 按需测试,由 API 触发,也就是一次 Test
```
- 线路任务模板:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。
- 测试方案(Plan):多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `backend/src/MoShiro.Backend.Application/Tests/Plans.cs`。
- 期望任务集:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskManager` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 拨号方。新增任务类型时协议不变。
## 3. Kind 目录:积木组合
### 3.1 积木(`MoShiro.Measurement`,只有新增积木才需要写代码)
| 类别 | 积木 | 参数示例 |
| --- | --- | --- |
| Transport | `udp`、`tcp`、`tls`、`icmp` | `port`、`sni`、`nodelay`、`sndbuf` |
| Pacer(发送节奏) | `fixed`、`poisson`、`burst`、`ladder` | `rate_hz`、`mbps`、`burst_len`、`steps[]` |
| Payload(负载形态) | `fixed`、`uniform`、`choice` | `size`、`min`/`max`、`sizes[]` |
| Behavior(对端行为) | `echo`、`sink`、`source`、`connect`、`hold` | `direction`(`fwd`/`rev`/`bidir`)、`streams` |
| Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` |
| Recorder(记录器) | 根据 Kind 声明的列自动生成的列式记录器 | — |
两种角色互相独立:连接角色(拨号方 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Agent 共用。
### 3.2 Kind 声明(`MoShiro.Protocol/Catalog/Kinds`)
```csharp
public static readonly KindSpec TcpEcho = new(
Id: "tcp_echo",
Scopes: Scope.Line,
Transport: Blocks.Tcp(nodelay: true),
Pacer: Blocks.Fixed(rateHz: P.Int("rate_hz", @default: 20, min: 1, max: 200)),
Payload: Blocks.Uniform(P.Int("min", 32, 8, 1400), P.Int("max", 64, 8, 1400)),
Behavior: Blocks.Echo(),
Samplers: [Blocks.TcpInfo(everyMs: P.Int("tcp_info_ms", @default: 100, min: 10, max: 1000))],
Records:
[
Rec.Stream("msg", Col.U32("seq"), Col.U16("size"),
Col.Ts拨号方("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.Ts拨号方("t4")),
Rec.Stream("tcp_info", Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"),
Col.U32("rttvar_us"), Col.U32("cwnd"), Col.U32("retrans_total"), Col.U64("bytes_acked")),
],
Budget: p => Bandwidth.Echo(p.RateHz, p.Max)); // 用于校验上限和估算带宽
```
这一条声明会被四个地方使用:
- Agent:组装积木执行任务;
- Recorder:根据列定义记录数据;
- Backend:校验参数和预算,并在 `/catalog/kinds` 中展示;
- Analysis:按列名读取数据。
### 3.3 内置 Kind
| Kind | 组合 | 默认参数 | 主要服务于 |
| --- | --- | --- | --- |
| `udp_echo` | udp + fixed + fixed + echo | 10 Hz × 64 B | 基础指标、mc、hy2 |
| `udp_train` | udp + fixed/ladder + fixed + sink | 1250 B,`rev` | hy2 |
| `tcp_echo` | tcp/tls + fixed + uniform + echo + tcp_info | 20 Hz × 32–64 B,`tcp_info` 每 100 ms | mc、reality |
| `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd 拨号方 发 / rev Agent 发),拨号方 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality |
| `connect` | tcp/tls + fixed + connect | 每分钟 6 次 | reality,以及目标任务 |
| `hold` | tls + hold | 每 30 s 一次心跳 | reality |
| `icmp_echo` | icmp + fixed + fixed + echo | 1 Hz × 64 B | 目标任务 |
默认的线路任务模板:`udp_echo`、`tcp_echo`、`connect`(持续运行;`connect` 每分钟 6 次,记录 TCP 建连和 TLS 握手耗时,约 0.4 kbps)。其余 Kind 可以按需开启,或者以 Plan 形式运行。每一批默认任务只种入一次,在设置里用标记记住,所以删掉的默认模板不会被重新建出来;后来新增的默认任务(`connect`)在已有部署升级后会补建一次。
### 3.4 回显的四个时间戳
```text
拨号方 单调时钟 Agent 单调时钟
t1 发送 ─────────────> t2 收到
t4 收到 <───────────── t3 回复
```
RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/`t3` 但缺少 `t4` 表示下行丢失。`t2 − t1` 和 `t4 − t3` 的变化量在去除偏移和漂移后,就是每个方向各自的时延抖动。
## 4. 原始块(自描述)
```text
RawChunk
├─ header(JSON,自描述)
│ chunk_id(UUIDv7) container_version kind task_id test_id? phase?
│ agent_id dialer_id? target? params(完整快照) port direction
│ start_utc_ms duration_ms
│ clock:agent 单调时钟 ↔ UTC 锚点,拨号方时钟偏移估计
│ streams: [ { name: "msg", columns: [ {name:"seq", type:"u32", enc:"delta"}, … ], count } … ]
├─ 各列数据(delta / zigzag / varint,按列定义编码)
├─ Brotli 压缩(BCL 自带)
└─ crc32c
```
- 列定义写在块头中:新增列或新增 Kind 时不需要升级 `container_version`。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。
- 切块规则:持续任务每 10 秒切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。
- 按需测试按阶段切块,阶段内同样受记录数上限约束。
- 编解码器位于 `MoShiro.Protocol/Raw`,Agent、Backend、离线工具使用同一份代码。
## 5. 列的归属与合并
两侧各自记录完整行的流(`StreamSpec.Union`,例如 `tcp_info`):Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。
`tcp_info` 的可空 `conn` 列标识当前记录器内的一条 TCP 连接,不包含地址。Backend 按 `(side, conn)` 分别求累计计数差,避免把重连前后或同时存活的套接字混为一个序列;时间不递增的样本被忽略,任一计数器回退时重新建立基线。旧数据没有 `conn`,仍按 `side` 分组,无法事后恢复丢失的连接归属。升级时先更新 Backend 和 Agent,再更新 拨号方;旧 Agent 合并采样时会丢弃尚不认识的列。
TCP 回显的长度头和正文合为一次写入,避免在启用 `TCP_NODELAY` 时为长度头单独发送一个小段。`tcp.retrans` 仍按内核的重传段数与发送段数求比,不由应用消息数估算。
采集层只产生原始列(`seq`、`size`、`t1…t4`、`t_send`/`t_recv`、`outcome`……),不做任何统计,也不认识上层的方案或协议;丢包率、RTT、抖动由 Backend 的分析层从这些列推算。
每个记录流(`StreamSpec`)声明:
- `Driver`:行的主导方——行由它发出的包或事件决定;
- `Key`:连接键(通常是 `seq`);
- 每一列属于哪一侧由它的时钟决定:`Ts拨号方` 属于 拨号方,`TsAgent` 属于 Agent,其余列属于主导方。
两端各自把本侧的列记在一张按键索引的环形行表里(`RowStore`),拨号方 每 10 秒把已完成的行切成列块经控制通道交给对应的 Agent,Agent 按声明合并成完整的 RawChunk 后上传:
- `Driver = 拨号方`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),拨号方 的列按键补上,缺失就是缺失(丢失);
- 没有 `Key`,或只有主导方一侧有列:原样透传。
新增一种记录流只需要写声明,不需要写合并代码。拨号方 的列在规定时间内没有送达时,Agent 带着已有的数据继续,不上传只有 Agent 一侧的块。
## 6. 代码结构(`backend/shared/MoShiro.Measurement`)
同一种采集方法的两端是一对角色写在同一个文件里:拨号方(拨号方 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Agent 只负责组装自己那一侧。
```text
MoShiro.Measurement/ 依赖:MoShiro.Protocol(仅 BCL,零 NuGet)
├─ Core/
│ ├─ Contracts.cs IBlock、BlockSpec、BlockEnv、DataSession、IAdmission、IFragmentSink……
│ ├─ RowStore.cs 本侧记录的行:只含本侧拥有的列,按键索引的环形缓冲,热路径不分配
│ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点
│ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务)
├─ Net/
│ ├─ MsQuic*.cs、QuicDatagramTransport.cs、MeasurementDatagramRouter.cs MsQuic 数据面与逐流认证分发
│ ├─ LinuxIcmpEcho.cs、TcpInfo.cs ICMP 回显;Linux `TCP_INFO` 采样
│ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧)
├─ Blocks/ 一个文件 = 一种采集方法的两个角色
│ ├─ BlockBase.cs 积木的公共基类
│ ├─ Echo.cs DatagramEchoDialer/Listener、TcpEchoDialer/Listener
│ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener
│ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行)
│ ├─ Bulk.cs `tcp_bulk` 的两个角色
│ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来
├─ Merging/ FragmentMerger(声明驱动的按键合并)、ChunkFinalizer(盖章:块头、时钟锚点、起止时间)
├─ Execution/ BudgetGuard(参数校验 + 本地硬上限,超出即拒绝)
├─ Platform/ 主机汇总采样、IPv6 诊断、本机地址、平台识别
└─ Update/ RestartUpdater(收到更新请求后有序退出)
```
边界(由架构测试强制):采集层的任何代码里不能出现 REALITY、Hysteria2、Minecraft 这些业务概念,也没有测试方案——方案(例如 `hy2_ladder`、`reality_session`)只是若干采集原语阶段的编排,只存在于 Backend。监听方的准入策略(重放窗口、令牌桶、源地址学习)是安全边界,通过 `IAdmission` 由 Agent 实现,积木本身不含策略。
## 7. 资源与安全上限
| 项目 | 默认值 | 由谁约束 |
| --- | --- | --- |
| 同时运行的重负载测试 | 每个 Agent 1 个;拨号方 全局 1 个 | Backend 编排 + 节点本地 |
| 单次测试时长 / 码率 | ≤ 120 s;≤ 本地 `max_total_mbps` | 节点本地硬上限 |
| 单块记录数 | ≤ 50 000 | Recorder |
| 拨号方 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | 拨号方 |
| 持续任务的总带宽 | 每条线路 ≤ 100 kbps(默认模板约 40 kbps) | Backend 校验 |
| 接收端速率校验 | 实际速率 > 声明速率 × 1.2 时中止 | 节点 |
## 8. 端口
| 用途 | 默认端口 | 说明 |
| --- | --- | --- |
| 拨号方 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | |
| QUIC Datagram 数据(`udp_echo`、`udp_train`) | 7443/udp | Agent 固定监听;拨号方 每次新连接轮换本地源端口 |
| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 |
- 默认不使用 443,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。
- 任务参数中的 `port` 只能取 Agent 本地配置 `listen` 列表中的端口。要用 443 测量,需要节点所有者在本机把 443 加入 `listen`(并授予 `CAP_NET_BIND_SERVICE`),然后在任务中指定 `port: 443`。
- 每个原始块的头部都记录实际使用的端口。当测量端口与被推算服务的常用端口(443)不同时,分析结果附带 `port_differs_from_service` 置信说明,因为运营商的 QoS 可能按端口区别对待。
## 9. 边界
采集端只模拟流量特征(端口、包大小、速率、握手、方向),不实现 REALITY 或 Hysteria2 协议本身,分析结果附带置信说明。用户态看不到 TCP 的逐段收发,TCP 的原始数据是逐消息时间戳加内核采样;pcap / eBPF 见[路线图](../roadmap.md)。