数据截至 (上游 commit 97c8097d51d0)
Dify Sandbox — 架构与原理
30 秒导读: Dify Sandbox 是一个只干一件事的 HTTP 服务——你 POST 一段 Python 或 JavaScript 代码给它,它在同一台机器上把这段代码关进一个「几乎什么系统调用都不许做」的短命子进程里跑完,把 stdout/stderr 还给你。它是 Dify 工作流里「代码节点」「模板 节点」背后的执行器。
1. 这是什么(零基础也能懂)
一句话定义
一个跑别人写的、你不敢信的代码的小服务。它不开虚拟机、不起容器,而是在自己所在的容器里 fork 一个子进程,让这个子进程自己把自己关进笼子,然后才执行用户代码。
解决什么问题、给谁用
场景是这样的:你做了一个 AI 工作流平台,用户在页面上写了一段 Python:
def main(x):
return {"result": x * 2}
这段代码你必须真的跑起来。但用户完全可以把它换成 os.system("cat /etc/shadow")、open("/app/.env").read()、while True: pass,或者一个把你机器网卡打满的脚本。
你需要一个东西:能跑代码,但跑出来的进程碰不到你的文件、开不了新进程、连不上不该连的网、超时会被砍掉。 这就是 Dify Sandbox。
主要使用者是 Dify 主服务(Python 后端),它把工作流里代码节点的脚本通过内网 HTTP 发过来。README 明确写了它只支持 Linux、是为 Docker 容器设计的(README.md)。
它能做什么
- 跑 Python 3 和 Node.js 两种语言的代码片段(
internal/controller/run.go:17-28,switch req.Language)。 - 按请求粒度决定允不允许联网(
enable_network)。 - 到点强杀超时的进程。
- 管 理一份 Python 依赖清单,并定期重装、重新灌进沙箱(
internal/server/server.go:78-92)。 - 用 API Key 做最简单的调用方鉴权,用两道闸门限制并发。
它不做的事同样重要:不管调度、不管多租户配额、不持久化任何东西、不给沙箱内的代码留下任何文件系统状态。
用起来什么样
服务默认监听 8194,默认 key 是 dify-sandbox(conf/config.yaml:1-4)。一次真实调用长这样:
curl -X POST http://localhost:8194/v1/sandbox/run \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: dify-sandbox' \
-d '{"language":"python3","code":"print(1+1)","enable_network":false}'
返回的 JSON 是两层结构——外层是服务级的成败,内层 data 是进程级的结果:
{
"code": 0,
"message": "success",
"data": { "stdout": "2\n", "stderr": "", "error": "", "exit_code": 0 }
}
字 段定义在 internal/types/response.go:3-10(外层 DifySandboxResponse)和 internal/service/run_code.go:21-26(内层 RunCodeResponse)。
换成一段作恶的代码,返回是这样(行为由集成测试 TestRunCommand 锁定,tests/integration_tests/python_malicious_test.go:52-71):
{
"code": 0,
"message": "success",
"data": {
"stdout": "",
"stderr": "",
"error": "process exited with code -1\nerror: operation not permitted\n",
"exit_code": -1
}
}
注意外层 code 仍然是 0:「用户代码跑挂了」不是「服务出错了」,这个区分贯穿整个响应设计(见 01 章)。
一句话直觉
把它想成一次性的密室:每来一个请求,现搭一间没有门窗的小屋(chroot 根 + 临时 UID),把人推进去,人进去后自己把所有工具都扔出来(seccomp 白名单),然后才开始干活;活干完或者时间到,屋子直接拆掉。
关键在**「自己把自己关起来」**——上锁的代码不是父进程写的,而是子进程加载一个 Go 编译出来的动态库、主动调用它给自己套上枷锁的。这是整个项目最独特的一招。
2. 顶层全景(它大概怎么转)
一次请求的主线
先看这张图。从上往下是时间顺序,虚线框里是子进程自己做的事——父进程管不着,也不需要管。
Dify 主服务
│ POST /v1/sandbox/run { language, code, enable_network }
▼
┌─────────────────────┐
│ ① gin HTTP 层 │ X-Api-Key 校验 → 并发闸门 → trace id
│ controller/*.go │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ ② service 层 │ 按语言挑 runner、套 worker_timeout、
│ service/*.go │ 检查 enable_network 是否被全局允许
└──────────┬──────────┘
▼
┌─────────────────────┐
│ ③ runner 编排 │ 借一个临时 UID → 写 bootstrap 脚本 →
│ core/runner/* │ 起 python3/node 子进程,用户代码走 fd 3
└──────────┬──────────┘
▼
╭ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮
│ ④ 子进程自锁 │ chroot → no_new_privs → seccomp → setuid
│ 加载 python.so │ ——四步做完,才 exec 用户代码
╰ ─ ─ ─ ─ ─ ┬ ─ ─ ─ ─ ╯
▼
stdout / stderr 管道 → 聚合成四字段 JSON → 返回
怎么读:①②③ 都在**父进程(Go 服务)里;④ 在子进程(python3 或 node)**里。父子之间只有三条通道——命令行参数、fd 3(送代码)、stdout/stderr(收结果)。
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| HTTP 入口 | 建路由、挂鉴权与并发中间件 | internal/controller/router.go |
| 请求绑定 | 把 JSON/form 解成结构体,出错直接返回 -400 | internal/controller/base.go |
| service 层 | 选语言、套超时、聚合输出 | internal/service/python.go、nodejs.go、run_code.go |
| Python runner | 编排 python3 子进程、管 bootstrap 脚本 | internal/core/runner/python/python.go |
| Node runner | 编排 node 子进程、给每次请求现搭根目录 | internal/core/runner/nodejs/nodejs.go |
| UID 池 | 借还 10000-10999 之间的临时 UID | internal/core/runner/uidpool/uid_pool.go |
| 输出捕获 | 管道读取、超时强杀、退出码归类 | internal/core/runner/output_capture.go |
| 上锁库(c-shared) | 被子进程加载,执行 chroot/seccomp/setuid | internal/core/lib/python/add_seccomp.go、cmd/lib/python/main.go |
| 系统调用白名单 | 按语言 × 架构列出放行的 syscall 号 | internal/static/python_syscall/、internal/static/nodejs_syscall/ |
| 沙箱文件系统装配 | 发现 Python 库路径并硬链接进沙箱根 | internal/static/python_lib_discovery.go、internal/core/runner/python/env.sh |