跳到主要内容

数据截至 (上游 commit 97c8097d51d0)

01 · 请求生命周期与输出协议

这章讲什么: 请求从进门到出门要过哪几关,以及返回的四个字段各自到底代表什么。这一层没有任何安全魔法,但输出协议的设计相当讲究,值得单独看。


1. 三条路由,只有一条重要

路由表极小(internal/controller/router.go:11-47):

路径方法鉴权干什么
/healthGET返回字符串 "ok"
/v1/sandbox/runPOST跑代码,本项目的全部意义
/v1/sandbox/dependenciesGET/POST列出、更新、刷新 Python 依赖

只有 /v1/sandbox/run 挂了并发中间件(router.go:37-47);依赖相关的三个接口是裸的。


2. 第一关:一个字符串比较

鉴权就是拿请求头和配置里的 key 做字符串相等比较(internal/middleware/auth.go:8-16):

if config.App.Key != c.GetHeader("X-Api-Key") {
c.AbortWithStatus(401)
return
}

三个要点:

  • key 在中间件构造时就从配置里取走了(config := static.GetDifySandboxGlobalConfigurations() 在闭包外),所以运行中改配置不会生效。
  • 默认值 dify-sandbox 明文写在 conf/config.yaml:4,可被环境变量 API_KEY 覆盖(internal/static/config.go:63-66)。
  • 这是纯内网信任模型:一把静态 key,没有租户、没有过期、没有限速到人。它假定自己只暴露给 Dify 主服务。

3. 第二关:两道语义不同的并发闸门

这是很容易看混的地方。两个中间件都叫「限并发」,但行为完全相反

请求到达


┌──────────────┐ 计数器 >= max_requests ?
│ MaxRequest │────────────► 是 → 立刻 503「Too many requests」
│ (快速拒绝) │
└──────┬───────┘ 否 → 计数 +1,放行

┌──────────────┐ 信号量满了 ?
│ MaxWorker │────────────► 是 → 阻塞排队,直到有位置
│ (排队等待) │
└──────┬───────┘

真正执行
中间件机制满了怎么办符号
MaxRequest带锁的整型计数器直接 503 拒绝计数器与 tryAcquireinternal/middleware/cocrrent.go:25-40,中间件本体在 49-66
MaxWorker容量为 max 的 channel 信号量阻塞,直到别人释放internal/middleware/cocrrent.go:12-23

默认 max_requests: 50max_workers: 4(conf/config.yaml:5-6)。合起来的效果是:最多 4 个代码在同时跑,最多 50 个请求在系统里(4 个跑 + 46 个排队),第 51 个直接被拒。这样既保住了机器,又不会让上游无限等待。

第三个中间件 TraceMiddleware 只做可观测性:解析 W3C traceparent 头,解析不出来就自己生成一对 trace/span id,再把 X-User-IDX-User-Type 塞进 context(internal/middleware/trace.go:8-32),之后所有 slog.ErrorContext 打日志都会自动带上这些字段(internal/utils/log/core.go:17-35)。


4. 请求体:四个字段

绑定用的是一个匿名结构体,直接写在 handler 里(internal/controller/run.go:11-16):

字段必填含义
language只认 python3nodejs,其它返回 -400
code用户代码正文
preload在上锁前执行的前置代码,默认被丢弃(见下)
enable_network这次执行允不允许联网

绑定逻辑在 internal/controller/base.go:8-25(BindRequest):Content-Type: application/jsonBindJSON,否则走 ShouldBind(表单)。绑定失败返回 HTTP 200 + body 里 code=-400(base.go:19-22)——这是整个项目的主流风格:HTTP 状态码通常是 200,真正的错误码在 body 里

有三个例外,读的时候别被上一句带偏:

HTTP 状态码什么时候位置
400语言不支持(body 里同时是 -400)controller/run.go:27;依赖三接口同样如此,run.go:405366
401X-Api-Key 对不上,直接 abortmiddleware/auth.go:12
503并发请求数超过 max_requestsmiddleware/cocrrent.go:58

也就是说:只有「语言认得出来」的请求,才享受「HTTP 恒 200」这个待遇。语言写错、key 写错、闸门拒绝这三种情况,HTTP 层就已经把话说完了。

preload 的两道闸

preload 默认被直接清空(internal/service/python.go:19-21):

if !static.GetDifySandboxGlobalConfigurations().EnablePreload {
preload = ""
}

配置里 enable_preload: False 并附了一句注释「please keep it as False for security purposes」(conf/config.yaml:10)。为什么必须默认关,在 05 章 讲——简单说:preload 是在上锁之前、以 root 身份、在 chroot 之外跑的。

enable_network 要过全局许可

请求级的 enable_network 不能凌驾于全局配置(internal/service/check.go:14-22):

if options.EnableNetwork && !configuration.EnableNetwork {
return ErrNetworkDisabled
}

即「全局关网 + 请求要网」= -400 报错;「全局开网 + 请求不要网」= 允许,这次不给网络 syscall。


5. 超时:一个定时器 + 一刀 Kill

超时不是靠 context,而是一个朴素的 time.AfterFunc(internal/core/runner/output_capture.go:112-119):

