Agent Sandbox 规模化存储方案:基于 JuiceFS 的架构实践 📅 发布时间:2026/9/8 14:17:46 👁 浏览次数: 去年底我们把 Agent Sandbox 往集群化方向推的时候我一度以为最难的会是 GPU 调度、并发上限或者提示词链路。三轮压测跑下来真正让平台卡住的反而是存储沙箱启动要拉代码、装依赖、读数据集执行过程中要写中间状态、生成物和 trace 日志每一个动作都在和文件系统打交道。最后我们选择 JuiceFS 作为存储底座逐步搭起了一套能支撑 2000 个并发沙箱的方案。这篇文章把选型逻辑、目录规划、参数调优和踩坑记录完整写出来给正在做 Agent Sandbox 规模化的朋友一个可参考的路线。1. 沙箱数量一上来瓶颈果然先卡在“文件能看到”这件事上1.1 沙箱在隔离什么又必须共享什么Agent 沙箱的本质是给模型生成的代码和工具调用一个可执行、可观测、可回滚的隔离环境。最常见的落地形态就是 Kubernetes Pod 或者独立容器沙箱内跑的是临时进程树、环境变量、本地依赖这些本不需要共享。但一旦任务要跨节点迁移、失败后要恢复、多个 Agent 要协作那些被容器隔离“看不到”的数据就成了刚需代码仓库、依赖包缓存、模型权重、任务中间产物、结果文件。单机阶段这些可以用本地盘凑合规模上来之后任何节点都可能被调度到文件必须“跟着任务走”而不是“跟着机器走”。这是我们最开始的认知盲区也是后面一系列改造的起点。1.2 规模化之后的四类存储压力工作区持久化。多步 Agent 任务可能执行几分钟到几小时中途任何一个沙箱被回收或者节点故障如果工作区没有持久化前面的上下文就全丢了。对于带状态的 Agent 任务来说这不是“重试一次”能解决的问题而是整个任务直接断掉。只读依赖分发。模型生成的脚本大概率会执行pip install、npm install还会读取模型权重和数据集。如果每个沙箱各下各的网络带宽和对象存储请求费用都会在第一时间爆掉。产物与日志回传。沙箱隔离做得越彻底、回收销毁越快就越需要一个统一的位置来接收结果文件而不能等到容器销毁前再手动拷文件。任务协作。多个 Agent 处理同一件事时经常需要共享状态文件、中间结果、锁文件。这要求写入后对其他沙箱可见一台机器上的本地路径完全做不到。1.3 为什么 CPU/内存调度解决不了这层压力调度器解决的是“任务放哪台机器”存储解决的是“不管任务在哪台机器都能看到同一份数据”。这两件事如果硬揉在一起就会变成几十 GB 的 rsync 在集群里横飞。任务调度一多网络先被打满机器磁盘也长期被临时副本占据。最直接的表现就是调度器明明把任务调度到了任意空闲节点沙箱却因为看不到之前写的数据而无法启动最终只能靠“固定节点”牺牲调度灵活性。所以存储层的能力必须独立出来否则规模永远卡在几十个沙箱。2. JuiceFS 在这个体系里到底扮演什么角色一个挂载点解决三类数据2.1 为什么不是本地盘、NFS、S3 直连选型的时候我们认真对比过几类方案结论不是一个方案全面碾压另一个而是场景匹配度的问题。方案优势规模化后的核心问题本地盘 / hostPath延迟低、零改造无法跨节点共享任务迁移需要搬运数据节点故障直接丢状态传统 NFSPOSIX 兼容、团队熟悉中央节点成为性能和容量瓶颈小文件并发差inode 容易耗尽对象存储直连容量无限、成本低没有完整 POSIX 语义追加写、目录 rename、文件锁都要自己实现JuiceFS对象存储容量 数据库元数据 本地缓存需要额外维护缓存盘和元数据引擎但组件远少于自建 Ceph最终选 JuiceFS核心原因是它把容量和性能拆开了容量用对象存储承担扩 MinIO 或者扩公有云 OSS 都容易元数据和目录树放进 Redis/TiKVPOSIX 语义是完整的并且能保证 close-to-open 一致性。再加上本地缓存吸收热点数据沙箱读依赖包和模型权重时不用每次都打到对象存储。2.2 JuiceFS 的工作方式为什么天然适配沙箱JuiceFS 客户端会把文件拆成数据块写入对象存储而目录树、inode、文件大小、权限这些元数据单独放进数据库。客户端通过 FUSE 或者 CSI 挂载成一个普通目录对应用来说就是一个本地文件夹。这套架构对应到沙箱场景有几个直接好处多个节点上的沙箱看到的是同一棵目录树调度到任何节点都能找到工作区。任务交接时有明确的一致性语义A 沙箱写入并 close 文件后B 沙箱 reopen 一定读到最新内容这对多 Agent 协作非常重要。删除文件不是立刻物理删除而是先进回收站能防御脚本误删。只要对象存储部署在内网比如自建 MinIO所有数据都不用出内网审计和带宽都可控。2.3 目录规划三类数据怎么摆才不乱JuiceFS 能解决“共享”但解决不了“乱放”。我们一开始就没有把所有东西都塞进一个挂载点而是按生命周期分成几个区域/agent/shared工具链、依赖包缓存、模型权重。绝大多数场景是只读目录层级上直接设只读权限。/agent/workspaces/{task_id}每个任务独立工作区任务开始创建任务结束归档或者清理。/agent/artifacts/{task_id}结果与日志只写不删进入归档期后由离线任务统一迁移。/tmp不走 JuiceFS回到 tmpfs 或本地盘。这样划分的原因很直接包缓存和模型权重是热点放共享目录后所有沙箱能在同一套缓存中命中冷启动从分钟级变成秒级而沙箱内的临时文件如果也走 JuiceFScreate/open/unlink 产生的元数据请求会把引擎打爆这是后面碰到的第一个大坑。3. 元数据引擎选型小文件并发怎么压垮 Redis以及 TiKV 的分水岭3.1 瓶颈是如何暴露出来的第一阶段我们用的元数据引擎是 Redis跑开发环境完全没问题。压力到 300 个并发沙箱时现象非常典型沙箱启动时间从 5 秒变成 40 秒FUSE 操作日志里大量getattr、readdir变慢但对象存储带宽并不高。用juicefs stats看到 meta ops 延迟明显上涨再配合 Redis 的INFO发现 ops/s 已经逼近单线程瓶颈。追根溯源问题出在pip install和npm install这类小文件密集操作上一个依赖包几十上百个文件每个文件都伴随 create/open/write/close/unlink 一系列元数据事务。对象存储层面其实没压力压力全在元数据引擎。3.2 沙箱负载下的元数据访问特征沙箱场景和一般的 Web 服务不太一样它有几个非常明显的特征高 create 比例安装依赖、解压工具链时会一次性创建大量新文件。高 unlink 比例临时文件、缓存文件生命周期很短创建完用一下就删。长目录 readdir应用启动时经常递归扫描整个依赖目录一次扫描可能列举成千上万个条目。元数据不断建删长期跑下来Redis 内存碎片和 key 数量都会持续增长。这些特征决定了我们不能只看对象存储容量元数据 QPS 才是真正决定扩展上限的指标。3.3 什么时候继续用 Redis什么时候换 TiKV根据我们的实测经验可以给一个比较实用的分水岭元数据条目在 2000 万以下并发元数据 QPS 长期低于 5 万Redis 足够。成本可控运维也简单。条目数继续涨或者无法接受“所有元数据常驻内存”的成本就考虑 TiKV。TiKV 的元数据不需要全部放内存扩展性更好适合海量小文件的场景。迁移的时候要注意JuiceFS 支持更换元数据引擎但这不是一个随时能做的操作需要规划专门的停写窗口把旧引擎里的元数据完整导出再导入。我们当时把迁移和一次发布窗口绑在一起提前做了演练避免生产环境现场翻车。另外提醒一句用 Redis 时必须配置 AOF并且别用单节点。沙箱产生的都是小文件元数据量很大一旦丢失整个文件系统就废了这个风险不值得冒。4. 挂载参数和缓存策略我们怎么调才扛住并发写4.1 一份经过压测的挂载参数清单下面这组参数是我们支撑高并发沙箱场景时实际使用的配置基于社区版 JuiceFS各位可以参考但不要照抄因为节点数、对象存储能力、缓存盘性能都会影响最终效果。juicefs mount \ --cache-dir /var/lib/juicefs/cache \ --cache-size 102400 \ --free-space-ratio 0.15 \ --max-uploads 128 \ --buffer-size 1024 \ --writeback \ --attr-timeout 1 \ --entry-timeout 1 \ --negative-timeout 0 \ --open-cache 0 \ redis://:passwordmeta-redis:6379/1 \ /agent4.2 每个参数背后的取舍逻辑--attr-timeout和--entry-timeout控制元数据缓存时间。默认 1s 在跨沙箱共享场景下勉强够用它平衡了元数据引擎压力和一致性。如果业务要求其他沙箱写入后立刻可见可以调到 0但元数据 QPS 会直接上升一个量级需要预估引擎能不能扛住。--negative-timeout 0是我强烈建议打开的参数。负缓存意味着“文件不存在”的结果也会被缓存在分布式场景下极容易出现 A 沙箱刚创建目录B 沙箱立刻访问却报 ENOENT 的问题。把负缓存关掉能省掉一大批间歇性文件找不到的故障。--writeback是把写入集中在本地缓存盘异步上传对象存储。这个模式对沙箱临时数据非常合适写入延迟明显降低吞吐也上去了。代价是如果缓存盘损坏未上传的数据会丢。我们的处理方式是把 JuiceFS 拆成两个挂载点一个给 workspace 和 shared 开 writeback另一个挂只读产物目录不开 writeback。所有需要严格持久化的数据都不走 writeback 挂载点。--free-space-ratio 0.15的意思是缓存盘剩余空间低于 15% 时主动清理旧缓存。这个值不要设太小否则缓存盘一满所有沙箱的写操作都会被卡住。--max-uploads 128和--buffer-size 1024关系到并发上传和客户端缓冲。沙箱数量上来之后单客户端默认的上传并发数可能不够需要根据对象存储的带宽适当调大。Buffer 太小时高并发写入会出现 IO 拥塞调大后稳定很多。4.3 缓存预热与包管理器缓存共享JuiceFS 的读缓存是“读过的数据才缓存”所以第一次读大量文件时还是会有冷启动尖峰。我们的做法是给关键 shared 目录做预热for f in $(find /agent/shared/model -type f); do cat $f /dev/null; done这个脚本在集群空闲窗口分批跑比如凌晨触发把模型权重和常用工具链提前拉进各节点的缓存盘。预热要控制并发不要一次性把所有文件都读一遍否则瞬间把带宽打满影响线上任务。包管理器缓存共享是性价比更高的手段。我们把PIP_CACHE_DIR指向/agent/shared/pipnpm cache dir指到/agent/shared/npm第一次沙箱安装包时慢一点后续所有节点上的沙箱都命中缓存整体冷启动速度提升非常明显。5. 从几十个沙箱扩到两千个我经历的四个改造阶段5.1 阶段一hostPath 手动迁移先跑通流程最初每个沙箱在节点本地/data下建工作目录调度基本靠固定节点。任务要换节点时就只能人工 rsync。一个工作区几个 GB 非常常见传输时间长而且节点一旦宕机整个任务状态直接归零。当时团队的想法是“加一块更大的盘”就能解决但很快就发现这不是容量问题是跨节点共享能力的问题。这个阶段帮我们验证了业务链路可以跑通但离“规模化”差得很远。5.2 阶段二NFS 集中存储性能和运维开始报警为了支持更多节点我们接了一个现成的 NFS server把工作区统一放到 NFS 上。前两周很爽开发不再关心文件在哪台机器但压测到 200 多个沙箱同时执行依赖安装时问题集中爆发小文件 create 特别慢目录列表也卡顿。NFS server 的 inode 和文件句柄被占满新沙箱创建目录都失败。NFS server 单点故障挂掉之后整个平台所有沙箱的 IO 全部报错。想扩容只能买更贵的存储设备成本线性上升但性能并没有质变。这个阶段让我们彻底想明白一件事规模化场景需要的是“元数据和数据分离”的架构。数据可以放便宜的容量存储元数据需要高性能数据库来扛。JuiceFS 正是这个思路。5.3 阶段三JuiceFS 接入 CI/构建类沙箱先接受“双跑”引入 JuiceFS 后我们没有立刻全量切换而是先拿 CI/构建类沙箱试点。选择这类任务的原因任务短、失败可以重跑、对缓存价值要求高。双跑期间CI 沙箱的工作区迁移到/agent/workspaces宿主机挂载 JuiceFS容器通过 hostPath 进入。双跑结论非常明确任务失败恢复率大幅提升依赖缓存命中率上来了重复下载基本消失。而且 JuiceFS 的参数和文档都比较清楚我们不需要维护复杂的存储集群内网本来就是 MinIO接入成本比预期低很多。5.4 阶段四全量沙箱默认挂载试点稳定后我们进入全量阶段所有 Agent Sandbox 创建时默认挂载/agent每个任务工作区放在/agent/workspaces/{task_id}容器内/tmp用 tmpfs不经过 JuiceFS。这里要特别说明挂载方式。我们没有给每个容器都直挂 FUSE而是在 K8s 节点上统一挂载 JuiceFS再通过 hostPath 加 subPath 把对应目录注入沙箱容器。这样容器本身不需要/dev/fuse也不需要特权安全层面容易过gVisor 这类的强隔离运行时也不会遇到兼容问题。全量后的收益是分层的调度器不再受存储位置限制任务可以调度到任意空闲节点。Agent 任务中断后在另一个节点上重新拉起工作区原样恢复。日志和产物实时可见运营同学不用再“进容器掏文件”。模型权重、依赖缓存全局共享冷启动时间大幅下降。5.5 扩缩容之前先算清楚三笔账从经验看每扩一批沙箱之前都要先算清楚三笔账否则容易在瓶颈处翻车。元数据 QPS并发沙箱数乘以单个沙箱的元数据操作数。比如预期增加到 1000 个并发沙箱每个沙箱每秒产生 50 次元数据操作那就是 5 万 QPS要确认元数据引擎能扛得住。对象存储请求数小文件直传对象存储会产生大量 PUT/GET 请求开启客户端缓存和合理的写回模式后这个数字会大幅下降。带宽大模型权重、数据集一次下发会占用大量带宽缓存盘的容量和网络规划要先做好避免多个节点同时重复拉取同一批数据。6. 复盘规模化沙箱存储要避开的五个坑6.1 回收站背的锅删除的临时文件没立刻腾出空间JuiceFS 默认开启回收站删除的文件不会立刻物理消失而是会继续保留一段时间。我们大规模清理沙箱 workspace 时发现对象存储容量不减反增排查才知道是被回收站扣住了。处理方式很简单对临时目录关掉回收站或者把--trash-days调成 0对 artifacts 这类需要误删保护的目录保留回收站。同时定期跑juicefs gc和juicefs fsck保持文件系统健康。这个坑最容易在“第一次大清理”时爆发提前做好容量监控就能及时发现。6.2 缓存盘写炸所有沙箱跟着卡我们有一批节点的缓存盘是 512G SSD理论上够大但在多个沙箱同时跑数据清洗任务时被写满了。原因有两个写缓存需要空间读缓存也在同一块盘上抢空间--free-space-ratio设置太小JuiceFS 还没来得及清理缓存写请求已经堵住。解决思路是三层缓存盘不跟系统盘共用单独挂大容量 NVMe。--free-space-ratio保持在 10% 到 15% 以上给写入留足缓冲。容器内临时文件用 tmpfs不落到 JuiceFS 缓存盘缓存盘压力立刻下降一大截。6.3 FUSE 进容器的权限和安全上下文限制刚开始我们想在 K8s 里让每个沙箱容器直接挂载 JuiceFS CSI这需要向容器暴露/dev/fuse还要给 SYS_ADMIN capability。在安全要求高的集群里这个方案过不了审批一些强隔离的沙箱运行时根本不支持 FUSE。最终我们采用“节点级挂载 hostPath 注入”把/agent暴露给容器。容器内进程感知不到 JuiceFS它只看到一个普通目录所有 FUSE 进程都运行在宿主层。这个方案牺牲了一点点“每个沙箱独立挂载”的隔离粒度但在大规模多租户环境下安全性和可运维性明显更好。6.4 跨沙箱协作时忽略 close/flush 语义有段时间我们经常收到“文件写完了另一个沙箱就是读不到”的反馈。排查下来问题不在 JuiceFS而是写端进程没有 close 文件就通知下一个 Agent。JuiceFS 保证的是 close-to-open 一致性一个客户端 close 文件之后另一个客户端再 open 一定能看到最新内容。但如果你不 close、不 fsync数据还停留在写端的文件描述符缓冲里任何文件系统都保证不了实时可见。这个语义需要在代码层面强制约束尤其是“写完立刻交接”的关键路径必须显式 close fsync。6.5 监控只看容量没盯元数据 QPS 和缓存命中率很多存储监控只看容量但在沙箱场景里容量是最不敏感的指标。更有用的是 JuiceFS 的 fuse ops、meta ops、cache read/write bytes、缓存命中率以及元数据引擎自身的 QPS、延迟、key 数量。我们现在的告警面板会采集这几类数据JuiceFS 侧fuse 操作延迟、meta 操作延迟、缓存命中率。元数据引擎QPS、长尾延迟、内存、key 规模。对象存储API 请求次数、流量、错误码。宿主机缓存盘容量、inode、磁盘 IO。经验值上读缓存命中率如果长期低于 70%就要排查是不是热点数据没有预热或者缓存盘容量不够导致频繁驱逐。meta ops 出现长尾优先看元数据引擎的资源占用不要先去折腾缓存参数。这次改造下来我越来越觉得 Agent Sandbox 规模化的本质是把计算资源变成无状态的把状态完整地挪到存储层。JuiceFS 帮我们完成了最后这一步。如果你也在做类似的事情我的建议是从目录规划入手先让数据分类跑通再逐步推量不要一上来就全量替换先让一个 CI 沙箱吃满 JuiceFS你看一遍监控再放大规模心里就完全有底了。后续我们还在探索数据集缓存和分布式缓存组的方向等跑出数据再来补充。