Skip to content

mitm 抓包 body 无大小上限且永不回收,长期运行会撑爆磁盘 #118

Description

@wuchulonly

环境

  • 复现版本 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:70Write() 无条件写全部字节:

n, err := s.file.Write(p)

previewMax(调用处传的是 maxBodySnip = 4096,mitm.go:174:612)只截断内存里的预览,不影响落盘。所以一个 395 MB 的响应,磁盘上就是完整的 395 MB。

值得注意的是 refLocked() (body.go:189):

Truncated: false,

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.gotools/proxy/mitm.gotools/proxy/hub.go 中与体积上限或回收相关的逻辑。

另外注意到 dc9e0d7 fix(runner): cap inline tool result size at 64KiB —— 给别的路径加体积上限的思路已经在做了,mitm 抓包这条只是还没走到。

复现

  1. 起一个长期运行的 agent,挂 mitm 代理
  2. 让它反复通过代理拉大文件(数据集 / tarball / 大 PDF)
  3. 观察 .aiscan/mitm/capture/body/ —— 只增不减,mitm list 里却只看得到最近 10000 条

修复设计

问题的本质是生命周期错配:FlowStore 有界(10000 条),body 文件无界;而 body 文件的存在意义就是被对应 flow 的 BodyRef.Path 水化,它本就该由那条 flow 拥有。

所以不需要引入 TTL 守护协程或后台回收器 —— 让已有的环形淘汰成为文件的所有者即可。三处改动,合起来给出磁盘占用的确定上界。

A. 单个 body 硬上限 —— 把已建模的 Truncated 落地

aop/traffic/body.goBodySink 增加 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.goAdd()。淘汰分支里 s.flows[idx] 即将被覆盖,而它自带 Request.BodyRefResponse.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 增加 bytesmaxBytes,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。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions