跳到主要内容

Dify Sandbox — 这是什么 · 全景 · 阅读地图

30 秒导读: Dify Sandbox 是一个 Go 写的小 HTTP 服务,专门干一件危险活——替别人执行他们提交的、你完全不信任的代码。它对外只有一个核心接口 POST /v1/sandbox/run:你把一段 Python 或 Node.js 代码发进去,它在一个上了三重锁的子进程里跑,把 stdout/stderr 收回来还给你。真正的价值不在这个 HTTP 壳,而在子进程里那套 in-process 沙箱:换根、砍系统调用、降权到一次性用户。

本章是这个子库的门厅——只讲顶层总览,不进任何机制的深水区(那些留给 01–05 章)。读完你应该能对同事讲清:它是什么、大概怎么转、安全靠什么、接下来该读哪章。


1. 这是什么(零基础也能懂)

一句话定义。 Dify Sandbox 是一个在多租户环境下安全执行不可信代码的 Go HTTP 服务——多个用户可以往里提交代码,它在一个受限的沙箱里跑,限制这段代码能碰的资源和系统调用(README.md:3)。

解决谁的什么问题。 想象你在做一个 AI 应用平台(它正是 Dify 的沙箱组件):平台上的用户会写"代码节点",里面是任意 Python/Node.js。你不能直接在自己服务器上 exec 这段代码——它可能读你的密钥、删你的文件、发起横向攻击。你需要一个"防爆间":把代码扔进去引爆,炸的只是那个一次性的小房间。Dify Sandbox 就是这个防爆间。

它能做什么。

  • 执行 Python 3Node.js 两种语言的代码片段(controller/run.go:17-25)。
  • 在执行前把进程关进沙箱:换根目录、只放行白名单系统调用、降权到一次性 UID。
  • 采集代码的 stdout / stderr / 退出码,结构化返回。
  • 管理可用依赖(Python requirements),支持列出、更新、刷新(controller/run.go:32-69)。
  • 靠 API Key 鉴权、并发限额、超时限额来扛多租户压力。

平台限制。只支持 Linux——因为整套安全机制(seccomp、chroot、setuid)都是 Linux 内核特性,设计上就是跑在 Docker 容器里的(README.md:7)。

一句话直觉/类比。 把它当成餐厅的独立试毒间:客人(租户)递进来一道来路不明的菜(代码),你不在自己厨房尝,而是送进一个门锁死、窗封上、只留一个送餐口的小隔间里让它自己反应,反应完把结果(stdout/stderr)从送餐口递出来,隔间随后作废。


2. 用起来什么样(一个最小请求 / 响应)

对外的核心只有一个接口:POST /v1/sandbox/run。请求体的字段来自 controller 里那个匿名 req 结构体(controller/run.go:11-16),响应 data 部分的字段来自 service.RunCodeResponse(service/run_code.go:21-26)。

请求(需带 X-Api-Key 头,鉴权见 middleware/auth.goAuth):

curl -X POST http://localhost:8194/v1/sandbox/run \
-H "X-Api-Key: <your-key>" \
-H "Content-Type: application/json" \
-d '{
"language": "python3",
"code": "print(1 + 1)",
"preload": "",
"enable_network": true
}'

请求字段(源:controller/run.go:11-16 的匿名结构体):

字段类型含义
languagestring"python3""nodejs",必填(binding:"required")
codestring要执行的用户代码,必填
preloadstring在锁上笼子之前先跑的初始化代码(如 import),可空
enable_networkbool是否给这段代码放行网络相关系统调用

响应(外层是统一信封 types.DifySandboxResponse,dataRunCodeResponse):

{
"code": 0,
"message": "success",
"data": {
"stdout": "2\n",
"stderr": "",
"error": "",
"exit_code": 0
}
}

响应 data 字段(源:service/run_code.go:21-26RunCodeResponse):

字段含义
stdout子进程原始标准输出
stderr子进程原始标准错误 / 日志输出
error真正执行失败时才填(退出码非 0 时带上 process exited with code N + 细节);成功执行即使 stderr 有内容,error 也保持空(见 service/run_code.go:16-26 的注释与 buildExecutionError)
exit_code子进程退出状态码

