数据截至 (上游 commit 415259e527d2)
嵌套隔离:容器内再用 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-117)。一次典型使用:
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:435-442和pkg/runtime/isolated_session_ctrl.go:756-763。本章会讲清楚"已铺好的地基"和"还没盖的楼", 不会把桩当成能用的功能。
平台边界: 这层只在 Linux 上真正工作。非 Linux 是编译期桩,Available() 恒为 false
(pkg/isolation/bwrap_stub.go:46);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:150-155 |
bwrapImpl | bubblewrap 的具体实现:构造 bwrap 命令行 | pkg/isolation/bwrap_linux.go:84-110 |
buildArgv | 按固定段序拼出 bwrap 参数(命名空间/挂载/seccomp) | pkg/isolation/bwrap.go:37-181 |
MergedView | 用户态的联合视图:读上盖下、写只落 upper | pkg/isolation/merged_view.go:42-47 |
UpperManager | 分配/回收/配额每个会话的 upper 目录 | pkg/isolation/upper.go:28-95 |
Probe | 启动时探测:bwrap 在不在、能不能建命名空间、overlay 行不行 | pkg/isolation/probe.go:63-111 |
IsolatedRunner | 会话增删改查 + 空闲回收 + 并发串行化 | pkg/runtime/isolated_session_ctrl.go:45-64 |
isolatedSession | 一个常驻 bash 进程,活在 bwrap 命名空间里 | pkg/runtime/isolated_session.go:66-98 |
2.3 主线走一遍(高层,不进代码)
- 启动时(
main.go:59-113):加载 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:150-155):
// 真实源码,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:23-34 |
WorkspaceMode | rw / overlay / ro | 工作区怎么挂:直接读写 / 上层草稿 / 只读 | isolator.go:36-49 |
EnvMode | deny / allow | 宿主环境变量怎么透传:黑名单 / 白名单 | isolator.go:51-63 |
两个"数据袋"结构:
WrapOptions(isolator.go:133-145):一次执行的全部输入——profile、workspace、ExtraWritable(额外可写路径)、ShareNet(是否共享网络)、EnvPassthrough、Uid/Gid、UpperDir/WorkDir。Capabilities(isolator.go:110-131):这台机器能干什么——是否可用、版本、支持哪些 profile、CommitSupported/DiffSupported/PersistAvailable等开关,以及SeccompProfileSHA256(seccomp 配置指纹)。
诚实点:
SeccompProfileSHA256这个字段声明了但当前 没有任何代码去填(全库仅出现在结构体 定义isolator.go:126)。它是为"把 seccomp 策略指纹暴露给客户端"预留的位置,尚未接线。
3.2 bubblewrap 实现:把 exec.Cmd 包进命名空间
要解决的小问题: 怎么让一条普通命令"降生"在一个新的 PID/IPC/UTS/(可选)网络命名空间里, 根文件系统只读、工作区可写、危险系统调用被拦?
思路: 不自己调 clone/unshare,而是复用成熟的 bwrap 二进制——OpenSandbox 只负责把参数拼对。
拼参数的活全在 buildArgv/buildArgvWithLifecycle(pkg/isolation/bwrap.go:37-181),它按一个固定的段序产出参数,
顺序不能乱(编号注释内联在 bwrap.go:56-180):
① 命名空间标志 --unshare-pid --unshare-uts --unshare-ipc --unshare-cgroup [--unshare-net]
[userns 模式再加 --unshare-user --uid/--gid]
② 根挂只读 --ro-bind / /
③ /tmp 段 strict: --tmpfs /tmp balanced: --bind /tmp /tmp
④ /run /dev --tmpfs /run --dev /dev
⑤ /proc --proc /proc(lifecycle 模式挪到所有挂载之后)
⑥ 工作区段 rw/ro/overlay 三选一(见 3.3)
⑥b 藏 upper 根 --tmpfs <upperRoot> ← 防跨会话偷看别人的 upper
⑦ 额外可写 --bind <p> <p> ...
⑦b 显式 bind --bind/--ro-bind <src> <dst> ...
⑧ 环境变量段 按 EnvMode 生成 --setenv / --unsetenv / --clearenv
⑨ seccomp --seccomp <fd>
⑩ die-with-parent + lifecycle 的 block/status fd
⑪ 分隔 + 降权 -- setpriv --reuid --regid --clear-groups <用户命令>
(lifecycle 模式先过 fail-closed gate 再降权,见 WrapWithLifecycle)
几个不显然的设计点:
- 默认不开 user namespace(setpriv 模式)。
UidMode默认setpriv(isolator.go:69-78), 靠真实的setpriv从 root 降到目标 UID/GID(bwrap.go:170-180),降权后进程没有CAP_SETUID,无法再爬回来;也可显式选UidModeUserns(--unshare-user+--uid/--gid,bwrap.go:184-213)——但 setuid 版 bwrap 不支持--disable-userns(bwrap.go:188-190的注释)。 - 藏起 upper 根目录(
bwrap.go:82-86):UpperDir形如<root>/<id>/upper,于是把<root>整个--tmpfs盖掉,namespace 内看不到别的会话的 upper,杜绝横向数据泄露。 - strict 档更狠:
/tmp用独立 tmpfs(bwrapTmpSegment,bwrap.go:247-255),且默认剥离一批 敏感环境变量(见 3.6)。
改写命令的收尾(wrapWithArgv,bwrap.go:380-390):把 bwrapPath 放最前,接 bwrap 参数,
再接原命令,替换 cmd.Path。
真正执行 Wrap 的入口在 bwrapImpl.Wrap(bwrap_linux.go:146-172):它先把 seccomp BPF 通过
memfd 挂进 cmd.ExtraFiles,算出子进程侧的 fd 号,再调 buildArgv。
bwrap 二进制怎么找(findBwrap,bwrap_linux.go:44-60):优先 $PATH,再依次找
/opt/opensandbox/bwrap(init 容器注入)、/usr/bin/bwrap、/usr/local/bin/bwrap。
能力探测(Probe,probe.go:63-111)分三步,任何一步失败都降级:
probeBwrapVersion:能不能跑bwrap --version并解析出版本(probe.go:121-153) 。probeBwrapSmoke:真起一个最小 namespace 跑true,按 uid 模式分别做 setpriv 冒烟与身份切换 冒烟、userns 冒烟(probeBwrapSetprivSmoke等,probe.go:154-193;入口probeBwrapSmoke:176), 结果汇总进setBwrapModeAvailability(probe.go:113-119)。probeOverlayMount:真做一次 overlay 挂载测试(probe.go:243-287)——成功才点亮CommitSupported/DiffSupported。注意它故意在upperRoot(通常 tmpfs/emptyDir)上探, 因为 overlayfs 不能嵌套在 Docker 的 overlay2 层上,但在 tmpfs 上没问题(probe.go:249-251)。
clone3 兼容:让老 seccomp 场景不崩
坑: Go 运行时和 glibc 会用 clone3(2);某些旧内核 / 严格 seccomp 环境里 clone3 会被拦成
EPERM 而不是 ENOSYS,导致不回退到 clone(2),进程直接失败。
对策(pkg/clone3compat):可选地装一条 seccomp 规则,让 clone3 返回 ENOSYS,libc/Go 就会
优雅回退到 clone。由环境变量控制(compat_linux.go:39-79):
EXECD_CLONE3_COMPAT | 行为 |
|---|---|
空 / 0 / false / off / no | 不启用 |
1 / true / yes / on | 进程启动后装过滤器 |
reexec | 装过滤器后 re-exec 自身,让所有 init 代码都在过滤器已生效下跑 |
装过滤器本身在 loadClone3EnosysFilter(compat_linux.go:81-103),默认放行、只对 clone3
返回 ENOSYS。main.go:40 在最早期调用 clone3compat.MaybeApply()。
3.3 overlay 合并视图:下层只读 + 上层草稿
这是本章的核心。它有两副面孔,别混淆:
- 内核态 overlay(给 namespace 里的进程用):bwrap 用
--overlay-src LOWER --overlay UPPER WORK DEST真的挂一个 overlayfs,进程写文件时自动落到 upper。 - 用户态
MergedView(给 host 侧的文件 API 用):一个 Go 结构体,模拟 overlay 语义,让/v1/isolated/.../files/*这些接口读到"上盖下"的合并视图。
内核态挂载参数在 bwrapWorkspaceSegment(bwrap.go:258-283):
// 真实源码节选,pkg/isolation/bwrap.go:167-178 —— overlay 分支
case WorkspaceOverlay:
if opts.UpperDir == "" {
// upper 落在 tmpfs,进程一退就没 —— 临时草稿
return []string{"--overlay-src", ws.Path, "--tmp-overlay", ws.Path}, nil
}
// upper 落在磁盘 —— 会话内跨多次 run 保留
return []string{"--overlay-src", ws.Path, "--overlay", opts.UpperDir, workDir, ws.Path}, nil
用户态 MergedView 的规则(merged_view.go):
| 操作 | 行为 | 代码 |
|---|---|---|
| 读(Stat/Open/ReadFile) | 先查 upper,命中即返回;否则落 lower | merged_view.go:135-242 |
| 列目录(ReadDir) | 合并 lower + upper,upper 覆盖同名项 | merged_view.go:157-198 |
| 写(WriteFile) | 只写 upper,并 chown 到会话 uid/gid | merged_view.go:247-271 |
| 删(Remove) | upper 有就删;lower 有则建 whiteout 遮住 | merged_view.go:310-344 |
| 改名/改权限 | lower-only 时先 copy-up 到 upper 再改 | merged_view.go:395-485 |
whiteout(白障) 是 overlay 删除下层文件的标准技巧:在 upper 建一个 .wh.<name> 标记文件,
表示"这个名字在合并视图里当作不存在"(createWhiteout,merged_view.go:111-122;读取时
hasWhiteout 与 ReadDir 里 .wh. 前缀处理,merged_view.go:125-132、177-181)。
关键坑(代码注释里白纸黑字写着,
merged_view.go:29-38): 用户态MergedView往 upper 目录的 直接写入,在一个正在运行的内核 overlay 挂载里是看不见的——内核 VFS 在挂载时缓存了目录项, 绕过 overlay 的直改会被无视。所以只有 run→API 方向(进程写→经 overlay 落 upper→host 读 upper) 可靠;要双向交换文件,应该用rw模式而不是overlay。
3.4 upper 层的持久化与配额
要解决的小问题: 每个 overlay 会话都要一块可写的 upper 目录,谁来分配、谁来回收、别把磁盘撑爆?
思路: UpperManager(upper.go:28-34)在一个 root 下(默认 /var/lib/execd/isolation,
见 config.go:50)为每个会话开一对目录,并加锁跟踪、做总量配额。
布局: 每次 Allocate()(upper.go:65-95)生成一个随机 16 字节 hex 会话 ID,建
<root>/<id>/upper 和 <root>/<id>/work(overlayfs 要求一个同文件系统的 work 目录)。
配额: Allocate 前先算当前总用量,>= maxBytes 就返回 ErrUpperLimitExceeded(upper.go:69-74),
maxBytes 即配置里的 UpperMaxBytes(默认 8 GiB,config.go:51)。用量靠 dirSize 遍历累加
文件大小(usageLocked + dirSize,upper.go:168-214)。
回收有三档:
| 方法 | 语义 | 代码 |
|---|---|---|
Release | 只标记"可回收",不立即删 | upper.go:98-105 |
Remove | 立即删整个 <root>/<id> | upper.go:108-126 |
Collect | 一轮 GC:删掉所有已 Release 的条目 | upper.go:137-158 |
诚实点: upper 目录在一次会话内跨多次 run 是保留的(常驻 bash + 同一个 overlay 挂载)。 但"persist"(让 upper 跨会话、跨重启存活)是另一回事,当前未实现:
Capabilities里PersistAvailable: false(bwrap_linux.go:139),PersistMaxBytes*只是预告的默认值。 会话一旦delete,DeleteIsolatedSession会顺手upperMgr.Remove把这层删掉 (isolated_session_ctrl.go:733-741)。
3.5 seccomp:按 profile 生成系统调用过滤
要解决的小问题: 命名空间挡不住所有坏事,得再拦一层危险系统调用(挂载、ptrace、加载内核模块……)。
思路: 生成一段 默认放行、黑名单拒绝 的 BPF 字节码,通过 memfd 交给 bwrap 的 --seccomp。
黑名单(denylistSyscalls,seccomp_gen.go:30-71)覆盖几类:文件系统操纵(mount/umount2/
chroot/pivot_root)、进程窥探(ptrace/process_vm_readv)、内核模块(init_module 等)、
BPF/seccomp 自身、namespace 操纵(setns/unshare)、kexec、reboot 等。注释特意说明没有
封 setresuid/setresgid——因为 setpriv 降权要用(seccomp_gen.go:47-49)。
生成过程(generateSeccompDenyBPF,seccomp_gen.go:85-138):
// 真实源码节选,pkg/isolation/seccomp_gen.go:96-104 —— 默认放行、命中黑名单返回 EACCES
policy := seccomp.Policy{
DefaultAction: seccomp.ActionAllow,
Syscalls: []seccomp.SyscallGroup{{
Names: names, // 已按当前架构过滤掉不存在的 syscall
Action: seccomp.ActionErrno | seccomp.Action(syscall.EACCES),
}},
}
生成后序列化成 struct sock_filter 字节流(每条 8 字节,seccomp_gen.go:122-135),启动时预生成一次
(NewBwrap 里 seccompBPF,bwrap_linux.go:103-108),每次 Wrap 用 memfd 把它喂给 bwrap
(createMemfdWithData,bwrap_linux.go:305-320)。
可覆盖: 配置里 [seccomp] deny = [...](SeccompOverride,config.go:42-44)会完全替换
内置黑名单(不是合并,config.go:36-37)。不同架构上不存在的 syscall 会被 filterKnownSyscalls
静默跳过(seccomp_gen.go:141-149)。
注意: profile(strict/balanced)当前主要影响
/tmp和环境变量的处理;seccomp 黑名单本身 不随 profile 分档——两档用的是同一份内置黑名单(除非配置覆盖)。这点从generateSeccompDenyBPF只吃override不吃Profile可以看出(seccomp_gen.go:85)。
3.6 环境变量透传:别把容器的密钥漏进沙箱
要解决的小问题: 容器里常带 *_API_KEY、AWS_*、KUBE_* 这类敏感变量,不该无脑透传给沙箱里
跑的代码。
思路(bwrapEnvSegment,bwrap.go:309-347):按 EnvMode 分三种情形— —
- 未指定 mode:剥离一批敏感变量(
unsetBlacklistedEnv,按strictEnvBlacklistglob 匹配,bwrap.go:350-353:*_API_KEY/*_TOKEN/*_SECRET/*_PASSWORD/AWS_*/ALI_*/ALIYUN_*/K8S_*/KUBE_*)。 - deny:显式
--unsetenv指定 key(空 list 时退回上面的黑名单)。 - allow:
--clearenv清空,再只--setenv白名单里的 key。
匹配是大小写不敏感的简单 glob(前缀/后缀/包含/精确,matchEnvPattern,bwrap.go:356-377)。
4. 会话生命周期(深入实现)
本节把"一个隔离会话从生到死"走一遍代码。会话 = 一个常驻 bash 进程,活在 bwrap 命名空间里,
多次 run 复用同一个 bash(所以 upper 层在会话内累积)。
4.1 create:分配 upper + 启动常驻 bash
CreateIsolatedSession(isolated_session_ctrl.go:337-406):
- 校验
ExtraWritable必须在 allowlist 内(validateExtraWritable,:454-476)。 os.MkdirAll建工作区。- overlay 模式(或未指定 mode,默认 overlay)→
upperMgr.Allocate()拿到upperID/upperDir/workDir(:162-170)。 session.start()启动;失败则回滚 upper(:172-177)。
start()(isolated_session.go:74-163)的要点:
// 真实源码节选,pkg/runtime/isolated_session.go:75-76
cmd := exec.Command("bash", "--noprofile", "--norc")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // 独立进程组,便于整组 kill
- 组装
WrapOptions:profile(默认 strict,:83-90)、workspace 模式(默认 overlay,:93-100)、 env 透传(默认 deny,:105-110)、uid/gid、UpperDir/WorkDir。 isolator.Wrap(cmd, wrapOpts)把 cmd 改写成 bwrap 调用。- 建 stdin/stdout 管道,
cmd.Start(),起一个死亡守望 goroutine 关doneCh(:148-151)。 - 启动自检:等 100ms,若 bwrap 立刻退出就判定失败(
:156-160)——早失败早报错,不拖到首个 run。
4.2 run:把代码喂给常驻 bash,读到标记为止
RunInIsolatedSession(isolated_session_ctrl.go:553-637)的机制很巧:
- 每会话串行:
s.runMu.Lock(),同一会话的多个 run 排队(:243-244)。 - 组装脚本:可选地在子 shell 里
export环境变量 + 用户代码 + 一行echo __ISOLATED_RUN_END__<uuid> $?(:260-279)。这个唯一结束标记用来切分本次输出、并带回退出码。 - 取消/超时用 SIGINT 而非关 stdin(
:598-614):关 stdin 会杀死整个常驻 bash,而发 SIGINT 只 中断当前命令,bash 还活着可供下次 run。 scanUntilMarker(:318-368)逐行扫 stdout,遇到标记就解析退出码返回;标记可能出现在行中间 (上条命令输出没有换行结尾时),所以用strings.Index定位而非整行相等(:332-337)。
web 层 Run(controller/isolated_session.go:229-322)把每行 stdout 包成 SSE 事件流回,末尾发
IsolatedComplete 或 IsolatedError。
4.3 delete 与空闲 GC
DeleteIsolatedSession(isolated_session_ctrl.go:694-753):stop()杀进程组 (syscall.Kill(-pid, SIGKILL),isolated_session.go:166-179)→upperMgr.Remove删 upper → 从isolatedSessionMap移除。- 后台 GC(
gcLoop+CollectIdle,:86-136):每 60s 一轮,清掉已死的会话,以及空闲超过IdleTimeoutSeconds的会话(用runMu.TryLock避开正在跑的,:125-128)。
4.4 隔离 内的文件操作
/v1/isolated/.../files/* 与 .../directories/* 这一整套(controller/isolated_session_files.go)
不进 bash,而是直接对 MergedView 操作:先 GetMergedView(isolated_session_ctrl.go:768-843)
拿到该会话的合并视图,再做 info/list/search/download/upload/rename/chmod/replace/mkdir/remove。
GetMergedView 里有个细节:rw 模式下 upper 直接指向工作区本身(写落原地),overlay 模式才用
会话的 upperDir(:426-432)。这解释了 3.3 里"要双向就用 rw"的建议。
4.5 diff / commit:地基已铺,楼还没盖(Phase 2)
这是全章最需要诚实的地方。相关表面都在,但实现是桩:
| 表面 | 现状 | 代码 |
|---|---|---|
GET .../diff | 返回 503 not implemented yet (phase 2) | controller/isolated_session.go:435-437 |
POST .../commit | 返回 503 not implemented yet (phase 2) | controller/isolated_session.go:440-442 |
IsolatedRunner.DiffUpper | 返回 "diff not implemented yet" | isolated_session_ctrl.go:756-758 |
IsolatedRunner.CommitUpper | 返回 "commit not implemented yet" | isolated_session_ctrl.go:761-763 |
capabilities 里 commit/diff | 强制置 false,即便 overlay 探测成功 | controller/isolated_session.go:497-498 |
换句话说:upper 层(diff/commit 的数据基础)已经真实存在并在累积改动,Probe 也能探出 overlay
可用,但"把 upper 算成一份 diff""把 upper 合并回 lower"这两步逻辑尚未落地。config.go 里的
DiffMaxBytes(默认 4 GiB,config.go:31、:121)是为 diff 预留的配额,当前也没有任何代码消费它。
5. 巧妙之处(可借鉴的技术)
- 用
Wrap(cmd)改写而非新起进程。 隔离对 runtime 层几乎透明:照常建exec.Cmd,Wrap一下 再Start。接口窄到只有四个方法(isolator.go:150-155)。 - 藏 upper 根防横向泄露。 把整个 upper root
--tmpfs盖掉,namespace 内看不到别人的草稿层 (bwrap.go:82-86)——一行挂载解决多租户隔离。 - 默认不靠 user namespace,靠真 setpriv 降权。 规避了 userns 的兼容/权限坑,降权后无
CAP_SETUID无法回爬(isolator.go:69-78、bwrap.go:170-180)。 - seccomp 走 memfd。 BPF 字节码放匿名内存文件、经
ExtraFiles传 fd,不落磁盘 、无临时文件清理 (bwrap_linux.go:155、appendSeccompFile/createMemfdWithData272-322)。 - 结束标记切分流式输出。 常驻 bash 复用,靠
echo <marker> $?精确定位每次 run 的结束与退出码, 还能容忍标记出现在行中间(isolated_session_ctrl.go:577、652-661)。 - 取消用 SIGINT 保命。 中断当前命令但不杀常驻 bash,会话得以复用(
:598-614)。 - overlay 探测挑对文件系统。 明知 overlayfs 不能嵌在 Docker overlay2 上,就故意去 tmpfs 上探
(
probe.go:249-251)——一个很实战的兼容判断。 - clone3→ENOSYS 兼容垫片。 用一条 seccomp 规则让运行时优雅回退到
clone,还能 re-exec 让全程 生效(compat_linux.go)。
6. 边界与局限(诚实)
- diff / commit / persist 未实现(Phase 2)。 见 §4.5。API 在、能力字段在、配额配置在,但逻辑是桩。
SeccompProfileSHA256声明未填。 预留字段,无代码写入(isolator.go:126)。- 只在 Linux 生效。 非 Linux / Windows 全是编译期桩,
Available()恒 false (bwrap_stub.go、isolated_session_stub.go)。 - overlay 模式下 host 写 upper 对运行中进程不可见。 内核 VFS 目录项缓存所致;双向交换要用 rw
模式(
merged_view.go:29-38)。 - seccomp 不 随 profile 分档。 strict / balanced 共用同一份内置黑名单(除非配置覆盖),
profile 目前只影响 /tmp 与环境变量处理(
seccomp_gen.go:85、bwrap.go:247-255)。 - 依赖外部 bwrap 二进制。 找不到就整组隔离能力降级为 503;能力探测三步任一失败即不可用
(
probe.go:63-111)。 - 配额是软的、事后的。
UpperManager用量靠遍历目录累加,Allocate前检查一次;单次会话跑飞 的写入不会被实时拦截(upper.go:65-95、146-156)。
7. 横向对比
- 与本项目 04-execd-data-plane:04 是"容器内直接跑",本章是"容器内再套一层 可回滚命名空间"。两者数据面能力(命令/文件)对等,但本章多了 upper 草稿层、seccomp 与更细的挂载控制。
- 与 03-runtime-backends 的容器级隔离:03 是外层(Docker/K8s 起容器), 本章是内层(容器里再隔离)。两层正交,合起来是"外容器 + 内命名空间"的双层沙箱。
- 与外层网络策略见 06-networking-and-vault:本章的
ShareNet决定 隔离层是否--unshare-net,与出站策略是不同层次的控制。
8. 代码地图(导航索引)
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 隔离接口与值类型 | pkg/isolation/isolator.go | Isolator、Profile、WorkspaceMode、EnvMode、Capabilities、WrapOptions |
| bwrap 参数拼装 | pkg/isolation/bwrap.go | buildArgv、bwrapWorkspaceSegment、bwrapEnvSegment、strictEnvBlacklist、wrapWithArgv |
| bwrap 实现与 seccomp 挂载 | pkg/isolation/bwrap_linux.go | bwrapImpl、NewBwrap、Wrap、findBwrap、createMemfdWithData |
| 非 Linux 桩 | pkg/isolation/bwrap_stub.go | bwrapStub |
| 启动能力探测 | pkg/isolation/probe.go | Probe、probeBwrapVersion、probeBwrapSmoke、probeOverlayMount |
| 用户态合并视图 | pkg/isolation/merged_view.go | MergedView、createWhiteout、hasWhiteout、Rename、Chmod、Search |
| upper 层管理与配额 | pkg/isolation/upper.go | UpperManager、Allocate、Release、Remove、Collect、ErrUpperLimitExceeded |
| seccomp BPF 生成 | pkg/isolation/seccomp_gen.go | generateSeccompDenyBPF、denylistSyscalls、filterKnownSyscalls |
| 隔离配置 | pkg/isolation/config.go | Config、DefaultConfig、LoadConfig、SeccompOverride |
| clone3 兼容垫片 | pkg/clone3compat/compat_linux.go | MaybeApply、loadClone3EnosysFilter |
| 常驻 bash 会话 | pkg/runtime/isolated_session.go | isolatedSession、start、stop、dead |
| 会话增删改查 + GC | pkg/runtime/isolated_session_ctrl.go | IsolatedRunner、CreateIsolatedSession、RunInIsolatedSession、scanUntilMarker、GetMergedView、DiffUpper(桩)、CommitUpper(桩) |
| Windows 桩 | pkg/runtime/isolated_session_stub.go | IsolatedRunner |
| HTTP 控制器(会话) | pkg/web/controller/isolated_session.go | IsolatedSessionController、Create、Run、Delete、Diff(桩)、Commit(桩)、Capabilities |
| HTTP 控制器(文件) | pkg/web/controller/isolated_session_files.go | getMergedView、UploadFile、DownloadFile、ListDirectory |
| 请求/响应模型 | pkg/web/model/isolated_session.go | CreateIsolatedSessionRequest、IsolatedRunRequest、CapabilitiesResponse |
| 路由注册 | pkg/web/router.go | /v1/isolated group(:95-114) |
| 启动接线 | main.go | LoadConfig、Probe、NewBwrap、NewIsolatedRunner(:46-78) |