Agent 工程实习复盘 14|沙箱内存为什么会越跑越高:Idle Sweeper、Orphan Reaper 与分层回收
本文基于一次测试环境的沙箱资源泄漏治理整理。文中使用通用的 Agent、Gateway、Runtime 和 CubeSandbox 等技术名称,不涉及具体项目、公司、用户或服务器标识。
本文是第 01 篇沙箱接入与架构改造的后续,重点只讨论运行后的资源生命周期、异常中断和安全回收,不重复介绍沙箱的基础能力。
一、先说结论
沙箱的资源回收不能只依赖进程正常退出时调用一次 close()。
在真实测试环境中,服务重启、OOM、部署中断或代码异常都可能让本地 Runtime 先消失,但远端 MicroVM 仍持续运行。此时它们既不在当前进程内存里,也未必还能从本地状态文件被正确找到,却继续占用 CPU 和内存。
这次治理最终拆成两层:
- Idle Sweeper:回收当前进程还认识、当前没有任务执行、并且长时间无活动的沙箱。
- Orphan Reaper:通过远端实例列表、持久化状态和实例元数据三方对账,识别异常遗留实例;默认只记录候选,不直接删除。
核心原则是:自动回收可以积极发现,但删除必须保守执行。
二、事故信号:不是“内存高”,而是资源所有权已经断了
当时测试环境出现的不是短时间峰值,而是持续累积的资源占用:
- 总内存约
31 GiB,其中约24 GiB已使用,swap 约1.9 GiB被打满。 - RSS 排名前列是沙箱运行时的 shim 进程,单实例约
0.5 GiB到2.3 GiB。 - 远端控制面显示约
36个沙箱、约28个仍处于 running 状态。 - 这些 running 实例合计约占
20 GiBRSS。 - 当前本地状态文件只记录约
14个沙箱,远少于远端运行数量。 - 内存压力最终触发 OOM,影响了 Gateway 和 Agent Runtime 等正常服务。
最关键的证据不是“有很多进程”,而是:远端正在运行的沙箱数,明显大于当前进程和持久化状态能够认领的数量。
因此,单纯调小 keepalive、重启服务、或者只清当前内存中的对象,都不能解决已经脱离归属关系的历史实例。
三、先分清四种状态:不是所有没活动的沙箱都是孤儿
回收前必须先定义状态,否则自动清理很容易误杀正常任务。
| 状态 | 远端实例 | 本地持久化 State | 当前进程内存 | 是否可回收 |
|---|---|---|---|---|
| 正在执行 | running | 有 | 有 active operation | 不可回收 |
| 正常闲置 | running | 有 | 有对象 | Idle Sweeper 超时后可回收 |
| managed orphan | running | 无 | 无 | 通过保护期和元数据验证后可回收 |
| stale state | running | 有,但长期未更新 | 无 | 通过超时和二次检查后可回收 |
| dead state | 不存在或已停止 | 有 | 无 | 只删本地失效 State,不杀远端实例 |
这里最容易犯的错误是把“本地没看到”直接等同于“可以杀”。例如一个刚创建的沙箱可能还没来得及写完状态;另一个实例可能属于并行部署、人工 smoke 或其他系统。它们都不能因为当前进程不认识就直接删除。
四、为什么正常的 release() 不够
正常路径是:Agent 使用沙箱,任务完成,Provider 释放本地映射并关闭远端实例。它只覆盖“当前进程一直活着、并且代码走到了清理分支”的情况。
但下面这些情况会跳过正常路径:
- Runtime 被 OOM killer 杀死。
- 发布脚本在重启中途异常退出。
- 宿主机断电、容器崩溃或网络隔离。
- 远端实例创建成功,但本地写入 State 前进程中断。
- 本地 State 已存在,但实际远端实例早已消失。
所以资源治理不能只是一条“退出时 close”。它至少要有一个针对进程内对象的日常回收器,和一个跨进程、跨重启的对账回收器。
graph TD;
A[Agent 申请沙箱];
B[远端创建实例];
C[本地记录映射和活动时间];
D[任务执行];
E[正常 release];
F[远端实例关闭];
G[异常重启或 OOM];
H[远端实例仍在运行];
I[本地归属可能丢失];
J[Orphan Reaper 后续对账];
A --> B;
B --> C;
C --> D;
D --> E;
E --> F;
D --> G;
G --> H;
H --> I;
I --> J;
五、第一层:Idle Sweeper 回收当前进程管理的闲置实例
Idle Sweeper 的目标很窄:只处理当前 Provider 内存中还持有的沙箱。它不扫描远端控制面,也不处理历史遗留实例。
每个已获取的沙箱需要维护至少三类运行时数据:
last_activity:最后一次获取、访问或操作完成的时间。active_operations:当前正在执行的操作计数。- owner 与 sandbox 的映射:用于删除持久化 State 和本地索引。
后台线程按固定间隔扫描 last_activity。候选必须同时满足:超过 idle timeout,且 active_operations 为 0。
graph TD;
A[定时扫描当前 Provider 内存];
B[读取 sandbox 最后活动时间];
C{超过 idle timeout};
D[保留实例];
E{存在 active operation};
F[保留实例];
G[再次加锁确认状态];
H[删除本地映射和 State];
I[关闭远端实例];
A --> B;
B --> C;
C -->|否| D;
C -->|是| E;
E -->|是| F;
E -->|否| G;
G --> H;
H --> I;
5.1 为什么要维护 active operation,而不是只看最后活动时间
一个长命令可能运行时间超过 idle timeout。如果只看开始时间,后台线程会把仍在运行的沙箱误判为空闲并关闭。
因此在真实操作开始时增加计数,结束时减少计数并刷新活动时间。Sweeper 看到计数大于 0 时直接跳过。释放前还会在锁内重新检查一次,避免“扫描发现空闲”和“新任务刚开始”之间的竞态。
5.2 为什么移除 State 和关闭远端实例要一起考虑
释放时需要清理内存映射、用户或 thread 映射、活动时间和持久化 State,再关闭远端实例。若只关闭远端而不移除 State,下次恢复会指向一个已死实例;若只删 State 而不关闭远端,又会制造新的 orphan。
这不是分布式原子事务,因此代码仍需允许失败和重试。但至少要让两侧状态尽可能朝同一个终态收敛。
六、第二层:Orphan Reaper 用三方对账处理跨重启遗留
Orphan Reaper 处理的是当前进程已经无法直接 release() 的情况。它同时读取:
- 当前远端控制面可列出的实例。
- 本地持久化的沙箱 State。
- 当前 Provider 内存中受保护的实例和 active operation。
远端实例创建时会携带托管元数据,例如:是否由本系统创建、Provider 类型、稳定的部署实例标识、owner 的哈希以及模板标识。owner 不直接写明文,避免把用户或 thread 语义暴露到远端控制面。
graph TD;
A[获取远端沙箱列表];
B[读取本地持久化 State];
C[读取当前内存保护集合];
D[按 sandbox_id 对账];
E{远端运行且本地无 State};
F[managed orphan 候选];
G{State 指向运行中的远端实例且长期未更新};
H[stale state 候选];
I{State 指向不存在或停止的实例};
J[dead state 候选];
K[二次检查和 dry run];
A --> D;
B --> D;
C --> D;
D --> E;
E -->|是| F;
D --> G;
G -->|是| H;
D --> I;
I -->|是| J;
F --> K;
H --> K;
J --> K;
6.1 managed orphan:远端仍在,本地已没有记录
它是典型的异常重启遗留:远端实例带有本系统的元数据,正在运行,但不在 State 里,也不在当前进程内存保护集合中。
即使满足这些条件,也必须超过 grace period 才进入候选。保护期给“刚创建、尚未落盘”或“部署切换中的暂态”留出时间。
6.2 stale state:本地还记得它,但当前进程不再管理它
这类实例的 State 尚在,远端也仍运行,但最后活动时间远早于阈值,且内存中没有活跃对象。实际动作是先销毁远端实例,再删除对应 State。
处理前会再次加载 State,确认它仍指向同一个 sandbox ID。若 State 在扫描期间已经被新请求刷新或改指向另一个实例,候选会被跳过。
6.3 dead state:本地指针还在,远端资源已经没了
它不是资源泄漏,不能执行 kill。Reaper 只删除无效 State,防止后续恢复流程持续尝试连接一个不存在的实例。
七、为什么默认 disabled 和 dry-run
Orphan Reaper 的风险高于 Idle Sweeper:它会调用远端 kill 或 close,一旦识别错误,损失的是用户正在运行的环境。
因此配置默认值采用两层保护:
orphan_reaper_enabled = false
orphan_reaper_dry_run = true
正确的上线顺序不是直接开启删除,而是:
- 先启用 reaper,但保持 dry-run,收集候选日志。
- 人工核对候选的元数据、创建时间、owner hash、远端状态和业务活动。
- 调整 grace period 与 stale-state 阈值。
- 在低风险窗口将 dry-run 改为 false。
- 持续观察删除数量、失败数量、误杀告警和内存曲线。
尤其不能根据实例名字、数量过多或“看起来很旧”就删除。可靠回收依赖的是可验证归属、时间窗口和二次检查,而不是经验判断。
八、实现中的几个安全细节
8.1 只处理本系统且属于当前部署实例的沙箱
Reaper 不会清所有远端实例。它先检查托管标记和 Provider 标记,再比对部署实例标识。没有这些标识的人工测试实例或其他系统实例不进入候选。
8.2 当前内存对象和活跃任务永远优先保护
无论 State 是否缺失,只要 sandbox ID 仍在当前 Provider 内存映射中,或者 active operation 大于 0,Reaper 就跳过。内存中的实时事实比落盘快照更接近正在发生的任务。
8.3 候选在删除前必须重新验证
第一次扫描和真正删除之间可能已经发生恢复或新任务。对于 managed orphan,会再次检查 State 是否已重新出现;对于 stale state,会重新加载 owner 的 State 并确认 sandbox ID 没变化。
8.4 SDK 不支持 list 时宁可跳过
跨进程对账依赖远端 list()。如果 SDK 版本没有暴露该能力,Reaper 只记录并跳过,不能退化成“根据本地缺失就猜测远端可删”。
九、验证:回收逻辑要覆盖不删除的情况
这类功能最重要的测试不是“能 kill 一个实例”,而是证明它不会错杀。
| 场景 | 期望结果 |
|---|---|
| 当前进程内沙箱空闲超时 | Idle Sweeper release 并移除本地 State |
| 当前进程内沙箱有 active operation | 即使超时也不释放 |
| managed orphan 且 dry-run | 只记录候选,不关闭实例、不删 State |
| managed orphan 且显式关闭 dry-run | 销毁远端实例 |
| stale state 对应远端仍运行 | 销毁实例并删除 State |
| dead state 对应远端已不存在 | 只删除 State,不执行 kill |
| 当前内存仍保护该实例 | Reaper 不产生候选 |
| 候选期间 State 重新出现或改变 | 跳过删除 |
| SDK 没有 list 能力 | 跳过 reaper,不做猜测性删除 |
实现中为上述分支新增了定向测试,包括 dry-run、真正销毁、State 清理、活跃任务保护和远端消失等场景。测试关注的是状态收敛和副作用边界,而不只是函数返回值。
十、这次复盘中几个不够的方案
10.1 只缩短 keepalive
keepalive 只影响新实例的预期存活时间,不能清理已经失去本地归属的实例,也无法处理远端控制面与本地状态不一致。
10.2 服务 shutdown 时统一 close
它覆盖正常停机,却无法应对 OOM、强杀和部署中断。恰恰是这些异常路径最容易产生高成本遗留。
10.3 看到本地 State 不存在就杀远端实例
刚创建实例、并行部署或其他系统实例都会被误伤。必须结合托管元数据、部署实例标识和 grace period。
10.4 只清本地 JSON 或 SQLite State
这只会让远端实例更难找,资源仍在运行,甚至会把可恢复状态变成真正的 orphan。
10.5 一上线就打开 destroy
自动删除一旦判断错误,影响比内存泄漏更直接。先 dry-run 观察候选,是这类运维自动化的基本纪律。
十一、可以复用到其他资源系统的经验
这套思路不限于 MicroVM,也可用于容器、浏览器会话、临时 GPU Worker、远程开发环境等所有“本地有引用,远端有实体”的资源。
- 资源必须有可验证的归属标签。 不能靠名称猜来源。
- 正常回收和事故回收是两条链路。 一个处理进程内闲置,一个处理跨重启遗留。
- State 既可能缺失,也可能过期。 对账必须同时容忍这两类错误。
- 删除前的二次检查很重要。 扫描结果只是候选,不是删除授权。
- dry-run 是产品能力,不是临时日志。 它让删除策略能在真实环境中被观察和校准。
- 监控应关注资源数量和归属差值。 远端 running 数、本地 State 数、内存中受保护数之间的偏差,比单独看 RSS 更能提前发现泄漏。
十二、面试时我会怎么讲
我会这样概括:
我参与过 Agent 沙箱的资源生命周期治理。一次测试环境 OOM 中,远端运行中的沙箱数量明显大于本地状态能认领的数量,说明异常重启后出现了孤儿实例。为此我把治理拆成两层:Idle Sweeper 只回收当前进程管理且无活跃操作的超时实例;Orphan Reaper 通过远端实例、持久化 State 和进程内保护集合对账,区分 managed orphan、stale state 和 dead state。为了避免误删,实例需要带受控元数据和部署实例标识,Reaper 默认关闭且 dry-run,删除前再次核验 State。这样既处理日常闲置,也能处理 OOM 和部署中断后的跨进程遗留。
如果继续追问,我会强调:这不是“定时杀容器”。它的关键是所有权证明、状态对账、保护期、二次检查和默认不删除。
结语
沙箱隔离了用户执行,但不会自动消除资源生命周期问题。只要远端实例可以比本地进程活得更久,就必须设计“谁拥有它、何时仍在使用、异常后如何找回或回收”的完整闭环。
把回收做成分层、可观测、可 dry-run 的机制,才能让 Agent 的执行能力在长期运行中保持可控,而不是把内存风险留给下一次 OOM。