环境
- 复现版本
77ba030 (2026-08-27)
- 已确认 master 最新版
5e6139e (v1.0.0-rc2-50-g5e6139ed, 2026-09-02) 仍存在,下述代码与行号均以该版本为准
- 通过
AISCAN_DATA_DIR 把 .aiscan 落在持久化卷上,agent 容器长期运行(3~6 天不重启)
现象
.aiscan/mitm/capture/ 单调增长,从不回收。实测两个长期运行的工作区:
|
body 目录 |
文件数 |
最大单文件 |
| A |
7.1 GB |
172,823 |
395 MB (.resp) |
| B |
2.2 GB |
143,619 |
— |
对照组:一个只跑浏览器抓网页的工作区,同样运行 3 天,635 个文件 / 311 MB,最大 body 1.7 MB。
所以撑爆磁盘的不是抓包频率,是单个响应体积 × 永不回收。
同一目录下 flows.jsonl 只有 274 MB,而 body 有 7.1 GB —— 索引和实际占用完全脱节。另外发现多个 200~300 MB 的孤儿 .resp.part(如 298 MB、294 MB)。
根因
1. 内存有界,磁盘无界
FlowStore 是容量 10000 的环形缓冲(tools/proxy/mitm.go:39、:636)。Add() 满了淘汰最老的一条(mitm.go:720、:727):
idx := (s.head + s.size) % s.cap
if s.size == s.cap {
idx = s.head
s.head = (s.head + 1) % s.cap
}
淘汰只覆盖内存槽位,磁盘上对应的 body 文件不做任何处理。超过 1 万条之后,每个落盘 body 都成了没有索引指向的孤儿。上表 172,823 个文件对 10000 的容量,正是这个比例。
2. body 全量落盘,文件侧没有任何上限
aop/traffic/body.go:70 的 Write() 无条件写全部字节:
n, err := s.file.Write(p)
previewMax(调用处传的是 maxBodySnip = 4096,mitm.go:174、:612)只截断内存里的预览,不影响落盘。所以一个 395 MB 的响应,磁盘上就是完整的 395 MB。
值得注意的是 refLocked() (body.go:189):
BodyRef 建模了 Truncated 字段,但取值写死为 false —— 截断能力留了接口没有实现。
3. 默认过滤器等于不过滤
captureResponseAllowed (tools/proxy/hub.go:134):
return (f.Status == "" || matchStatus(status, f.Status)) &&
(f.CType == "" || strings.Contains(strings.ToLower(contentType), strings.ToLower(f.CType)))
两个条件默认空字符串 → 一律放行。它是给操作员用 --status / --type 手动收窄的查询过滤器,不是默认护栏。全仓没有任何基于 Content-Length 或体积的跳过逻辑。
4. 唯一的清理路径需要人手动触发
删 body 的只有 FlowStore.Clear() → os.RemoveAll(bodyDir) (mitm.go:899、:920),而它只被 mitm.go:94 的手动 clear 命令调用。没有 TTL、没有容量预算、没有后台回收。
此外 Close(complete=false) 时 .part 文件按注释是"保留以供诊断"(body.go:121),中断的大传输会留下完整残骸且永不回收。
补充:这不是回归,是从未有过生命周期
aop/traffic/body.go 自诞生至今只有一个提交:
b0b1ffb0 2026-08-22 feat(proxy): stream MITM bodies into durable capture files
"durable" 即设计意图,落盘就是要持久化 —— 只是配套的回收层从来没有建过。检索了 master 全部历史与当前 104 个 PR,没有任何提交触及 aop/traffic/body.go、tools/proxy/mitm.go、tools/proxy/hub.go 中与体积上限或回收相关的逻辑。
另外注意到 dc9e0d7 fix(runner): cap inline tool result size at 64KiB —— 给别的路径加体积上限的思路已经在做了,mitm 抓包这条只是还没走到。
复现
- 起一个长期运行的 agent,挂 mitm 代理
- 让它反复通过代理拉大文件(数据集 / tarball / 大 PDF)
- 观察
.aiscan/mitm/capture/body/ —— 只增不减,mitm list 里却只看得到最近 10000 条
修复设计
问题的本质是生命周期错配:FlowStore 有界(10000 条),body 文件无界;而 body 文件的存在意义就是被对应 flow 的 BodyRef.Path 水化,它本就该由那条 flow 拥有。
所以不需要引入 TTL 守护协程或后台回收器 —— 让已有的环形淘汰成为文件的所有者即可。三处改动,合起来给出磁盘占用的确定上界。
A. 单个 body 硬上限 —— 把已建模的 Truncated 落地
aop/traffic/body.go。BodySink 增加 maxBytes,Write() 中:
s.size 继续累加全量(真实体积仍如实上报)
- 仅在
written < maxBytes 时落盘,写到上限即停
s.truncated = true,refLocked() 返回真实值而非写死的 false
必须在 Write() 而不是按 Content-Length 拦截:chunked / 流式响应根本没有 Content-Length,而本次观测到的 200~300 MB 残骸全是 .resp.part,即中断的流 —— 按响应头判断恰好漏掉真正造成损害的那类。Write() 是唯一写路径,在这里设闸能覆盖全部情况。
digest 建议只对实际保留的字节计算,以维持 sha256(file) == BodyRef.SHA256 这个不变量;Truncated=true 已足以表达"这是前缀"。
B. 环形淘汰联动删文件
tools/proxy/mitm.go 的 Add()。淘汰分支里 s.flows[idx] 即将被覆盖,而它自带 Request.BodyRef 与 Response.BodyRef,路径现成:
if s.size == s.cap {
idx = s.head
evicted := s.flows[idx] // 取出待删路径
s.head = (s.head + 1) % s.cap
}
注意把 os.Remove 放到 s.mu.Unlock() 之后执行,不要在持锁期间做 I/O。
C. 容量预算,而不只是条数
只有条数上限是不够的:10000 × 单体上限 仍是个很大的乘积。FlowStore 增加 bytes 与 maxBytes,Add() 循环淘汰直至 size <= cap && bytes <= maxBytes 同时满足。增减量取自 BodyRef.Size,簿记成本可忽略。
至此磁盘占用 ≤ maxBytes + 在途量,是确定值而非统计值。建议默认:单体 8 MiB,总量 2 GiB,均可配置。
D. 启动时清扫孤儿 .part
SetBodyDir 中扫掉早于本进程启动的 *.part。进程崩溃 / 强杀留下的残骸只能靠这个回收,一次性,无需常驻。
为什么不用 TTL / 后台回收
- 无需协程、时钟与调参
- 最坏情况是确定的;TTL 挡不住窗口内的突发写入
- 淘汰逻辑本来就存在,这是把它补完整,而不是并行加一套独立机制
- 两层存储共用同一个界,不新增需要推理的生命周期
附带
flows.jsonl 同样只增不减(本次观测 274 MB)。体量远小于 body,但性质相同,建议一并做轮转或上限,否则修完 body 之后它就是下一个。
方案认可的话我可以提 PR。
环境
77ba030(2026-08-27)5e6139e(v1.0.0-rc2-50-g5e6139ed, 2026-09-02) 仍存在,下述代码与行号均以该版本为准AISCAN_DATA_DIR把.aiscan落在持久化卷上,agent 容器长期运行(3~6 天不重启)现象
.aiscan/mitm/capture/单调增长,从不回收。实测两个长期运行的工作区:.resp)对照组:一个只跑浏览器抓网页的工作区,同样运行 3 天,635 个文件 / 311 MB,最大 body 1.7 MB。
所以撑爆磁盘的不是抓包频率,是单个响应体积 × 永不回收。
同一目录下
flows.jsonl只有 274 MB,而 body 有 7.1 GB —— 索引和实际占用完全脱节。另外发现多个 200~300 MB 的孤儿.resp.part(如 298 MB、294 MB)。根因
1. 内存有界,磁盘无界
FlowStore是容量 10000 的环形缓冲(tools/proxy/mitm.go:39、:636)。Add()满了淘汰最老的一条(mitm.go:720、:727):淘汰只覆盖内存槽位,磁盘上对应的 body 文件不做任何处理。超过 1 万条之后,每个落盘 body 都成了没有索引指向的孤儿。上表 172,823 个文件对 10000 的容量,正是这个比例。
2. body 全量落盘,文件侧没有任何上限
aop/traffic/body.go:70的Write()无条件写全部字节:previewMax(调用处传的是maxBodySnip = 4096,mitm.go:174、:612)只截断内存里的预览,不影响落盘。所以一个 395 MB 的响应,磁盘上就是完整的 395 MB。值得注意的是
refLocked()(body.go:189):BodyRef建模了Truncated字段,但取值写死为false—— 截断能力留了接口没有实现。3. 默认过滤器等于不过滤
captureResponseAllowed(tools/proxy/hub.go:134):两个条件默认空字符串 → 一律放行。它是给操作员用
--status/--type手动收窄的查询过滤器,不是默认护栏。全仓没有任何基于Content-Length或体积的跳过逻辑。4. 唯一的清理路径需要人手动触发
删 body 的只有
FlowStore.Clear()→os.RemoveAll(bodyDir)(mitm.go:899、:920),而它只被mitm.go:94的手动 clear 命令调用。没有 TTL、没有容量预算、没有后台回收。此外
Close(complete=false)时.part文件按注释是"保留以供诊断"(body.go:121),中断的大传输会留下完整残骸且永不回收。补充:这不是回归,是从未有过生命周期
aop/traffic/body.go自诞生至今只有一个提交:"durable" 即设计意图,落盘就是要持久化 —— 只是配套的回收层从来没有建过。检索了 master 全部历史与当前 104 个 PR,没有任何提交触及
aop/traffic/body.go、tools/proxy/mitm.go、tools/proxy/hub.go中与体积上限或回收相关的逻辑。另外注意到
dc9e0d7 fix(runner): cap inline tool result size at 64KiB—— 给别的路径加体积上限的思路已经在做了,mitm 抓包这条只是还没走到。复现
.aiscan/mitm/capture/body/—— 只增不减,mitm list里却只看得到最近 10000 条修复设计
问题的本质是生命周期错配:
FlowStore有界(10000 条),body 文件无界;而 body 文件的存在意义就是被对应 flow 的BodyRef.Path水化,它本就该由那条 flow 拥有。所以不需要引入 TTL 守护协程或后台回收器 —— 让已有的环形淘汰成为文件的所有者即可。三处改动,合起来给出磁盘占用的确定上界。
A. 单个 body 硬上限 —— 把已建模的
Truncated落地aop/traffic/body.go。BodySink增加maxBytes,Write()中:s.size继续累加全量(真实体积仍如实上报)written < maxBytes时落盘,写到上限即停s.truncated = true,refLocked()返回真实值而非写死的false必须在
Write()而不是按Content-Length拦截:chunked / 流式响应根本没有Content-Length,而本次观测到的 200~300 MB 残骸全是.resp.part,即中断的流 —— 按响应头判断恰好漏掉真正造成损害的那类。Write()是唯一写路径,在这里设闸能覆盖全部情况。digest 建议只对实际保留的字节计算,以维持
sha256(file) == BodyRef.SHA256这个不变量;Truncated=true已足以表达"这是前缀"。B. 环形淘汰联动删文件
tools/proxy/mitm.go的Add()。淘汰分支里s.flows[idx]即将被覆盖,而它自带Request.BodyRef与Response.BodyRef,路径现成:注意把
os.Remove放到s.mu.Unlock()之后执行,不要在持锁期间做 I/O。C. 容量预算,而不只是条数
只有条数上限是不够的:
10000 × 单体上限仍是个很大的乘积。FlowStore增加bytes与maxBytes,Add()循环淘汰直至size <= cap && bytes <= maxBytes同时满足。增减量取自BodyRef.Size,簿记成本可忽略。至此磁盘占用 ≤ maxBytes + 在途量,是确定值而非统计值。建议默认:单体 8 MiB,总量 2 GiB,均可配置。
D. 启动时清扫孤儿
.partSetBodyDir中扫掉早于本进程启动的*.part。进程崩溃 / 强杀留下的残骸只能靠这个回收,一次性,无需常驻。为什么不用 TTL / 后台回收
附带
flows.jsonl同样只增不减(本次观测 274 MB)。体量远小于 body,但性质相同,建议一并做轮转或上限,否则修完 body 之后它就是下一个。方案认可的话我可以提 PR。