Files
xinmi/docs/sandbox-docker-backend.md
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

226 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Docker 沙箱后端
面向部署方与要改这块代码的人。本文说明 docker 后端现在是什么形态、怎么配、边界在哪,
以及为什么是这个设计。协议层的整体立场见 [沙箱协议接入说明](./sandbox-protocol.md),
CubeSandbox / E2B 的集群与模板见 [沙箱集群与标准模板](./sandbox-cluster.md)。
## 结论先说
- docker 后端已经是**会话级后端**:一个会话一个长驻容器,脚本、`shell_exec`、附件暂存、
产物收集都落在同一个容器里,与 E2B/Cube 在应用层的行为一致。
- 实现方式是 `RemoteSandboxClient` 适配器(`internal/sandbox/docker_remote_client.go`),
直接打 Docker Engine API。session→sandbox 绑定、生命周期锁、能力矩阵一行没改——
这正是当初把 provider 抽象成 `RemoteSandboxClient` 的收益。
- 它适合单机 / 私有化部署。跨主机调度、内核级隔离、内存态快照仍然要用 E2B 协议后端,
原因见「边界」一节。
- **默认关闭。** 本机 `docker.sock` 等同宿主机 root。系统管理员可在「设置 → 系统设置 → 网络安全」打开,立即生效;也可用环境变量 `WEKNORA_SANDBOX_DOCKER_ENABLED=true` 作为未落库时的回退。设置页始终保留 Docker 标签:未打开时没有「添加」入口,只说明如何启用。已有配置仍可查看/删除,但不会再创建容器。
- Docker 官方的 Docker Sandboxes(`sbx`)不能当后端:那是开发者本机 CLI,要 Docker 账号登录、
工作区是宿主机目录直挂、没有多租户服务端 API。
## 之前为什么不行
旧实现每次执行都是 `docker run --rm` 加一个只读 bind mount,和 E2B 不是「能力少一点」,
而是模型不同。具体是这几条(都在改造中修掉了):
| 旧行为 | 后果 |
| --- | --- |
| 执行完即销毁容器 | 没有会话状态,`shell_exec`、附件暂存、产物收集在能力矩阵里根本不注册 |
| 超时 `kill` 的是 `docker run` 客户端进程 | 容器继续跑到自己结束;WeKnora 已经给用户返回超时了。实测见 [PoC](./poc/docker-sandbox) |
| `/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](./poc/docker-sandbox)
里专门复现了这条),所以每次 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:
- **跨主机调度**:一个配置就是一个 daemon,也就是一台机器。要多机就得自己选机、分发镜像、
在绑定里记住会话落在哪台机器上——那就是在写一个控制面,这条边界是刻意保留的。
- **内核级隔离**:容器共享宿主机内核。要更强只能叠 gVisor / Kata(配置里的 runtime 字段)。
- **内存态快照**:`docker commit` 只保存文件系统。CRIU(`docker checkpoint`)是 experimental,
默认 daemon 直接拒绝。E2B 的 pause/snapshot 会保存内存,Docker 不会。
- **域名级出网策略**:Docker 只有 L3/L4。`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 不一样,值得单独说清楚:
- 第 N+1 代是从第 N 代起的容器 commit 出来的,**N 的层完整包含在 N+1 里**。所以两代镜像并存
时,旧的那个 tag 不额外占盘;反过来说,`PruneSupersededSnapshots` 到期删掉旧 tag 也几乎
回收不了空间。真正回收发生在整条链的 tag 全部退役之后,因此 `DeleteSnapshot` 必须带
`PruneChildren`(即 `noprune=0`),否则无 tag 的祖先层会永久留下。
- **卸载一个 skill 会让镜像变大**:`rm -rf` 在 overlay 上是新增一层 whiteout,被删的文件仍
留在父层。也就是说装和卸都只增不减,Cube / E2B 是每代一张独立模板、到期真删,只有 Docker
是单调累积。要把空间还回来,只能从基础模板重建整条链——`SkillSnapshotTriggerRebuild` 为此
预留了,但重建流程尚未实现。
- 快照的 owner fingerprint 只在 host 是**显式配置**时才把 host 计入。留空的配置一律记成
`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:
```bash
go test ./internal/sandbox -run 'TestDocker' -count=1
```
一致性测试打真实 daemon,覆盖会话状态保持、包安装跨执行存活、`shell_exec` 复用同一沙箱、
附件暂存与产物收集、超时确实终止进程、容器被外部停掉后恢复:
```bash
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](./poc/docker-sandbox) 是当初的可行性验证,保留下来作为
「Docker 能做什么、不能做什么」的可复现证据,它不参与主模块构建。