timer := time.AfterFunc(timeout, func() {
if cmd != nil && cmd.Process != nil {
s.result.SetExitCode(-1)
s.WriteExecError([]byte("error: timeout\n"))
cmd.Process.Kill()
}
})

超时值来自 worker_timeout(秒),在 service 层换算(internal/service/python.go:23-25);CaptureOutput 里还兜了个底:传 0 就用 5 秒。

注意 cmd.Process.Kill() 发的是 SIGKILL,沙箱进程无法捕获、无法拖延。配合 seccomp 白名单里没有 fork/execve(见 03 章),被杀的一定是唯一一个进程,不存在杀不干净的孤儿。


6. 输出协议:为什么要四个字段

这是这一层最值得学的设计。子进程的输出被拆成三条 channel,而不是简单的两个 buffer(internal/core/runner/output_capture.go:18-26):

channel装什么谁写进去
stdout进程标准输出原样读管道的 goroutine(output_capture.go:149-166)
stderr进程标准错误原样读管道的 goroutine(output_capture.go:169-186)
execError沙箱自己判定的失败超时、管道读失败、wait 失败、bad system call

为什么要把第三条单拎出来?因为 stderr 里塞的东西未必是错误。一个用 logging.info() 打日志的正常脚本,日志全在 stderr 里,但它跑得好好的。源码注释把这条规则写死了(internal/service/run_code.go:16-20):成功的执行即使 stderr 有内容,error 字段也必须为空。

对应的集成测试锁定了这个语义(tests/integration_tests/python_feature_test.go:155-187,TestPythonLoggingStaysInStderr):stderr 含 INFO:root:Starting task...error 为空、exit_code 为 0。

拼装规则

最终四字段由 buildExecutionError 决定(internal/service/run_code.go:65-76):

exit_code == 0 ?
├─ 是 → error = execError(通常为空字符串)
└─ 否 → error = "process exited with code N"
+ execError(如果有)
+ stderr(全文附上,方便定位)

所以看到 error 里同时有「process exited with code -1」和 Python 的 traceback,是故意的:非零退出时把 stderr 复制一份进 error,调用方只读一个字段就够。

聚合的收尾姿势

聚合函数 collectRunCodeResponse(internal/service/run_code.go:28-63)用了一个双层 select:外层等 done,收到 done 之后不立刻返回,而是进内层循环把三条 channel 里的残留数据排干,直到 default 分支才收工。这避免了「进程结束了但最后一批输出还在 channel 里」造成的截断。

输出量不小也扛得住——TestPythonLargeOutput 断言 30 万字节输出一字不差(tests/integration_tests/python_longchars_test.go:59-61)。

退出码的三种来源

情况exit_code谁设的
正常结束进程真实退出码output_capture.go:208,status.ExitCode()
超时被杀-1定时器回调 output_capture.go:114
Process.Wait 出错-1(若原本是 0)output_capture.go:203-205

还有一条关键的翻译规则(output_capture.go:212-214):退出状态字符串里含 bad system call(即进程被 SIGSYS 打死,意味着它碰了白名单外的系统调用),就往 execError 里写一句人话:

if strings.Contains(exitString, "bad system call") {
s.WriteExecError([]byte("error: operation not permitted\n"))
}

这就是为什么用户在 Dify 界面上看到的是 operation not permitted 而不是一串信号编号——FAQ 里专门有一节解释这个错误(FAQ.md「My Python code returns an operation not permitted error?」)。


7. 错误码一览

这里说的是 body 里的 code,和上面第 4 节的 HTTP 状态码是两套编号,别混。

code触发条件位置
0服务侧一切正常(哪怕用户代码崩了)internal/types/response.go:12-18
-400参数绑定失败 / 语言不支持 / 关网时要网controller/base.go:20controller/run.go:27service/check.go:17
-429Python 专有:UID 池借不到号,消息是 no available sandbox UID: sandbox UID pool exhaustedinternal/service/python.go:32-34,消息由 python.go:41%w 包上 uid_pool.go:12 的原错误
-500runner 启动阶段的其它错误service/python.go:35service/nodejs.go:29
-503并发请求数超过 max_requestsinternal/middleware/cocrrent.go:58

注意 -429 这一条:Node 路径没有做同样的区分,UID 借不到时统一落成 -500(internal/service/nodejs.go:28-30)。这是两条路径实打实的行为不对称。


8. 代码地图

主题文件路径符号名
路由与中间件挂载internal/controller/router.goSetupInitRunRouterInitDependencyRouter
API Key 校验internal/middleware/auth.goAuth
快速拒绝闸门internal/middleware/cocrrent.goMaxRequestMaxRequestIfaceMaxRequestIface.tryAcquire
排队闸门internal/middleware/cocrrent.goMaxWorker
链路追踪注入internal/middleware/trace.goTraceMiddleware
泛型请求绑定internal/controller/base.goBindRequest
语言分发internal/controller/run.goRunSandboxController
网络许可校验internal/service/check.gocheckOptionsErrNetworkDisabled
输出聚合与错误拼装internal/service/run_code.gocollectRunCodeResponsebuildExecutionError
管道读取、超时、退出码internal/core/runner/output_capture.goCaptureOutputOutputCaptureResult
响应信封internal/types/response.goSuccessResponseErrorResponse