外层信封 code/message/data 的规则见 types/response.goSuccessResponse / ErrorResponse——成功 code: 0,出错 code 为负数。


3. 顶层全景图(它大概怎么转)

怎么读这张图: 从上到下是一次 POST /v1/sandbox/run 的数据流。左侧是"在主进程里发生的事"(HTTP → 中间件 → controller → service → runner);到 runner 这一层它会派生一个子进程,真正危险的用户代码在那个子进程里、且先上三重锁再执行;最后 runner 从子进程收集 stdout/stderr,一路原路返回。

HTTP 客户端
│ POST /v1/sandbox/run (X-Api-Key, JSON)

┌─────────────────────────────────────────────────────────────┐
│ 主进程 (Go / gin) │
│ │
│ ① 中间件链 │
│ 鉴权 Auth ─ 并发闸 MaxRequest/MaxWorker ─ 链路追踪 Trace │
│ internal/middleware/{auth,cocrrent,trace}.go │
│ │ │
│ ▼ │
│ ② controller: 按 language 分派 │
│ internal/controller/run.go RunSandboxController │
│ │ │
│ ▼ │
│ ③ service: 取超时/配置, 调 runner, 收集结果 │
│ internal/service/{python,nodejs,run_code}.go │
│ │ │
│ ▼ │
│ ④ language runner: 领一个一次性 UID, 搭好沙箱目录, │
│ 写引导脚本, 用管道把用户代码喂进去 │
│ internal/core/runner/python/python.go PythonRunner.Run │
│ │ exec.Command 派生 │
└────────────────────────────┼─────────────────────────────────┘

┌───────────────────────────────────────────┐
│ ⑤ 子进程 (解释器 + in-process 沙箱) │
│ │
│ prescript.py 引导: │
│ chdir 到沙箱根 → 跑 preload → │
│ 调 python.so 的 DifySeccomp(): │
│ chroot 换根 + setgid/setuid 降权 │
│ + seccomp 系统调用白名单 │
│ ── 三重锁上齐 ── │
│ exec(用户代码) ← 真正不可信的代码在这 │
│ internal/core/lib/python/add_seccomp.go │
└───────────────────────────────────────────┘
│ stdout / stderr

⑥ 采集: OutputCaptureRunner 把输出收进 channel
internal/core/runner/output_capture.go


collectRunCodeResponse 拼成 RunCodeResponse
internal/service/run_code.go


HTTP 200 + JSON 返回

各部件一句话职责(源文件见图中标注):

部件干什么在哪个文件
中间件链API Key 鉴权、并发/请求数限额、注入 trace 上下文internal/middleware/{auth,cocrrent,trace}.go
controller校验请求、按 language 分派到对应 serviceinternal/controller/run.go
service取超时/配置,调 runner,把 channel 输出拼成响应internal/service/{python,nodejs,run_code}.go
language runner领 UID、搭沙箱目录、写引导脚本、派生子进程internal/core/runner/python/python.go
in-process 沙箱在子进程里 chroot + setuid + seccomp 后才跑用户代码internal/core/lib/python/add_seccomp.go + prescript.py
输出采集把子进程 stdout/stderr 收进 channel,带超时internal/core/runner/output_capture.go

4. 核心安全直觉(纵深防御四层)

这是整个项目的灵魂,也是你读完最该带走的一句话:安全不靠单一机制,而是四层锁叠在一起——就算某一层被绕过,还有下一层挡着(这叫纵深防御,defense in depth)。而且这四层都在派生出去的子进程里生效,主进程的 Go 服务本身不碰用户代码。

这层挡什么关键实现
chroot 换根把子进程的根目录 / 换成一个精心搭建的最小目录,用户代码看不到宿主真实文件系统internal/core/lib/python/add_seccomp.go:18 syscall.Chroot(".")
seccomp 白名单只放行一小撮系统调用,其它一律拒绝——想 fork 攻击、开危险 fd?内核直接拦internal/core/lib/python/add_seccomp.goDifySeccomp + internal/static/python_syscall/syscalls_amd64.goALLOW_SYSCALLS
降权到一次性 UID从 UID 池领一个专属用户,setgid/setuid 降权后再跑代码,跑完 UID 归还——每次执行一个隔离身份add_seccomp.go:62,68 Setgid/Setuid;UID 池 internal/core/runner/uidpool/uid_pool.go
并发 / 超时限额限制同时在跑的请求数、给每次执行套超时,防资源耗尽internal/middleware/cocrrent.go MaxRequest/MaxWorker;output_capture.goSetTimeout

