
1. 从一次线上事故说起为什么沙箱接入生产远比想象中复杂去年年底我们团队在做一个代码执行类 Agent 产品内部测试环境跑得好好的一上生产就出事了。用户提交的一段 Python 脚本在沙箱里跑了 47 秒还没结束把执行队列堵死后面排队的请求全部超时。更麻烦的是那个沙箱容器在超时后没有被正确回收磁盘上残留了 2GB 的临时文件第二天运维同事才发现。这件事让我彻底意识到Agent 沙箱接入生产难点从来不在能不能跑起来而在跑得稳、收得回、查得到。市面上讲沙箱原理的文章很多但真正把选型、持久化、执行协议这三件事串起来讲清楚的不多。这篇就把我们踩过的坑、做过的取舍、最后落地的方案完整摊开讲一遍涉及 Go SDK 的接入细节、执行协议的设计、以及持久化层怎么选。如果你正在做 Agent 开发尤其是涉及代码执行、工具调用这类需要隔离环境的能力这篇内容应该能帮你少走至少两个月的弯路。不管你是刚接触 Agent 沙箱的新手还是已经在做生产化改造的老手我都会尽量把为什么这么选讲透而不是只丢一个结论。2. Agent 沙箱到底在解决什么问题先想清楚边界2.1 沙箱不是虚拟机别把它当万能隔离很多人一上来就问用 Docker 还是 Firecracker其实这个问题问早了。先要搞清楚沙箱在 Agent 架构里承担什么角色。Agent 执行代码的场景通常有三类一是模型生成的代码需要验证正确性比如让它写个排序算法然后跑测试二是工具调用需要执行系统命令比如文件操作、数据处理三是多 Agent 协作时某个子 Agent 需要独立的工作空间。这三类场景对隔离级别的要求完全不同。第一类只需要进程级隔离加资源限制就够了因为代码是模型生成的恶意性有限主要防的是死循环和内存爆炸。第二类需要文件系统隔离因为涉及真实文件读写不能让 Agent 碰到宿主机的敏感目录。第三类需要网络隔离加状态隔离因为多个 Agent 并行时不能互相干扰。我们最初图省事所有场景都用同一套 Docker 容器方案结果发现第一类场景的启动开销太大——每次执行都要拉容器、初始化运行时平均 800ms 起步而模型生成代码验证这种场景一天要跑几十万次光启动开销就吃掉大量资源。后来做了分级轻量场景用进程池加 seccomp 限制重量场景才走容器。选型的第一步不是选技术是给场景分级。分级错了后面怎么优化都是白费。2.2 沙箱的四个核心能力隔离、限制、观测、回收把沙箱拆开看它必须提供四个能力缺一个在生产环境都会出问题。隔离是基础包括进程隔离、文件系统隔离、网络隔离。进程隔离保证 Agent 代码不能影响主进程文件系统隔离保证它只能看到自己的 workspace网络隔离保证它不能对外发起意外请求。这三层里网络隔离最容易被忽略但生产环境必须做否则 Agent 可能被诱导去访问内网服务。限制是保命符包括 CPU 时间、内存上限、磁盘配额、执行时长。这里有个经验值CPU 时间限制要比墙钟时间限制更严格因为有些代码会开多线程绕过墙钟限制。我们现在的配置是墙钟 30 秒、CPU 时间 15 秒、内存 512MB、磁盘 100MB超过任何一个维度直接 kill。观测是排查问题的眼睛。沙箱里发生了什么必须能拿到日志、退出码、资源使用峰值。没有观测能力的沙箱出问题就是黑盒只能靠猜。回收是最容易被低估的。沙箱执行完必须彻底清理包括进程、临时文件、网络连接、挂载点。我们那次事故就是回收没做好容器停了但 volume 没删。2.3 生产环境和测试环境的三个关键差异测试环境跑通不代表生产能用差异主要在三个地方。并发量级不同。测试时可能就几个并发生产可能是几百上千。这时候沙箱的创建和销毁开销会被放大连接池、对象池这些优化手段必须上。故障模式不同。测试时挂了就重启生产时挂了要考虑正在执行的请求怎么办、状态怎么恢复、用户怎么感知。这就要求沙箱执行必须支持幂等重试和状态查询。安全边界不同。测试环境可以宽松生产环境必须假设所有输入都是恶意的。模型生成的代码可能包含rm -rf /、可能尝试读取环境变量、可能发起 SSRF 攻击。沙箱必须默认拒绝一切只开放明确需要的权限。3. 选型实战从 Docker 到 microVM 的取舍逻辑3.1 四种主流方案的横向对比我们把当时能选的方案列了个表从六个维度打分。这个表后来成了团队选型的决策依据也帮我们避免了很多争论。方案隔离级别启动开销资源占用生态成熟度运维复杂度适用场景进程池 seccomp进程级极低10ms极低高低轻量代码验证Docker 容器容器级中300-800ms中极高中通用工具调用microVMFirecracker 类虚拟机级较高1-3s较高中高高安全要求WASM 运行时语言级极低5ms极低中低特定语言沙箱选型的核心逻辑是隔离级别越高开销越大所以要用在真正需要的地方。我们最后采用的是混合方案——轻量验证走进程池通用执行走 Docker涉及敏感操作的走 microVM。这里要特别说一下 WASM。它的启动开销极低隔离性也不错但问题是语言支持有限Python 生态在 WASM 上跑起来很别扭很多 C 扩展用不了。如果你的 Agent 主要执行 JavaScript 或 RustWASM 是很好的选择如果是 Python 为主还是老老实实用容器。3.2 为什么我们最终选了 Docker 作为主力microVM 的隔离性确实更好但运维成本太高。我们团队当时只有两个运维要同时维护 K8s 集群和 microVM 的调度根本忙不过来。而且 microVM 的启动开销在 1 秒以上对于需要频繁执行的 Agent 场景来说用户体验会明显下降。Docker 的优势在于生态成熟。镜像管理、网络配置、资源限制、日志收集这些都有现成的方案。我们用 K8s 做调度每个沙箱执行就是一个 Job配合 ResourceQuota 做资源限制配合 NetworkPolicy 做网络隔离配合 PodSecurityPolicy 做权限控制。这套组合拳下来安全性已经能满足大部分场景。真正需要 microVM 的场景我们单独走一条链路。比如用户上传的代码要执行系统级操作或者涉及多租户强隔离要求才会调度到 microVM 池。这样既保证了安全性又控制了成本。3.3 Go SDK 接入的架构设计我们的 Agent 主服务是 Go 写的所以沙箱接入也是围绕 Go SDK 来做。整体架构分三层调度层负责接收执行请求、选择沙箱类型、分配资源执行层负责在沙箱内运行代码、收集结果持久化层负责存储执行记录、代码快照、日志。调度层用 Go 的 goroutine pool 做并发控制每个执行请求封装成一个 Task通过 channel 分发给 worker。这里有个细节Task 必须带 context支持取消。因为用户可能中途取消执行或者上游超时了要级联取消没有 context 的话沙箱会一直跑到结束浪费资源。执行层通过 Docker SDK for Go 来操作容器。核心 API 是ContainerCreate、ContainerStart、ContainerWait、ContainerLogs、ContainerRemove。这里要注意ContainerWait返回的退出码要仔细处理非零退出码不一定是错误可能是用户代码本身的返回值。持久化层我们用了 PostgreSQL 存元数据对象存储存代码和日志。元数据包括执行 ID、用户 ID、沙箱类型、开始时间、结束时间、退出码、资源使用峰值。代码和日志因为体积大放对象存储更划算。type ExecutionRequest struct { ID string UserID string Code string Language string Timeout time.Duration MemoryLimit int64 SandboxType string } type ExecutionResult struct { ID string ExitCode int Stdout string Stderr string Duration time.Duration PeakMemory int64 Error error }这个结构体定义看起来简单但每个字段都有讲究。Timeout是墙钟超时MemoryLimit是硬限制SandboxType决定走哪条链路。实际接入时这些字段会从上层 Agent 的配置里透传下来。4. 持久化设计执行记录、代码快照与状态恢复4.1 为什么沙箱执行必须持久化有人会问沙箱执行完就完了为什么要持久化三个原因。审计需求。生产环境的 Agent 执行了什么代码、产生了什么结果必须可追溯。尤其是涉及用户数据的场景出了问题要能查到具体是哪次执行导致的。重试需求。沙箱执行可能因为各种原因失败——资源不足、网络抖动、节点故障。失败后要能重试重试就需要知道上次执行到哪了、输入是什么。状态恢复需求。有些 Agent 任务是多步的第一步执行完的结果要传给第二步。如果中间服务重启了没有持久化的话整个任务就丢了。我们最初没做持久化结果有次 K8s 节点滚动更新正在执行的几百个任务全部丢失用户投诉了一周。从那以后持久化成了硬性要求。4.2 存储选型PostgreSQL 对象存储的组合元数据用 PostgreSQL理由是事务支持好、查询灵活、生态成熟。表结构大概是这样CREATE TABLE executions ( id UUID PRIMARY KEY, user_id UUID NOT NULL, sandbox_type VARCHAR(32) NOT NULL, language VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL, exit_code INT, started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ, duration_ms BIGINT, peak_memory BIGINT, code_ref TEXT, stdout_ref TEXT, stderr_ref TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_executions_user_status ON executions(user_id, status); CREATE INDEX idx_executions_started_at ON executions(started_at);code_ref、stdout_ref、stderr_ref存的是对象存储的 key不是内容本身。因为代码和日志可能很大放数据库里会拖慢查询。对象存储我们用的是兼容 S3 协议的服务成本低、扩展性好。这里有个坑对象存储的写入不是事务性的。如果先写对象存储再写数据库中间挂了会出现孤儿对象如果先写数据库再写对象存储中间挂了会出现悬空引用。我们的做法是先写对象存储拿到 key 后再写数据库同时起一个后台任务定期清理孤儿对象。4.3 状态机设计让每次执行都有明确的生命周期执行状态必须用状态机管理否则会出现执行完了但状态还是 running这种脏数据。我们的状态机有六个状态pending已创建等待调度scheduling正在分配沙箱资源running沙箱内代码正在执行succeeded执行成功退出码为 0failed执行失败退出码非 0 或发生错误timeout执行超时被 kill状态流转必须单向不允许回退。每次流转都写一条状态变更记录方便排查。这里的关键是状态更新要幂等因为可能多个组件同时尝试更新状态比如调度器发现超时了要改成 timeout同时执行器也发现超时了要改两个都改不能出问题。我们用UPDATE ... WHERE status running这种条件更新来保证幂等。4.4 代码快照不只是存代码还要存环境只存代码是不够的。同样的代码在不同环境下执行结果可能不同所以还要存环境信息语言版本、依赖列表、环境变量、工作目录结构。我们的做法是每次执行前生成一个环境指纹包含这些信息的哈希值。执行记录里存这个指纹需要复现时用指纹去查对应的环境配置。环境配置本身也持久化用版本管理每次变更生成新版本。type EnvironmentFingerprint struct { LanguageVersion string Dependencies []string EnvVars map[string]string WorkDirHash string } func (e *EnvironmentFingerprint) Hash() string { h : sha256.New() h.Write([]byte(e.LanguageVersion)) for _, dep : range e.Dependencies { h.Write([]byte(dep)) } // 环境变量排序后写入保证哈希稳定 keys : make([]string, 0, len(e.EnvVars)) for k : range e.EnvVars { keys append(keys, k) } sort.Strings(keys) for _, k : range keys { h.Write([]byte(k e.EnvVars[k])) } h.Write([]byte(e.WorkDirHash)) return hex.EncodeToString(h.Sum(nil)) }这个哈希函数有个细节环境变量必须排序后再写入否则 map 遍历顺序随机会导致同样的环境算出不同的哈希。这个坑我们踩过排查了半天才发现。5. 执行协议设计让沙箱和主服务可靠对话5.1 协议要解决的核心问题沙箱和主服务之间的通信看起来简单——发代码、收结果。但生产环境要考虑的问题多得多怎么传大文件、怎么流式返回日志、怎么处理超时、怎么支持取消、怎么保证消息不丢。我们最初用的是简单的 HTTP 请求-响应模式主服务 POST 代码给沙箱沙箱执行完返回结果。这个模式在简单场景能用但很快暴露问题执行时间长的任务HTTP 连接会超时日志没法实时返回只能等执行完一次性拿取消操作没法实现只能等它自己结束。后来改成了基于 gRPC 的双向流。主服务和沙箱建立一个长连接通过 stream 发送执行请求、接收日志、接收结果、发送取消信号。这个模式灵活很多但复杂度也上去了。5.2 消息格式设计请求、日志、结果、心跳协议消息分四类用 protobuf 定义message ExecuteRequest { string execution_id 1; string code 2; string language 3; int64 timeout_ms 4; int64 memory_limit_bytes 5; mapstring, string env_vars 6; repeated string files 7; } message LogChunk { string execution_id 1; string stream 2; // stdout or stderr bytes data 3; int64 timestamp_ms 4; } message ExecuteResult { string execution_id 1; int32 exit_code 2; int64 duration_ms 3; int64 peak_memory_bytes 4; string error_message 5; } message Heartbeat { string execution_id 1; int64 timestamp_ms 2; ResourceUsage usage 3; }LogChunk用 bytes 而不是 string因为日志可能包含非 UTF-8 字符用 string 会出问题。Heartbeat是保活机制沙箱每 5 秒发一次主服务超过 15 秒没收到就认为连接断了触发重连或标记失败。5.3 超时与取消两级超时机制超时设计我们用了两级软超时和硬超时。软超时是给用户代码的比如 30 秒到了之后沙箱发一个 SIGTERM让代码有机会做清理。硬超时是给沙箱本身的比如 35 秒到了之后直接 SIGKILL不给任何机会。为什么要有 5 秒的间隔因为有些代码收到 SIGTERM 后需要时间保存状态、关闭文件。直接 SIGKILL 可能导致数据损坏。但如果代码不响应 SIGTERM硬超时就是兜底。取消操作通过 context 传递。主服务收到取消请求后通过 stream 发送一个 Cancel 消息沙箱收到后触发同样的 SIGTERM/SIGKILL 流程。这里要注意取消必须是幂等的重复取消不能报错。func (s *SandboxServer) Execute(req *ExecuteRequest, stream ExecuteStream) error { ctx, cancel : context.WithTimeout(stream.Context(), time.Duration(req.TimeoutMs)*time.Millisecond) defer cancel() // 启动执行 resultCh : make(chan *ExecuteResult, 1) go func() { result : s.runInSandbox(ctx, req) resultCh - result }() // 转发日志 go func() { for chunk : range s.logCh { stream.Send(LogChunk{...}) } }() select { case result : -resultCh: return stream.Send(result) case -ctx.Done(): return status.Error(codes.DeadlineExceeded, execution timeout) } }这段代码的关键是 context 的传递。stream.Context()会在客户端断开时自动取消这样沙箱能感知到主服务已经不要结果了可以提前终止执行。5.4 断线重连与消息补偿长连接不可避免会断。断了之后怎么办我们的策略是执行不中断结果不丢失。沙箱侧维护一个执行状态表记录每个 execution_id 的当前状态和已发送的日志偏移量。连接断了之后沙箱继续执行日志继续写本地缓冲。主服务重连后带上最后收到的日志偏移量沙箱从偏移量之后继续发。结果也是一样执行完成后结果先落盘主服务重连后能拿到。如果主服务一直不重连沙箱侧的结果会保留一段时间我们设的是 1 小时超时后清理。这个机制的关键是偏移量要持久化。主服务每次收到日志后把偏移量写到数据库。重连时从数据库读。这样即使主服务重启也能从正确的位置继续。6. 生产环境踩过的坑与排查手册6.1 沙箱泄漏容器停了但资源没释放这是最常见的坑。Docker 容器停了但 volume、network、临时文件可能还在。时间长了磁盘就满了。排查方法定期跑docker system df看资源占用用docker ps -a看有没有长期处于 exited 状态的容器。我们的做法是起一个清理任务每小时扫一次把超过 1 小时还是 exited 状态的容器强制删除同时清理对应的 volume。更彻底的做法是用 K8s 的 Job 加ttlSecondsAfterFinished执行完自动清理。但要注意 TTL 不能设太短否则排查问题时日志已经没了。我们设的是 1 小时。6.2 资源限制失效cgroup 没生效有次发现某个沙箱把宿主机内存吃满了查下来是 cgroup 限制没生效。原因是 Docker 的--memory参数在某些内核版本上对子进程不生效用户代码 fork 出来的进程不受限制。解决办法是用 cgroup v2并且设置memory.max而不是memory.limit_in_bytes。另外要设置pids.max限制进程数防止 fork 炸弹。# 检查 cgroup 版本 stat -fc %T /sys/fs/cgroup/ # cgroup v2 下设置内存限制 echo 536870912 /sys/fs/cgroup/sandbox-xxx/memory.max echo 100 /sys/fs/cgroup/sandbox-xxx/pids.max6.3 日志丢失缓冲区没 flush沙箱执行完日志却没收到。查下来是日志还在缓冲区里进程就被 kill 了。解决办法是执行结束前强制 flush或者用行缓冲模式。Python 的话python -u可以关闭缓冲。其他语言也有对应的参数。另外日志收集器要设置合理的 flush 间隔不能等缓冲区满了才发。6.4 常见问题速查表现象可能原因排查方法解决方案执行超时但进程还在SIGTERM 被忽略ps aux看进程状态加 SIGKILL 兜底内存超限但没被 killcgroup 未生效检查 cgroup 配置用 cgroup v2日志不完整缓冲区未 flush检查日志收集配置强制 flush 或行缓冲容器泄漏清理任务未运行docker ps -a加定时清理连接频繁断心跳超时太短看心跳日志调整心跳间隔结果丢失未持久化检查存储写入执行完先落盘6.5 几个提升稳定性的实操心得第一沙箱镜像要瘦身。镜像越大启动越慢。我们用 Alpine 做基础镜像把不必要的工具都删掉最终镜像控制在 50MB 以内。启动时间从 800ms 降到 300ms。第二预热沙箱池。对于高频执行的场景提前创建一批沙箱待命请求来了直接用省去创建开销。池子大小根据 QPS 动态调整。第三执行 ID 要全局唯一且可追溯。我们用 UUID v7既唯一又按时间有序方便按时间范围查询。第四给沙箱加标签。每个沙箱打上 user_id、execution_id、sandbox_type 标签排查问题时能快速定位。第五定期做故障演练。故意 kill 沙箱、断网、填满磁盘看系统能不能正确处理。我们每月做一次发现了好几个隐藏问题。7. 从能跑到好用几个值得投入的优化方向沙箱接入生产跑通只是起点。真正拉开差距的是这些细节执行结果的缓存、相似代码的复用、执行链路的可观测性。缓存这块我们对相同代码加相同环境的执行结果做了缓存命中率大概 15%省了不少资源。关键是缓存 key 要包含环境指纹否则会返回错误结果。可观测性方面我们接了 OpenTelemetry每次执行生成一个 trace包含调度、创建、执行、收集、清理各个阶段。这样一眼就能看出瓶颈在哪。最后分享一个我们最近在试的方向把沙箱执行做成可编排的 DAG。多个执行步骤之间有依赖关系可以并行执行无依赖的步骤整体耗时能降 40% 左右。这个还在打磨等稳定了再单独写一篇。如果你也在做 Agent 沙箱的生产化建议先从持久化和执行协议这两块入手这两块做扎实了后面扩展会顺很多。选型反而不用太纠结Docker 能覆盖 80% 的场景剩下的 20% 再针对性优化。