跳到主要内容

数据截至 (上游 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 3Node.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 解成结构体,出错直接返回 -400internal/controller/base.go
service 层选语言、套超时、聚合输出internal/service/python.gonodejs.gorun_code.go
Python runner编排 python3 子进程、管 bootstrap 脚本internal/core/runner/python/python.go
Node runner编排 node 子进程、给每次请求现搭根目录internal/core/runner/nodejs/nodejs.go
UID 池借还 10000-10999 之间的临时 UIDinternal/core/runner/uidpool/uid_pool.go
输出捕获管道读取、超时强杀、退出码归类internal/core/runner/output_capture.go
上锁库(c-shared)被子进程加载,执行 chroot/seccomp/setuidinternal/core/lib/python/add_seccomp.gocmd/lib/python/main.go
系统调用白名单按语言 × 架构列出放行的 syscall 号internal/static/python_syscall/internal/static/nodejs_syscall/
沙箱文件系统装配发现 Python 库路径并硬链接进沙箱根internal/static/python_lib_discovery.gointernal/core/runner/python/env.sh

三个必须先建立的概念

这三个词在后面每一章都会出现,先各用一句话点破:

  • bootstrap 脚本(引导脚本) — 真正被 python3 执行的那个文件。它不含用户代码,只负责「加载上锁库 → 上锁 → 从 fd 3 读用户代码 → 执行」。模板在 internal/core/runner/python/prescript.py
  • seccomp(系统调用过滤器) — Linux 内核特性,让进程给自己装一段 BPF 程序,规定「哪些系统调用允许、哪些直接杀进程」。装上以后不可撤销。
  • c-shared 库 — Go 用 -buildmode=c-shared 编出来的 .so(build/build_amd64.sh:6-8)。Python 用 ctypes 加载它,Node 用 koffi 加载它,两边调的是同一个导出函数 DifySeccomp

一句话把主线串起来

请求进来 → 借个 UID → 写一份只含引导逻辑的 bootstrap → 起子进程并把用户代码从 fd 3 灌进去 → 子进程加载 .so 给自己套上 chroot + seccomp + 降权 → 执行代码 → 管道收 stdout/stderr → 进程退出、删脚本、还 UID → 拼成 JSON 返回。


3. 阅读地图

建议顺序就是章节顺序;每章都能独立读,但 03 章是这个项目的技术内核,时间有限就直接跳过去。

章节讲什么什么时候读它
01-request-lifecycle.mdHTTP 到 service 的这一段:鉴权、MaxRequest/MaxWorker 两道闸门、超时、四字段输出协议想接入它、或想搞清楚返回值语义时
02-sandbox-core.mdrunner 怎么编排子进程:UID 池、bootstrap 拼装、fd 3 传代码、Python 与 Node 的编排差异想理解「一次执行」的完整机械过程时
03-seccomp-chroot.md三道锁:chroot、no_new_privs、seccomp BPF、setuid,顺序为什么是这个顺序想学这个项目的精华,只读这一章
04-fs-and-multilang.md沙箱里为什么能 import requests:库路径自动发现 + 硬链接复制 + 依赖热更新遇到 xxx.so: cannot open shared object file
05-cleverness-and-boundaries.md可借鉴的技巧、诚实的边界与已知弱点、和 e2b/microsandbox 等的对比做技术选型、或想抄它的设计时

4. 代码地图(总入口)

每章末尾都有更细的表;这里只给「从零开始读源码」的六个落脚点。

主题文件路径符号名
进程入口cmd/server/main.gomain
启动编排(配置 → 依赖 → gin)internal/server/server.goRuninitConfiginitDependencies
路由与中间件挂载internal/controller/router.goSetupInitRunRouter
一次 Python 执行的编排主体internal/core/runner/python/python.goPythonRunner.RunbuildBootstrap
子进程自锁的四步internal/core/lib/python/add_seccomp.goInitSeccomp
被执行的引导脚本模板internal/core/runner/python/prescript.py{{preload}}DifySeccompos.fdopen(3, "rb")