docs/sandbox-docker-backend.md
面向部署方与要改这块代码的人。本文说明 docker 后端现在是什么形态、怎么配、边界在哪, 以及为什么是这个设计。协议层的整体立场见 沙箱协议接入说明, CubeSandbox / E2B 的集群与模板见 沙箱集群与标准模板。
shell_exec、附件暂存、
产物收集都落在同一个容器里,与 E2B/Cube 在应用层的行为一致。RemoteSandboxClient 适配器(internal/sandbox/docker_remote_client.go),
直接打 Docker Engine API。session→sandbox 绑定、生命周期锁、能力矩阵一行没改——
这正是当初把 provider 抽象成 RemoteSandboxClient 的收益。docker.sock 等同宿主机 root。系统管理员可在「设置 → 系统设置 → 网络安全」打开,立即生效;也可用环境变量 WEKNORA_SANDBOX_DOCKER_ENABLED=true 作为未落库时的回退。设置页始终保留 Docker 标签:未打开时没有「添加」入口,只说明如何启用。已有配置仍可查看/删除,但不会再创建容器。sbx)不能当后端:那是开发者本机 CLI,要 Docker 账号登录、
工作区是宿主机目录直挂、没有多租户服务端 API。旧实现每次执行都是 docker run --rm 加一个只读 bind mount,和 E2B 不是「能力少一点」,
而是模型不同。具体是这几条(都在改造中修掉了):
| 旧行为 | 后果 |
|---|---|
| 执行完即销毁容器 | 没有会话状态,shell_exec、附件暂存、产物收集在能力矩阵里根本不注册 |
超时 kill 的是 docker run 客户端进程 | 容器继续跑到自己结束;WeKnora 已经给用户返回超时了。实测见 PoC |
/workspace 只读挂载 | 脚本写不了 /workspace/output,而 skills 框架恰恰把 WEKNORA_SKILL_OUTPUT_DIR 指到那里 |
| bind mount 由宿主机 daemon 解释 | WeKnora 自己跑在容器里时挂进去的是宿主机上的同名目录,通常不存在 |
| 走 docker CLI 而不是 API | 依赖宿主机装 CLI,错误只能靠字符串匹配,拿不到容器 ID 做对账与回收 |
配置面只有一个 image | CPU、内存、网络、TTL 都没法按空间配置 |
一个沙箱就是一个容器。标准镜像以 USER user 结尾,创建时覆盖为 uid 0,这样入口才能在 /var/lib 写下活跃标记;脚本仍以 user 执行。PID 1 是 sleep infinity,所有工作都通过 exec 进去做。
这层 wrapper 是通过 Entrypoint 下发并把 Cmd 显式清空的:daemon 会把镜像自带的
ENTRYPOINT 拼到 Cmd 前面,所以只设 Cmd 时,任何声明了 ENTRYPOINT 的镜像(本文件的
cube target 就有)都会顶掉 PID 1,活跃标记不会生成、exec 也会打到别的东西上。
| 契约方法 | Docker 实现 |
|---|---|
Health | GET /_ping |
Create | POST /containers/create + /start,metadata 落成 labels,镜像缺失时先 pull |
Connect | GET /containers/{id}/json;容器被停掉时重新 start,被 pause 时 unpause |
Get / List | GET /containers/json?filters=label=…,服务端按 label 过滤 |
Delete | DELETE /containers/{id}?force=1&v=1 |
Exec | POST /containers/{id}/exec → /exec/{id}/start(hijack)→ /exec/{id}/json |
WriteFile / ReadFile / Stat | 刻意不用 archive 接口,走 exec 的 cat > "$1" / cat / find -maxdepth 0 -printf(见下) |
MakeDir / Remove / ListDir | 没有原生接口,用 exec 的 mkdir -p / rm -rf / find -printf |
几个不显然但要紧的决定:
超时由容器内的 timeout(1) 执行。 取消 HTTP 请求不会终止容器里的进程(PoC
里专门复现了这条),所以每次 exec 都包一层
sh -c 'touch <marker>; exec timeout -s KILL <n> "$@"' weknora-exec <cmd> <args...>。
命令通过位置参数传进去,不做任何字符串拼接,脚本里的引号和换行不会改变实际执行的东西。
退出码 137/124 被翻译成 Killed=true。
空闲回收是 WeKnora 自己的事。 daemon 没有任何 TTL 概念。上面那层 wrapper 顺手 touch
一个活跃标记文件,清扫时用一次 HEAD /archive 读它的 mtime 就知道容器多久没干活了——
不需要额外往容器里 exec,也不需要 Redis 记账。清扫在 Create/Connect 时触发,
按 daemon 端点限流(默认最快一分钟一次),在后台跑。删掉一个空闲容器不需要跟绑定存储协调:
生命周期本来就把「provider 上已经没有的沙箱」当作可重新绑定,这跟 E2B 沙箱被自己的 TTL
回收后的路径完全一样。每个容器把创建时的 TTL 记在 label 上,所以 A 配置触发的清扫不会拿
自己的 TTL 去衡量 B 配置的容器。
标记文件必须对沙箱账号可写(只跑脚本的会话也要能刷新它),因此它的 mtime 是容器可影响的:
落在未来的时间戳一律不采信,退回按容器启动时间判断,否则一次 touch -d 2099-01-01
就能让容器永久免于回收。往前改只会让自己更早被回收,不构成问题。此外清扫在真正删除前
会再读一次标记:列举加逐个 stat 在繁忙 daemon 上不是瞬时的,期间会话可能已被恢复。
残留风险:标记对沙箱账号可写是刻意的(只跑脚本的会话也要能刷新它),所以任何能在容器里
执行命令的东西——包括普通技能脚本,不需要 root——都可以起一个后台循环持续 touch 标记,
把自己维持成「一直活跃」。当前没有硬寿命上限,需要的话应由部署方在 daemon 侧限制。
所有 exec 都以 user(uid 1000) 运行,没有例外。 脚本执行、shell_exec、全部文件操作、
以及 manager 自己的产物目录 bootstrap 都跑在沙箱账号下;RemoteExecRequest.User 留空时
适配器解析成 DefaultSandboxExecUser 而不是 root,漏传账号只会失去权限、不会拿到权限。
bootstrap 尤其不能以 root 跑:产物目录位于会话自己可写的 /workspace 下,而 chown/chmod
会跟随符号链接。会话只要把产物目录换成指向 /etc 的链接,一次 root bootstrap 就会把 /etc
的属主交给沙箱账号,接着改写 passwd 即可让该账号在下一次 exec 时变成 uid 0(真机验证过)。
以沙箱账号执行时这条链直接断在内核:chown 对不属于自己的目标一律失败。
容器 CapDrop: ALL 之后额外补回 CHOWN/DAC_OVERRIDE/FOWNER/FSETID/SETGID/SETUID/KILL,
Docker 默认给的 NET_RAW、MKNOD、SYS_CHROOT 等一律不给。注意这批 capability 是给容器内
root 用的(装包、修属主),而目前没有任何 exec 以 root 运行,因此它们对现有路径是冗余的;
保留是为了自定义镜像里用 sudo 装包的场景,收紧它们是可以独立推进的加固项。
文件操作走 exec,不走 archive 接口。 archive 接口(PUT/GET/HEAD /archive)由 daemon
执行,这意味着两件事同时成立:它忽略 exec user 一律以 root 操作,并且会在路径解析时跟随符号
链接。沙箱账号对 /workspace 有写权限,而调用侧的路径守卫都是字符串前缀比较,于是
ln -s /root /workspace/output/esc 之后,/workspace/output/esc/secret.txt 既能通过守卫,
又会被 daemon 以 root 读出来(真机验证过,不是推演)。改成以沙箱账号 exec 之后,能不能读写
由内核判定,符号链接指向哪里都不再重要,也不存在「先校验后使用」之间被换掉链接的窗口。
Stat 用 find,它不跟随最后一段路径,因此路径本身是链接时会如实报告为 other 类型,
要求正规文件的调用方在尝试读取之前就会拒绝。
这个保证到最后一段为止:中间层的链接由内核在路径解析时展开,/workspace/output/链接/passwd
仍会 stat 成普通文件(真机验证过)。因此「只读产物目录」是一个约定而非权限边界——绕过它读到的
东西,沙箱账号本来就能用 shell_exec 读到,真正的边界始终是内核的权限检查。
archive 接口里只剩 HEAD 还在用,且仅用于读固定路径的活跃标记。
PID 1 开 tini(HostConfig.Init)。 容器入口是 sleep,它从不调用 wait()。长会话里
后台进程一旦活得比启动它的 exec 久,退出后就会变成没人回收的僵尸,堆到 pids_limit 之后所有
后续 exec 都会失败。
镜像即模板。 ListTemplates 列出 daemon 上带 com.weknora.sandbox.template=true 标签的、
或名字就是标准镜像的镜像;EnsureStandardTemplate 在后台拉取,拉取期间模板状态显示为
building,与其它后端的模板构建流程对齐。
在「设置 → 沙箱后端」中新建配置并选择 Docker:
系统管理员可在「设置 → 系统设置 → 网络安全」打开 Docker 沙箱(立即生效)。未落库时回退到 WEKNORA_SANDBOX_DOCKER_ENABLED。默认关闭,因为能保存 Docker 配置的空间管理员可以在本进程够得到的 Engine API 上创建容器,而本机 docker.sock 等同宿主机 root。设置页始终有 Docker 标签;未打开时没有添加按钮,只提示如何启用。
| 字段 | 说明 |
|---|---|
| 镜像 | 必填。会话容器都从它创建,等价于其它后端的 template ID |
| Docker 守护进程地址 | 留空跟随本机 docker CLI(DOCKER_HOST 或当前 docker context),因此 Colima / Docker Desktop 不必手填 socket。远程填 tcp://host:2376,必须同时填 TLS 证书目录;私网地址要打开「允许访问私网集群地址」 |
| TLS 证书目录 | 远程 daemon 必填。WeKnora 主机上包含 ca.pem/cert.pem/key.pem 的目录,证书不入库 |
| 空闲回收 | 容器多久没执行任何命令就回收。留空 1800 秒 |
| CPU / 内存 / 进程数上限 | 单个沙箱的资源上限。留空 2 核 / 2048 MB / 512 进程 |
| 网络模式 | 只接受 bridge(默认)与 none(完全禁止出网)。host、container: 以及自定义网络名一律拒绝:常见部署通过挂载的 docker.sock 连 daemon,填上部署自身的 compose 网络就会让沙箱与 Postgres / Redis 同网 |
tcp:// daemon 的连接与其它后端的租户端点同一口径:保存时校验地址,实际拨号时再按
「允许访问私网地址」开关过一遍 SafeDialControl,这样保存校验解析到公网、连接时被
重解析到 169.254.169.254 的情况也拦得住。unix socket 不经过这一层。
镜像要求:uid 1000 的 user 账号、/workspace/{input,output} 归该账号所有、
GNU find(-printf)与 coreutils timeout。docker/Dockerfile.sandbox 产出的标准镜像满足这些,
Debian 系基础镜像天然带 find 和 timeout。
部署形态:
| 形态 | 适用 | 关键约束 |
|---|---|---|
| 本机 socket | 单机、私有化、开发 | app 与 daemon 同机;docker.sock 等于宿主机 root,只能暴露给 app 进程 |
| 远程 daemon(mTLS) | 沙箱负载与应用分离 | 必须配 TLS 证书;daemon 端口不得暴露到公网 |
| 每租户独立 daemon | 有强隔离诉求但没有 KVM | 由部署方分配,不同配置指向不同 host |
WeKnora 自己跑在容器里时,要把 实际的 docker socket 挂进 app 容器(并接受它等同宿主机 root 的事实),
或者改用远程 daemon。Linux 上通常是 /var/run/docker.sock;macOS 上 Colima / Docker Desktop / OrbStack
各自有 $HOME 下的 socket,以 docker context show 为准。入口脚本在 gosu 降权前会按
socket 的 GID 把 appuser 加入对应组;不要依赖 compose group_add,也不要 chmod 666
宿主机 socket。若 socket 是 root:root 且仅所有者可写,容器内无法安全补权,需在宿主机把
socket 改成非 root 组的 660。
这些是 Docker 给不了的,写在这里以免被当成 bug:
docker commit 只保存文件系统。CRIU(docker checkpoint)是 experimental,
默认 daemon 直接拒绝。E2B 的 pause/snapshot 会保存内存,Docker 不会。RemoteNetworkPolicy 里的域名 allow/deny 在这个后端
只能表达成「全开」或「全关」,要按域名放行得在部署侧加 egress proxy。SupportsVolumes 目前是 false,租户级共享卷还没有映射到 Docker named volume。「空间级管理沙箱装 skill → commit 成快照 → 会话从快照起容器 → 增量出下一版」这套流程已经接入:
DockerRemoteClient 实现 RemoteSnapshotManager,docker commit 打出带
com.weknora.sandbox.skill-snapshot 标签的本地镜像(命名空间 weknora-skill/),
会话启动时用该镜像覆盖配置里的基础 image。安装器用 root shell_exec 写
/opt/weknora/tenant/skills,与 Cube / E2B 同一条技能安装链路。
两个要注意的约束:镜像层上限 127,长期增量要定期压平;快照是本机资产,多机部署必须推到 registry。 压平和跨 daemon 分发还不在这条路径里。
磁盘占用的实际形态和 Cube / E2B 不一样,值得单独说清楚:
PruneSupersededSnapshots 到期删掉旧 tag 也几乎
回收不了空间。真正回收发生在整条链的 tag 全部退役之后,因此 DeleteSnapshot 必须带
PruneChildren(即 noprune=0),否则无 tag 的祖先层会永久留下。rm -rf 在 overlay 上是新增一层 whiteout,被删的文件仍
留在父层。也就是说装和卸都只增不减,Cube / E2B 是每代一张独立模板、到期真删,只有 Docker
是单调累积。要把空间还回来,只能从基础模板重建整条链——SkillSnapshotTriggerRebuild 为此
预留了,但重建流程尚未实现。local-daemon,不能采用 DetectLocalDockerHost() 的结果:那个值来自 DOCKER_HOST 或当前
docker context,切一次 Colima / Docker Desktop / OrbStack 就会变,而它一变就等价于「凭据
轮换」——会话静默退回基础模板(skill 全部消失)、安装被拒、快照清理被永久跳过。本机 daemon
换个 host 字符串通常还是同一块盘上的同一批镜像,跨账号那套推理在这里不成立。回收路径依赖 ledger 能给每张快照命名,而 snapshot_id 只有在 provider 应答之后才写得下来。
进程死在 commit 与那次写入之间,就会留下一张谁都叫不出名字的快照:PruneSupersededSnapshots
因为状态是 building 而跳过它,ReconcileSnapshots 只告警不删,配置删除时空 snapshot_id
被当成「无需释放」。因此 planned_name 在 commit 之前就落库(迁移 000088),之后靠
provider 的 ListSnapshots 按名字认领:Cube / E2B 会把请求的名字回显在 Names 里,Docker 的
ID 本身就是这个名字加上 weknora-skill/ 前缀。两条路径会用它——周期清理里的
reapAbandonedBuilds,以及配置删除时的 resolveAbandonedBuildIDs(配置一删,周期清理就再也
遍历不到这张快照,那是最后一次机会)。只有认领成功才会删;名字对不上时不动,因为无法区分
「commit 从未发生」和「该 provider 不回显名字」,猜错就等于丢掉一张仍然存在的快照的唯一记录。
单元测试用一个内存版 Engine API 驱动适配器,不需要 daemon:
go test ./internal/sandbox -run 'TestDocker' -count=1
一致性测试打真实 daemon,覆盖会话状态保持、包安装跨执行存活、shell_exec 复用同一沙箱、
附件暂存与产物收集、超时确实终止进程、容器被外部停掉后恢复:
docker build -f docker/Dockerfile.sandbox --target sandbox -t wechatopenai/weknora-sandbox:dev .
DOCKER_INTEGRATION_IMAGE=wechatopenai/weknora-sandbox:dev \
go test -tags=docker_integration ./internal/sandbox \
-run '^TestDocker.*Integration' -count=1 -v -timeout=15m
它和 E2B 的一致性测试断言的是同一批语义,这是刻意重复:一个后端只过其中一个, 就说明两者在应用层还不能互换。
docs/poc/docker-sandbox 是当初的可行性验证,保留下来作为 「Docker 能做什么、不能做什么」的可复现证据,它不参与主模块构建。