Agent 工程实习复盘 14|沙箱内存为什么会越跑越高:Idle Sweeper、Orphan Reaper 与分层回收

0 阅读12分钟

Agent 工程实习复盘 14|沙箱内存为什么会越跑越高:Idle Sweeper、Orphan Reaper 与分层回收

本文基于一次测试环境的沙箱资源泄漏治理整理。文中使用通用的 Agent、Gateway、Runtime 和 CubeSandbox 等技术名称,不涉及具体项目、公司、用户或服务器标识。

本文是第 01 篇沙箱接入与架构改造的后续,重点只讨论运行后的资源生命周期、异常中断和安全回收,不重复介绍沙箱的基础能力。

14.png

一、先说结论

沙箱的资源回收不能只依赖进程正常退出时调用一次 close()

在真实测试环境中,服务重启、OOM、部署中断或代码异常都可能让本地 Runtime 先消失,但远端 MicroVM 仍持续运行。此时它们既不在当前进程内存里,也未必还能从本地状态文件被正确找到,却继续占用 CPU 和内存。

这次治理最终拆成两层:

  1. Idle Sweeper:回收当前进程还认识、当前没有任务执行、并且长时间无活动的沙箱。
  2. Orphan Reaper:通过远端实例列表、持久化状态和实例元数据三方对账,识别异常遗留实例;默认只记录候选,不直接删除。

核心原则是:自动回收可以积极发现,但删除必须保守执行。

二、事故信号:不是“内存高”,而是资源所有权已经断了

当时测试环境出现的不是短时间峰值,而是持续累积的资源占用:

  • 总内存约 31 GiB,其中约 24 GiB 已使用,swap 约 1.9 GiB 被打满。
  • RSS 排名前列是沙箱运行时的 shim 进程,单实例约 0.5 GiB2.3 GiB
  • 远端控制面显示约 36 个沙箱、约 28 个仍处于 running 状态。
  • 这些 running 实例合计约占 20 GiB RSS。
  • 当前本地状态文件只记录约 14 个沙箱,远少于远端运行数量。
  • 内存压力最终触发 OOM,影响了 Gateway 和 Agent Runtime 等正常服务。

最关键的证据不是“有很多进程”,而是:远端正在运行的沙箱数,明显大于当前进程和持久化状态能够认领的数量。

因此,单纯调小 keepalive、重启服务、或者只清当前内存中的对象,都不能解决已经脱离归属关系的历史实例。

三、先分清四种状态:不是所有没活动的沙箱都是孤儿

回收前必须先定义状态,否则自动清理很容易误杀正常任务。

状态远端实例本地持久化 State当前进程内存是否可回收
正在执行running有 active operation不可回收
正常闲置running有对象Idle Sweeper 超时后可回收
managed orphanrunning通过保护期和元数据验证后可回收
stale staterunning有,但长期未更新通过超时和二次检查后可回收
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_operations0

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() 的情况。它同时读取:

  1. 当前远端控制面可列出的实例。
  2. 本地持久化的沙箱 State。
  3. 当前 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:它会调用远端 killclose,一旦识别错误,损失的是用户正在运行的环境。

因此配置默认值采用两层保护:

orphan_reaper_enabled = false
orphan_reaper_dry_run = true

正确的上线顺序不是直接开启删除,而是:

  1. 先启用 reaper,但保持 dry-run,收集候选日志。
  2. 人工核对候选的元数据、创建时间、owner hash、远端状态和业务活动。
  3. 调整 grace period 与 stale-state 阈值。
  4. 在低风险窗口将 dry-run 改为 false。
  5. 持续观察删除数量、失败数量、误杀告警和内存曲线。

尤其不能根据实例名字、数量过多或“看起来很旧”就删除。可靠回收依赖的是可验证归属、时间窗口和二次检查,而不是经验判断。

八、实现中的几个安全细节

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、远程开发环境等所有“本地有引用,远端有实体”的资源。

  1. 资源必须有可验证的归属标签。 不能靠名称猜来源。
  2. 正常回收和事故回收是两条链路。 一个处理进程内闲置,一个处理跨重启遗留。
  3. State 既可能缺失,也可能过期。 对账必须同时容忍这两类错误。
  4. 删除前的二次检查很重要。 扫描结果只是候选,不是删除授权。
  5. dry-run 是产品能力,不是临时日志。 它让删除策略能在真实环境中被观察和校准。
  6. 监控应关注资源数量和归属差值。 远端 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。