ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

生产级Agent沙箱设计:选型、持久化与执行协议全解析

生产级Agent沙箱设计:选型、持久化与执行协议全解析 1. 为什么本地跑通的沙箱一上生产就翻车先说个我们踩过的场景。最开始做 Agent 的时候团队里每个人都在自己电脑上跑代码沙箱主要就是拿 Docker 跑个容器把 LLM 生成的代码丢进去执行本地看起来一切正常。但等到要接生产环境的时候问题全冒出来了沙箱起停怎么和线上任务调度打通容器里头跑出来的数据存在哪任务超时了谁负责杀掉多租户隔离怎么保证日志怎么回溯本地那种“用完就扔”的玩法根本接不住生产流量。这个标题里说的“沙箱接入生产”本质上不是把沙箱装上就行而是要把沙箱当成一个生产基础设施来建设涉及三个躲不开的问题选型、持久化、执行协议。三者对应的是用什么东西隔离、跑完产生的数据怎么办、调用方怎么和沙箱通信。本篇就把我们在这三条线里的思考、踩坑和最终落地方案完整讲一遍给正在做 Agent 生产化的团队一个可参考的路径。1.1 开发环境的沙箱和生产的沙箱是两种东西很多团队对沙箱的认知停留在“能跑 LLM 生成的代码就行”。本地开发时沙箱的核心价值是防止你把开发机搞坏或者防止不安全的代码读到你本地的环境变量。这个阶段你用subprocess开个进程、用 venv 隔离 Python 环境甚至直接docker run --rm临时拉一个容器都够用。但生产环境的沙箱要解决的问题完全不同你不信任这段代码它不是你自己写的是模型生成的可能是错的、恶意的、或者无限循环的。你不只服务一个用户可能有几十上百个 Agent 实例并发执行任务每个任务都可能产生文件、读写网络、申请资源。你必须能解释一切任务为什么失败、跑了多久、在哪一步出错、产出物在哪这些都要能回溯审计。你必须处理异常生命周期调用方超时、进程崩溃、机器重启、宿主机资源耗尽沙箱都不能留垃圾。所以生产环境的沙箱本质是一个有界且可观测的执行容器。它必须有清晰的生命周期、资源配额、网络策略、持久化方案和审计日志。如果只把开发用的那套 Docker 脚本搬上线大概率第一周就出事故。1.2 从四个维度重新定义“生产级沙箱”这里我建议所有团队在选型之前先把“生产级沙箱”拆成四个可量化的维度避免被各种方案的宣传带跑安全隔离代码能访问到宿主机多少资源。理想目标是“即使代码是恶意的也无法接触到宿主内核和其他租户”。生命周期管理能不能自动回收、强制终止、异常恢复。这决定了你线上会不会积累一堆僵尸容器。持久化执行现场、产物、日志能否在容器销毁后继续存在且可以被外部系统检索。可观测性调用方能否实时拿到状态、日志、资源使用情况能否在出错时完整回溯。这四个维度也是后面所有选型和设计的评判标准。你会发现很多“看起来挺火”的沙箱方案在这四个维度上都有明显的短板而短板往往会在流量一起来的时候集中爆发。2. 花椒的沙箱选型过程容器、微虚机还是 WASM选型这件事我们当时大概调研了两周候选方案集中在四条路线普通 Docker 容器、gVisor 这种用户态内核隔离方案、Firecracker 这类微虚机、以及 WASM 运行时。另外也看了一眼商业的云端代码沙箱服务但因为要对接内部 Agent 平台、数据要留在内网商业方案先被排除。下面这张表是我们当时的对比视角方案隔离级别启动速度内存开销Python/Node 生态兼容性运维复杂度DockerrunC内核共享CGroupNamespace秒级低好低gVisorrunsc用户态内核系统调用拦截秒级中较好中Firecracker硬件虚拟化KVM毫秒到秒级高每个VM固定开销好高WASMWasmtime指令级沙箱毫秒级极低差C扩展难兼容中2.1 三个候选方案的横向对比先说普通 Docker。它的优势是生态最完善、镜像随便拉、团队熟悉。但问题是它的隔离依赖内核的 Namespace 和 CGroup一旦恶意代码利用内核漏洞就有可能逃逸到宿主机。尤其是我们的使用场景是执行不可信代码普通容器承担的风险太高。网上有很多关于 Docker 默认配置安全性的讨论核心结论就是默认的 Docker 适合隔离“不可信程度不高”的进程不适合隔离“可能被攻击”的代码。再说gVisor。它的思路是截获所有系统调用在用户态模拟内核行为所以 Guest 程序拿不到真实宿主机内核的接口逃逸难度高很多。代价是性能有损耗特别是 I/O 密集型的代码运行时间会比其他方案慢不少。但对 Agent 场景执行的大多是数据分析、脚本计算、调用 API 的任务不是高并发网络服务gVisor 的损耗完全可接受。另一个优点是它兼容 Docker 生态镜像不用改docker run --runtimerunsc就能切过去迁移成本很低。最后提一句Firecracker。它用 KVM 虚拟化每个实例都是一台微虚拟机隔离性最强但在我们的场景里有个致命问题内存开销固定每个微虚机都要分配独立的内核内存密集任务场景下宿主机资源浪费严重而且启动流程比容器重调度延迟不好控。至于WASM最大的短板是生态很多 Agent 任务要跑 Python 包比如 pandas、numpy这些包依赖原生系统库WASM 环境很难直接兼容除非对每个依赖做适配成本太高。2.2 我们最后选了什么以及为什么放弃另外两个最终我们选了containerd gVisorrunsc作为运行时K8s 只管编排。没上 K8s 的默认 runC而是把 RuntimeClass 指向 gVisor。调度层用 K8s 的 Pod 模型每个沙箱任务对应一个短生命周期的 Pod任务结束就销毁。这个组合所图的是三条安全边界够gVisor 拦截系统调用即使模型生成的代码不怀好意也拿不到宿主机内核接口攻击面小了很多。不用改镜像开发者不用关心沙箱底层是什么平时怎么写 Dockerfile 就怎么写兼容成本几乎为零。调度能力白嫖K8s 自带资源配额、优雅终止、健康检查、滚动发布这些生产环境必需的能力不用自己再造轮子。放弃 Firecracker 的原因可以再说细一点。除了内存开销我们还测过它的启动链路要设置网络、挂载 rootfs、启动 guest OS整体到“可以执行命令”的时延比容器方案高一个量级。Agent 任务的特点是短任务多、单任务执行时间常常只有几秒到几分钟启动开销占比太大会直接影响吞吐。我们当时的压测数据是runsc 冷启动到容器内第一条命令执行大概 400~600msFirecracker 冷启动在 1.5s 以上。这个差距在短任务场景下很难忽略。2.3 选型时容易忽略的隐性成本选型只看技术指标是不够的有四个隐性成本我们差点踩进去写出来提醒各位镜像仓库的带宽和存储。生产环境不能像本地一样每次都docker pull必须建内网镜像仓库做镜像分层缓存。Agent 的镜像更新频率很高如果每次都完整推送带宽很快就会打满。镜像安全扫描。不可信代码要跑在不可信镜像里镜像本身也要扫描漏洞。我们用 Trivy 挂在 CI 里高危漏洞直接阻断发布。基线镜像维护。团队里每个人都有自己的依赖习惯如果无人统一基线镜像最终会变成“每个 Agent 一个镜像”镜像仓库爆炸。我们后来固定了三个基线镜像Python 轻量版、Python 数据版预装 pandas/numpy/scikit-learn、Node 版所有 Agent 自定义镜像必须基于这三个版本扩展。沙箱 Pod 的冷启动和预热。gVisor 虽然快但创建 Pod、拉镜像、初始化网络仍然有延迟。我们在调度层做了一个缓冲池提前拉起闲置的沙箱 Pod任务到达时直接复用把调度时延从秒级压到几百毫秒。3. 持久化执行现场与执行产物如何落地沙箱是“用完即焚”的但数据不能跟着销毁。持久化是接入生产时最容易被低估的一环——因为本地开发时文件就写在本地磁盘上根本不需要考虑。生产环境里你面对的是容器随时可能被删、Pod 随时可能被调度到另一台机器、任务可能被取消、节点可能宕机如果数据不持久化任务执行到一半就全丢了。我们把持久化拆成三层来设计对应三种性质完全不同的数据文件系统层、状态层、产物层。3.1 文件系统持久化给每个沙箱挂一块“临时工作盘”第一层是执行现场的持久化。Agent 在跑代码时通常会在工作目录里生成中间文件、下载数据集、输出模型文件。这些数据在任务结束后有的要进对象存储、有的直接丢弃但任务执行期间必须保证无论 Pod 调度到哪台机器工作目录的读写都是同一份。我们的方案是给每个沙箱 Pod 挂载一块PVCPersistentVolumeClaim存储后端用 Ceph。每个任务独享一个 PVC任务结束后 PVC 不立刻删除保留 24 小时用于排障之后由异步任务清理。这里有一个关键点不要跨任务复用 PVC否则上一个任务残留的文件可能被下一个任务读取造成数据串扰。Ceph 的读写性能比本地盘差所以我们做了一个目录分级高频小文件放在沙箱的tmpfs内存盘上低频大文件放 PVC。业务代码通常感知不到这个差异因为容器内的路径是不变的只是底层挂载来源不同。3.2 状态持久化任务状态机必须落数据库第二层是任务状态。Agent 平台需要实时追踪每个任务的状态排队中、执行中、成功、失败、超时、被取消。这些状态不能只存在内存里一旦服务重启或者 Pod 被驱逐状态就丢了重试逻辑也没法做。我们建了一张任务状态表字段大概是任务 ID、Agent ID、沙箱实例 ID、状态、开始时间、结束时间、退出码、错误类型、资源用量。所有状态变更走同一个状态机服务通过数据库事务保证一致性。这里我踩过一个坑一开始图省事把状态直接存在 Redis 里结果线上有一段时间频繁出现“任务在跑但状态查不到”后来排查发现是 Redis key 过期时间设置太短任务一旦排队超过 TTL状态就凭空消失了。后来所有核心状态都改为落库Redis 只做热点查询缓存。3.3 产物持久化日志、文件、输出结果各归其位第三层是产物的持久化。沙箱里跑出来的东西分三类标准输出和标准错误我们走日志系统每条日志带任务 ID 和沙箱实例 ID支持按任务聚合查询。日志系统我们用的是自建的采集管道容器内日志通过 sidecar 方式采集禁止业务代码直接把日志写在容器内文件里——写了你也拿不出来除非主动上报。工作目录里的文件任务结束时Agent 通过协议接口主动声明哪些路径是产物平台把这些文件上传到对象存储并登记一个产物清单。这里没有采用全量镜像工作目录的方案因为中间文件太多全量上传浪费严重。任务结果数据结构化结果由 Agent 直接通过 API 写回自己的业务库不经过沙箱文件系统。这里有个很重要的原则沙箱里的数据默认是不可靠的可靠的数据必须通过明确的接口流出。我们定的规矩是沙箱执行完代码后如果要把结果传出来必须调用平台提供的回调接口或上传接口平台负责持久化。所有“自己写在容器里然后人肉去拷贝”的做法在接入生产的第一天就被禁止了。数据备份和恢复我们也做了功课。Ceph 集群本身有多副本但更关键的是对象存储的备份策略产物桶开启跨区域复制任务状态库每天全量备份到冷存储。我们有一次误删了某个测试桶因为备份策略完善半小时内就恢复了。这件事之后我把“恢复演练”写进了季度运维计划建议所有做 Agent 平台的团队都做一次类似的演练。4. 执行协议Agent 和沙箱之间怎么说话沙箱选型做完、持久化方案定了之后剩下最关键的就是协议。这个标题里说的“执行协议”在多数团队里其实是缺失的——大家默认“用 Docker 跑一下就行”没有定义 Agent 和沙箱之间怎么交互、任务如何提交、结果如何返回、取消如何生效。这些问题在单机开发时无感一到生产就全暴露了。我们最后走的是gRPC Protocol Buffers的路线接口层定义了一套完整的执行协议。下面讲核心设计。4.1 RPC 选型与核心接口定义为什么选 gRPC 而不是 REST主要是因为 Agent 和沙箱之间的交互是高频的、有状态且要双向流式的REST 做流式日志会很别扭而 gRPC 在这方面是原生优势。另外 gRPC 有完善的超时、重试、负载均衡机制正好配套生产环境。核心接口我们定义了六个方法看起来不多但覆盖了所有执行场景service SandboxService { // 提交一个执行任务 rpc SubmitTask(SubmitTaskRequest) returns (SubmitTaskResponse); // 查询任务状态 rpc GetTaskStatus(GetTaskStatusRequest) returns (TaskStatus); // 取消任务 rpc CancelTask(CancelTaskRequest) returns (CancelTaskResponse); // 获取任务产物清单 rpc GetArtifacts(GetArtifactsRequest) returns (ArtifactList); // 获取任务日志支持流式 rpc StreamLogs(StreamLogsRequest) returns (stream LogLine); // 上传执行产物 rpc UploadArtifact(stream UploadRequest) returns (UploadResponse); }每个提交请求里带一个TaskSpec包含了镜像名、执行命令、环境变量、资源限制、网络白名单、超时时间这些信息。这里我特别想强调超时时间必须由调用方显式传入不能只靠平台默认值——不同任务的耗时差异巨大有的任务几十秒就跑完有的要跑二十分钟统一超时必然导致误杀。我们当时设了一个平台上限默认 30 分钟但每次提交都必须带期望超时。4.2 任务生命周期与超时取消机制任务的生命周期我们定义为五态PENDING → RUNNING → SUCCEEDED / FAILED / CANCELLED另外加一个TIMEOUT状态本质上等价于超时触发的失败但单独标记让监控能区分“业务代码报错”和“没跑完被平台杀了”。超时取消机制是执行协议里最容易被忽略、但线上最重要的一环。我们的实现分两个阶段软取消先向沙箱内发送SIGTERM给业务代码一个收尾的机会。Agent 生成的代码很多时候不是恶意死循环只是逻辑没写好给它 5 秒清理临时文件、输出中间结果对体验很有帮助。硬终止软取消超时后发送SIGKILL强制结束然后由平台侧把整个 Pod 销毁。这里没有依赖沙箱内的进程去自杀——如果代码完全卡死进程根本处理不了信号回调只能靠平台兜底。资源限制也算一种“协议”。一个 Agent 实例默认最多创建 5 个并发沙箱任务超过就排队而不是直接拒绝。每个沙箱的 CPU 上限是 1 核、内存 2GB网络默认只允许内网域名白名单里的地址访问外部公网必须显式在 TaskSpec 里声明。这些限制不是在宿主机层面粗暴地--cpus1就完事而是要在平台侧做校验防止任务规格被恶意放大。4.3 错误码与可观测性约定错误处理也是协议的一部分。我们定义了一组统一的错误码所有 gRPC 返回都带 code、message 和 task_id。错误码分三层错误码含义常见原因INVALID_TASK请求不合法镜像不存在、命令为空、超时超出上限RESOURCE_LIMIT_EXCEEDED资源超限内存超限被 OOM、CPU 使用超过配额EXECUTION_FAILED代码执行失败业务代码抛异常、进程退出码非 0CANCELLED任务被主动取消用户取消、上游依赖失败INTERNAL_ERROR平台内部错误存储异常、调度失败、节点宕机这个错误码体系的价值在于下游调用方可以依据错误码做精确重试。比如RESOURCE_LIMIT_EXCEEDED重试大概率还是失败除非调大配额比如INTERNAL_ERROR重试是合理的比如EXECUTION_FAILED重试也要看是不是代码随机性引起的。没有这套约定调用方只能统一重试既浪费资源又解决不了问题。可观测性方面每个任务的关键节点都会打一条事件日志任务提交、开始执行、状态变更、软取消、硬终止、产物上传完成。这些日志统一进时序数据库用来画任务的链路图。我们内部管这个叫“一张图看全一个任务的一生”排障效率比之前强了不止一倍。5. 上生产前后要做的事灰度、配额与安全加固协议定完、代码写好不代表就能直接接生产流量。我们推进上线的时候分了三步走先灰度、再压测、最后开放全量。每一步都有一些值得记录的细节。5.1 灰度方案与回滚预案第一次接生产我们不敢直接让所有 Agent 实例全部走沙箱而是挑了两个内部 Agent 做试点一个负责周报数据汇总一个负责代码仓库的分析任务。灰度期间沙箱平台和旧执行方式直接在受控 VM 里跑并行运行流量按比例切。灰度要做的事主要有三件对比成功率。同一批任务分别用沙箱和旧方案跑看结果差异。我们发现一个有意思的问题沙箱里跑 Python 脚本时因为工作目录是空的很多脚本会隐式依赖“当前目录下已存在的文件”导致在沙箱里报文件找不到。这类问题在旧方案里根本不会出现因为脚本直接跑在宿主机的常驻目录里。最后我们规定所有 Agent 代码必须显式声明输入文件路径禁止依赖隐式文件。养成看日志的习惯。灰度期间要盯三类日志沙箱启动失败的、任务被取消的、资源超限的。这三类往往是配置问题而不是代码问题比如镜像标签写错、超时设置太小、内存配额不够。准备回滚预案。我们把沙箱平台的所有配置都纳管到 GitOps 流程里任何变更都有 commit 记录出问题可以直接回滚到上一个稳定版本。回滚预案不是写在文档里感动自己的要真的演练过至少一次。5.2 资源隔离和配额防止一个 Agent 拖垮全平台生产环境里最怕的不是单个任务失败而是一个异常任务把宿主机资源耗尽导致同节点其他任务全部陪葬。我们的做法是三管齐下CGroup 硬限制每个沙箱 Pod 的 CPU、内存、PID 数量都有硬限制。PID 限制特别重要否则一个 fork 炸弹就能让节点不可用。磁盘配额沙箱工作目录的写入量有上限超出直接报错。Agent 生成的代码经常会有无限写文件的 bug如果不限制几分钟就能写满一块盘。节点级水位线调度器在看节点可用资源时留出安全余量。比如节点内存 32GB实际最多调度到 24GB剩下 8GB 作为系统冗余。这一点是给 K8s 调度器和节点稳定性兜底的。配额还有一种非资源层面的维度调用频控。每个 Agent 每分钟最多提交 30 个沙箱任务超过直接返回限流错误。这是为了防呆防止业务侧代码因为循环写错把平台当成了无限计算资源。5.3 安全加固的几个细节安全这块我们从三个层面做了加固镜像层不允许 Agent 直接使用互联网上下载的任意镜像所有镜像必须经过扫描、签名、入库三道流程。运行时不使用特权模式不挂载宿主目录不允许--privileged。网络层沙箱 Pod 默认没有外网 IP出网统一走代理网关代理层做域名白名单和内容过滤。这里最有争议的是“包管理源要不要放开”我们的答案是只允许内网镜像源禁止直连公网 pip/npm。理由很简单不可信代码有可能下载恶意依赖内网源至少经过了审核和缓存。运行时层gVisor 本身拦截了大部分系统调用但还有一部分攻击面在文件中转和进程通信。我们关掉了容器内的 SSH、不要安装编译工具链……不对编译工具链还是要装的很多 Python 包要现场编译。这里我要说的是不要把宿主机内核模块暴露给容器所有内核模块相关的操作在 gVisor 下都会被拒绝这是特性不是 bug。日志脱敏也是一个容易被忽略的安全点。沙箱里跑的业务代码可能把敏感信息打到日志里我们在日志采集管道里加了脱敏组件对身份证号、手机号、Token 等模式做正则匹配命中的内容在存储前打码。这个功能上线后我们才敢把日志开放给业务方自助查询。6. 持久化方案落地时踩过的三个真实数据坑前面说了持久化的总体设计但这块在实际落地时踩过的坑比选型还多单独拿出来讲一下。6.1 任务执行中同步盘满了谁买单第一次压测的时候我们发现一个高频报错任务执行到一半PVC 说磁盘空间不足。原因是任务镜像的依赖包本身就占了不少空间加上部署时没区分镜像层持久化和工作目录持久化导致镜像层和工作目录共用同一块存储池。后来我们做了存储池隔离镜像层走本地缓存盘工作目录走独立 PVC依赖包的空间不再占用任务产物的配额。类似地读写日志也不占工作目录配额走的是平台日志通道。这种事本质上不是技术难题是容量规划没做细。上线之前一定要先做一个估算单任务平均产物大小、并发任务数、单节点磁盘最大容忍量然后倒推 PVC 池容量和回收频率。我们当时按日均 2 万个任务、平均产物 5MB 估算预留了 30 天的冗余才没在上线第一天就翻车。6.2 任务完成了产物却还锁在容器里第二坑是产物收割时机。任务状态变成SUCCEEDED之后平台立刻把 Pod 销毁但业务代码可能刚把文件写进工作目录还没来得及上报产物清单。这种“状态成功但产物丢失”的问题排查起来特别费劲因为日志里什么都查得到就是找不到文件。后来我们的协议里加了一个产物声明阶段业务代码在结束前必须显式调用UploadArtifact接口把产物上报平台收到上报确认后才允许任务进入SUCCEEDED状态。如果任务进程已经退出但产物未上报平台会标记ARTIFACT_LOST并在保留期内做一次工作目录扫描尝试找回。这个“先上报再成功”的顺序把产物可靠性从“碰运气”变成了“协议保证”。6.3 Agent 结果回写业务库需要做到幂等最后一个坑是在协议之外Agent 沙箱跑了业务代码如果结果要回写到 Agent 自己的业务库回写动作必须幂等。因为沙箱任务可能因为网络问题执行了两次或者重试时重复提交如果回写不幂等数据就会翻倍。我们团队内部定了一个规定所有 Agent 的执行结果都带一个全局唯一的 execution_id业务库以它做唯一键约束。第一次写入成功第二次写入会被数据库拒绝或转为更新。这个约定虽然不属于沙箱平台的代码范围但如果不写进执行协议文档里生产事故早晚找上门。7. 回到选型原点这些经验对中小团队意味着什么写到这很多人可能会问我们团队就三五个人没有专门的 infra 团队这套方案是不是太重了我的回答是如果只是给自己内部用确实可以从简单方案起手但三个原则不能丢。第一隔离层不能省。哪怕你最开始只有 10 个 Agent 在跑也尽量别用宿主机进程方式直接执行不可信代码。Docker 默认配置都比裸进程好一截如果任务确实不敏感可以先从 Docker 起手但只要任务可能接触到用户数据、内网资源gVisor 的投入就是值得的。第二持久化从第一天就用对象存储。不要等数据丢了再补。哪怕你的沙箱是docker run --rm跑完的结果也通过脚本传到对象存储这个习惯比任何架构都重要。第三协议先于实现。先定义好 Agent 和沙箱之间的接口再写沙箱的实现。哪怕一开始只用一个 shell 脚本模拟沙箱只要接口是稳定的后面换成 gVisor、Firecracker 甚至商业平台对上游都是透明的。我从这个项目里最深的一个体会是Agent 沙箱的价值不在于它能“安全地跑代码”而在于它让“让代码跑起来”这件事变得可管理、可解释、可恢复。选型给的是天花板持续化和协议决定的是地板生产环境的水平取决于地板而不是天花板。希望这篇文章能帮正在做 Agent 生产化的团队少踩几个坑把沙箱这个看似基础实则复杂的组件真正变成可依赖的基础设施。
返回列表