嵌套隔离:容器内再用 bubblewrap+overlay 套一层沙箱
30 秒导读: 沙箱已经是一个容器了,为什么还要再套一层?因为「容器」是给一个用户的一整个环境, 而 agent 常常想让每一次代码执行都有自己独立、可看 diff、可提交或丢弃的草稿 空间。本章讲 OpenSandbox 里工程含量最高的一支:在容器内部,用
bubblewrap(轻量命名空间沙箱工具)+overlayfs(联合挂载,下层只读、上层记改动)为单次运行再造一层可回滚的隔离。对外表现为/v1/isolated/*这组 API。
本章与 04-execd-data-plane(数据面:在沙箱里直接跑命令/文件/PTY)并列: 04 讲的是"直接在容器里跑",本章讲的是"在容器里再包一层命名空间跑"。两者共用不了实现,所以本章 不重复普通 command/fs 的内容,只讲这层额外的隔离。协议全景见 index,生命周期见 01-protocol-and-lifecycle。
1. 这是什么(零基础也能懂)
一句话定义: 在一个已经是沙箱的容器里,再给单次执行套一层可写、可 diff、可回滚的命名空间隔离。
1.1 为什么容器之上还要再隔离一层
沙箱容器解决的是"把 agent 的活动关进一个盒子"。但一个 agent 会话里,往往要跑很多次代码,你会希望:
- 让某一次执行在独立的可写层里改文件,不脏到原始工作区;
- 事后能看这次改了什么(diff),满意就提交(commit)进工作区,不满意就整层丢弃;
- 给这次执行更严的权限边界(不同 profile:更严 / 更平衡);
- 这一切都发生在已经在容器里的进程内部——不需要再起一个新容器。
这正是"给容器内的每次运行加一个 overlay 草稿层"的直觉。
1.2 一句话直觉/类比
把它想成 Git 的暂存区,但作用在文件系统上:
| 概念 | 类比 |
|---|---|
| lower(下层,只读) | 原始工作区,像 HEAD |
| upper(上层,可写) | 这次执行的所有改动,像 working tree 的改动 |
| diff | git diff:这次到底改了啥 |
| commit | git commit:把改动落回工作区 |
| 丢弃 upper | git checkout .:整层扔掉,工作区毫发无损 |
区别在于:这层"草稿"不只是文件差异,还是一个真正的命名空间沙箱——独立的 PID / IPC / UTS / (可选)网络命名空间 + seccomp 系统调用过滤。
1.3 用起来什么样
对外是一组 /v1/isolated/* HTTP 接口(注册见 pkg/web/router.go:95-114)。一次典型使用:
POST /v1/isolated/session 创建隔离会话(选 profile、workspace 挂载模式)→ 得到 session_id
POST /v1/isolated/session/{id}/run 在这层里跑代码(SSE 流式回传 stdout)
GET /v1/isolated/session/{id}/diff 看这次改了什么 ← Phase 2,尚未实现
POST /v1/isolated/session/{id}/commit 把改动提交进工作区 ← Phase 2,尚未实现
DELETE /v1/isolated/session/{id} 销毁会话,连 upper 层一起删
GET /v1/isolated/capabilities 问:这台机器到底支不支持隔离/commit/diff
⚠ 诚实提示(全章通用): 截至本 commit,
create / run / delete和一整套隔离内文件操作 已经能用;而 diff / commit / persist(跨会话持久化)属于 Phase 2,代码里是明确的桩(stub), 调用直接返回"not implemented yet"。见pkg/web/controller/isolated_session.go:237-244和pkg/runtime/isolated_session_ctrl.go:396-403。本章会讲清楚"已铺好的地基"和"还没盖的楼", 不会把桩当成能用的功能。
平台边界: 这层只在 Linux 上真正工作。非 Linux 是编译期桩,Available() 恒为 false
(pkg/isolation/bwrap_stub.go:37);Windows 的 runner 同样是桩(pkg/runtime/isolated_session_stub.go)。
2. 顶层全景(它大概怎么转)
2.1 三层结构
从 HTTP 请求到最底层的 bwrap 进程,分三层。怎么读:从上到下是调用方向,每层只依赖它下面一层。
HTTP /v1/isolated/*
│
┌──────────▼───────────┐ web 层:参数校验 + SSE 流式输出 + 文件操作代理
│ IsolatedSessionCtrl │ pkg/web/controller/isolated_session*.go
└──────────┬───────────┘
│
┌──────────▼───────────┐ runtime 层:会话生命周期、并发串行化、空闲 GC
│ IsolatedRunner │ pkg/runtime/isolated_session_ctrl.go
│ + isolatedSession │ (每会话 = 一个常驻 bash,活在 bwrap 命名空间里)
└─────┬──────────┬─────┘
│ │
┌──────▼───┐ ┌───▼────────┐ isolation 层:把 exec.Cmd 包进 bwrap;管 upper 层
│ Isolator │ │UpperManager│ pkg/isolation/{bwrap,upper,merged_view}.go
│ (bwrap) │ │ + Merged │
└────┬─────┘ │ View │
│ └────────────┘
┌────▼─── ──────────────────────┐
│ bwrap 进程(命名空间 + overlay │ 真正的隔离在这里发生
│ + seccomp)→ 里面跑 bash/命令 │
└──────────────────────────────┘
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Isolator 接口 | 抽象"把命令包进一层隔离"的能力 | pkg/isolation/isolator.go:108-114 |
bwrapImpl | bubblewrap 的具体实现:构造 bwrap 命令行 | pkg/isolation/bwrap_linux.go:63-143 |
buildArgv | 按固定段序拼出 bwrap 参数(命名空间/挂载/seccomp) | pkg/isolation/bwrap.go:42-126 |
MergedView | 用户态的联合视图:读上盖下、写只落 upper | pkg/isolation/merged_view.go:42-47 |
UpperManager | 分配/回收/配额每个会话的 upper 目录 | pkg/isolation/upper.go:28-93 |
Probe | 启动时探测:bwrap 在不在、能不能建命名空间、overlay 行不行 | pkg/isolation/probe.go:56-85 |
IsolatedRunner | 会 话增删改查 + 空闲回收 + 并发串行化 | pkg/runtime/isolated_session_ctrl.go:42-48 |
isolatedSession | 一个常驻 bash 进程,活在 bwrap 命名空间里 | pkg/runtime/isolated_session.go:45-60 |
2.3 主线走一遍(高层,不进代码)
- 启动时(
main.go:46-78):加载 TOML 配置 →Probe探测能力 → 能用就建bwrapImpl+IsolatedRunner,并注册进 controller。探不出来则整组接口对外返回 503。 - create:校验参数 → 建工作区目录 → overlay 模式下由
UpperManager分配一对upper/work目录 →isolatedSession.start()把bash --noprofile --norc包进 bwrap 启动。 - run:把用户代码 + 结束标记写进这个常驻 bash 的 stdin,逐行读 stdout 用 SSE 回传,读到标记 就知道跑完了、拿到退出码。
- delete:杀掉 bwrap 进程组 → 删掉 upper 层 → 从会话表里移除。
3. 核心原理(逐个机制,由浅入深)
3.1 隔离抽象:一个 Isolator 接口 + 一组值类型
要解决的小问题: runtime 层不该关心"到底是 bwrap 还是别的东西在隔离",它只想说"把这条命令包起来"。
思路: 定义一个窄接口,把"包一层"抽象成 Wrap(cmd, opts)。接口只有四个方法
(pkg/isolation/isolator.go:108-114):
// 真实源码,pkg/isolation/isolator.go:108-114
type Isolator interface {
Name() string
Available() bool
Capabilities() Capabilities
Wrap(cmd *exec.Cmd, opts WrapOptions) error
}
Wrap 的语义很关键:它不新起进程,而是改写传入的 *exec.Cmd——把 cmd.Path 换成 bwrap、
把原命令塞到 bwrap 参数后面。调用方随后照常 cmd.Start() 即可。
配套的三组"档位"枚举,都带 Valid() 自校验:
| 类型 | 取值 | 含义 | 定义 |
|---|---|---|---|
Profile | strict / balanced | 隔离档位:严格 vs 平衡 | isolator.go:20-31 |
WorkspaceMode | rw / overlay / ro | 工作区怎么挂:直接读写 / 上层草稿 / 只读 | isolator.go:33-46 |
EnvMode | deny / allow | 宿主环境变量怎么透传:黑名单 / 白名单 | isolator.go:48-60 |
两个"数据袋"结构:
WrapOptions(isolator.go:94-104):一次执行的全部输入——profile、workspace、ExtraWritable(额外可写路径)、ShareNet(是否共享网络)、EnvPassthrough、Uid/Gid、UpperDir/WorkDir。Capabilities(isolator.go:76-92):这台机器能干什么——是否可用、版本、支持哪些 profile、CommitSupported/DiffSupported/PersistAvailable等开关,以及SeccompProfileSHA256(seccomp 配置指纹)。
诚实点:
SeccompProfileSHA256这个字段声明了但当前没有任何代码去填(全库仅出现在结构体 定义isolator.go:87)。它是为"把 seccomp 策略指纹暴露给客户端"预留的位置,尚未接线。