顺序很关键。 引导脚本 prescript.pychdir 到沙箱根、先跑用户的 preload(此时还没上锁,方便 import),然后才调 DifySeccomp() 一次性把 chroot + 降权 + seccomp 都锁上,最后才 exec 用户的真正代码(prescript.py:25-35)。也就是说,preload 在笼子外、code 在笼子里——这是理解请求生命周期的一个关键分界(细节见 0102)。

真正的价值在哪。 别被外面那层 Go HTTP 服务迷惑——路由、鉴权、限流都是常规工程。这个项目的技术精华,是子进程里那套 in-process 沙箱:一个 Python 解释器启动后,靠 ctypes 加载一个 Go 编译出的 python.so,由它在同一个进程内把三重锁一次性焊死,再交出控制权跑不可信代码。想学"怎么把代码关进笼子",直接看 0203


5. 阅读地图(建议顺序)

由浅入深,建议按顺序读;想直接看安全机制可跳到 02/03。

顺序章节一句话摘要
1index.md(你在这)这是什么、顶层全景、四层安全直觉、去哪读
2一次执行请求的完整生命周期追一次 POST /run:中间件 → controller → service → runner → 子进程 → 收集输出的完整链路
3沙箱核心:一段不可信代码如何被关进笼子沙箱核心:引导脚本 + python.so,一段不可信代码怎么被一步步关进笼子
4两道锁:seccomp 系统调用白名单与 chroot 换根降权两道锁的实现细节:seccomp 系统调用白名单怎么装、chroot 换根与 setuid 降权怎么做
5沙箱文件系统的搭建与多语言支持沙箱那个最小文件系统怎么搭起来,Python 与 Node.js 如何共用同一套机制
6巧妙之处、配置与边界局限巧妙之处(UID 池、preload 时机、依赖热更新)、配置项、以及它刻意不做什么、会在哪崩

6. 顶层代码地图(导航索引)

给要跳进源码的人/agent 的跳转表。符号名比行号抗漂移,优先用符号 grep

主题文件符号
进程入口cmd/server/main.gomainserver.Run
启动骨架internal/server/server.goRuninitConfiginitServerinitDependencies
路由注册internal/controller/router.goSetupInitRunRouter
核心接口 handlerinternal/controller/run.goRunSandboxController(匿名 req 结构体)
鉴权中间件internal/middleware/auth.goAuth
并发/请求限额internal/middleware/cocrrent.goMaxRequestMaxWorker
链路追踪internal/middleware/trace.goTraceMiddleware
Python serviceinternal/service/python.goRunPython3Code
响应拼装internal/service/run_code.goRunCodeResponsecollectRunCodeResponsebuildExecutionError
统一响应信封internal/types/response.goDifySandboxResponseSuccessResponseErrorResponse
Python runnerinternal/core/runner/python/python.goPythonRunner.RunInitializeEnvironmentbuildBootstrap
子进程引导脚本internal/core/runner/python/prescript.pyDifySeccomp 调用点、exec(code)
三重锁(chroot+降权+seccomp)internal/core/lib/python/add_seccomp.gosyscall.ChrootSetgidSetuidDifySeccomp
系统调用白名单internal/static/python_syscall/syscalls_amd64.goALLOW_SYSCALLS
一次性 UID 池internal/core/runner/uidpool/uid_pool.goUID 领取/归还
输出采集internal/core/runner/output_capture.goOutputCaptureRunnerCaptureOutputSetTimeout

启动骨架读法:main 只调 server.Run(cmd/server/main.go:5-7);Run 依次 initConfig(读 conf/config.yaml、装日志、备好 runner 依赖)→ 后台 go initDependencies()(装 Python 依赖、备好依赖沙箱、起定时器周期更新)→ initServer(建 gin、controller.Setup 挂路由、监听端口),见 internal/server/server.go:15-116