跳到主要内容

一次执行请求的完整生命周期

30 秒导读: Dify Sandbox 对外只有一个真正干活的接口 POST /v1/sandbox/run:你把一段代码 + 语言丢进去,它在隔离环境里跑完,把 stdout / stderr / 退出码原样还给你。本章不进沙箱内部,只沿着这条 HTTP 主线,把请求怎么被鉴权、怎么排队、怎么按语言分发、怎么带超时执行、怎么被流式采集回一个 JSON,一步步讲透。

本章是进入沙箱内部之前的"主干道"。看懂了这条路,后面 沙箱核心 讲的"代码到底怎么被关进笼子"才有挂靠点。


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

一句话定义: Dify Sandbox 是一个 HTTP 服务,专职"接一段不可信代码,在隔离环境里安全地跑一次,把结果还给你"。

它对外长什么样。 整个服务只暴露极少的几个路由。真正执行代码的就一个:

路由方法作用
/healthGET健康检查,不鉴权
/v1/sandbox/runPOST跑一段代码,本章主角
/v1/sandbox/dependenciesGET/POST查/更新依赖,需鉴权

一次调用什么样。 你 POST 一个 JSON:

{
"language": "python3",
"code": "print(1 + 1)",
"preload": "",
"enable_network": false
}

拿回一个统一信封:

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

一句话直觉。 把它想成一个"一次性的代码自动售货机":投币(带对 API Key)→ 排队(并发闸门)→ 投料(你的代码)→ 机器在防爆玻璃箱里跑一次 → 吐出结果和小票(退出码)。本章讲的就是"从投币到吐小票"这条流水线,不讲玻璃箱本身怎么造


2. 顶层全景(请求怎么流动)

怎么读这张图: 从上到下就是一条请求的时间线;左边是它经过的"关卡"(gin 中间件 + controller + service),右边一句话说这一关干嘛。命中任何一个红叉就地返回,不再往下走。

POST /v1/sandbox/run

┌──────▼───────┐
│ Auth 鉴权 │ X-Api-Key 不对 → 401 就地终止
│ (整个私有组) │
└──────┬───────┘

┌──────▼───────────┐
│ MaxRequest 闸门① │ 在跑请求数 ≥ 上限 → 503 直接拒
│ (计数器,不排队) │
└──────┬───────────┘

┌──────▼───────────┐
│ MaxWorker 闸门② │ 信号量满 → 阻塞排队,等到有位再进
│ (带缓冲 channel) │
└──────┬───────────┘

┌──────▼───────┐
│ Trace 中间件 │ 解析/生成 traceID,塞进 ctx
└──────┬───────┘

┌──────▼─────────────────┐
│ RunSandboxController │ binding 校验 required 字段
│ 按 language 分发 │ python3 / nodejs / 其它→ -400
└──────┬─────────────────┘
│ python3
┌──────▼─────────────────┐
│ service.RunPython3Code │ 网络开关校验、preload gate、
│ │ 算超时、错误码映射
└──────┬─────────────────┘
│ 进沙箱执行(本章不展开)
┌──────▼─────────────────┐
│ collectRunCodeResponse │ 双层 select 边跑边收 stdout/
│ (流式采集 + 组装退出码) │ stderr,拼出 error
└──────┬─────────────────┘

JSON 信封返回

各部件一句话职责:

部件干什么文件
Setup / InitRunRouter装配路由和中间件链internal/controller/router.go:11,37
Auth比对 X-Api-Keyinternal/middleware/auth.go:8
MaxRequest计数器,超限直接 503internal/middleware/cocrrent.go:49
MaxWorker信号量,满了阻塞排队internal/middleware/cocrrent.go:12
TraceMiddleware注入 trace / identity 到 ctxinternal/middleware/trace.go:8
RunSandboxController校验入参、按语言分发internal/controller/run.go:10
RunPython3Codeservice 入口:开关/超时/错误码internal/service/python.go:14
collectRunCodeResponse流式采集、组装响应internal/service/run_code.go:28
CaptureOutput起 goroutine 读管道、超时 Killinternal/core/runner/output_capture.go:105

服务启动时 controller.Setup(r)initServer 调用(internal/server/server.go:54),这条链一次性装好,之后每个请求复用。


3. 路由与中间件装配

这节讲什么: 路由是怎么被分成"公开"和"私有"两组的,以及那条中间件链是按什么顺序挂上去的。

3.1 两个路由组:公开 vs 私有

Setup 把路由切成两半(internal/controller/router.go:11-26):

  • PublicGroup(前缀 "")——只有 /health,任何人可访问,直接返回 "ok"。用于负载均衡/编排系统探活。
  • PrivateGroup(前缀 /v1/sandbox/)——整组挂了 middleware.Auth()(router.go:15),所有实际业务路由都在这组下,必须带对 API Key。

一句话:探活不要钱,干活要鉴权。 这个切分让健康检查不受鉴权和并发闸门影响——探活永远秒回,不会因为沙箱忙就被误判成"挂了"。

3.2 run 路由的中间件链

InitRunRouterrun 这一条路由单独串了四个中间件,顺序是有讲究的(internal/controller/router.go:37-48):

runRouter.POST(
"run",
middleware.MaxRequest(static.GetDifySandboxGlobalConfigurations().MaxRequests), // ① 先挡洪峰
middleware.MaxWorker(static.GetDifySandboxGlobalConfigurations().MaxWorkers), // ② 再排队限流
middleware.TraceMiddleware(), // ③ 打 trace
RunSandboxController, // ④ 真正处理
)

Auth 因为挂在整个 PrivateGroup 上,逻辑上排在这四个之前先跑。所以完整顺序是:Auth → MaxRequest → MaxWorker → Trace → Controller

顺序的意义:鉴权失败的请求根本不该占用并发额度,所以 Auth 最先;MaxRequest 是"廉价的硬拒绝",放在会阻塞的 MaxWorker 之前,先把明显过载的流量弹掉,免得它们堆在排队闸门上白等。


4. 鉴权:一把对称的钥匙

这节讲什么: 请求怎么证明自己有资格调用。

机制极简——单一共享密钥,字符串直接比对(internal/middleware/auth.go:8-16):

func Auth() gin.HandlerFunc {
config := static.GetDifySandboxGlobalConfigurations()
return func(c *gin.Context) {
if config.App.Key != c.GetHeader("X-Api-Key") {
c.AbortWithStatus(401)
return
}
}
}
  • 请求头 X-Api-Key 必须逐字节等于配置里的 App.Key
  • 不等就 AbortWithStatus(401)——立即终止,后面的中间件和 controller 全部不执行。
  • Key 来自配置/环境变量 API_KEY(internal/static/config.go:64 附近读取)。

注意两点(边界):

  1. 这不是每租户密钥,而是部署级的单一共享密钥;所有调用方共用同一把钥匙。Dify Sandbox 的定位是"部署在可信内网、只被 Dify 主服务调用"的内部组件,不是面向公网的多租户 API,所以对称密钥够用。
  2. config 是在 Auth() 被调用(装配时)取一次并闭包捕获,后续每个请求复用同一份——运行中改 Key 不会热生效。

5. 两级并发控制(本章重点)

这节讲什么: 为什么要用两层闸门,它们分别挡什么、行为有什么本质区别。

沙箱执行代码很吃资源(每次都要拉起子进程、装 seccomp/chroot)。如果不限并发,一波流量就能把机器打满。Dify Sandbox 用了两个语义完全不同的闸门,一前一后。

5.1 一句话看懂区别

MaxRequest(闸门①)MaxWorker(闸门②)
数据结构计数器 + 读写锁带缓冲 channel(信号量)
满了怎么办立即拒绝,返回 503阻塞排队,等有空位再放行
对调用方快速失败,自己重试请求 hang 住,慢但最终会跑
保护的是"同时受理的请求总数""同时真正在执行的 worker 数"

一句话:MaxRequest 是"门口保安"(人太多直接不让进),MaxWorker 是"叫号排队"(进了门也得等叫到号)。

5.2 MaxWorker:带缓冲 channel 当信号量

MaxWorker 用 Go 里最地道的"带缓冲 channel 当信号量"手法(internal/middleware/cocrrent.go:12-23):

func MaxWorker(max int) gin.HandlerFunc {
sem := make(chan struct{}, max) // 容量 = max 的令牌桶
return func(c *gin.Context) {
sem <- struct{}{} // 拿令牌:满了就阻塞在这里排队
defer func() { <-sem }() // 处理完归还令牌
c.Next() // 持令牌期间执行真正逻辑
}
}
  • sem 是容量为 max 的缓冲 channel。往里写 struct{}{} 就是"占一个执行位"。
  • 桶满时,sem <- struct{}{} 这行会阻塞——请求被挂起、有序排队,直到某个前面的请求 <-sem 归还令牌。
  • defer 保证无论正常还是 panic,令牌都会还回去。

所以 MaxWorker 不丢请求,只是让超出并发的请求排队等待——用延迟换成功率。

5.3 MaxRequest:计数器 + 超限直接 503

MaxRequest 走的是完全相反的路子——不排队,超了就拒(internal/middleware/cocrrent.go:49-66):

func MaxRequest(max int) gin.HandlerFunc {
m := &MaxRequestIface{current: 0, lock: &sync.RWMutex{}}
return func(c *gin.Context) {
if !m.tryAcquire(max) { // 计数达上限 → false
c.JSON(http.StatusServiceUnavailable,
types.ErrorResponse(-503, "Too many requests"))
c.Abort() // 就地返回 503,不排队
return
}
defer m.release()
c.Next()
}
}

tryAcquire 加锁把 currentmax 比一下,没超就 current++ 返回 true,超了返回 false(cocrrent.go:30-40);release 加锁 current--(cocrrent.go:42-47)。

5.4 为什么要两层?

关键在于两个闸门保护的不是同一件事:

  • MaxWorker 保护"真正在跑代码的并发度"。它是慢闸门:排队的请求虽然还没执行,但仍占着连接、占着内存里的 goroutine。
  • MaxRequest 保护"系统里在途请求的总量"(包括正排在 MaxWorker 队列里等的那些)。它是过载熔断阀:如果只有 MaxWorker,一旦执行慢下来,请求会无限堆积在排队闸门上,连接数暴涨直到 OOM。MaxRequest 给"堆积"设了硬上限,超过就快速失败,让调用方早点收到 503 去重试,而不是干等到超时。

一个类比:MaxWorker 是餐厅里"同时能上灶的锅数",MaxRequest 是"餐厅里最多容纳多少客人(含排队的)"。锅有限所以要排队(MaxWorker);但店门口也不能无限塞人,塞满了就挂"客满"牌子把新客人劝走(MaxRequest)。两层各管一段,缺一个都会在过载时出事。

注意 -503 经过 ErrorResponse 时会保留(因为它 < 0);而 HTTP 状态码本身是 http.StatusServiceUnavailable(503)。这是 run 路由上唯一在 controller 之前就可能返回非 200 HTTP 状态码的业务分支。


6. Trace 中间件:给每条请求打上可追踪的标记

这节讲什么: 请求进入业务逻辑前,怎么被打上 trace 标记,方便日志串联。

TraceMiddleware 做两件事(internal/middleware/trace.go:8-33):

  1. 解析或生成 trace。 读请求头 traceparent(W3C Trace Context 标准),用 log.ParseTraceparent 解析出 traceID / spanID;解析失败(没传或格式不对)就自己生成一对新的(trace.go:12-17)。这保证每条请求无论上游有没有带 trace,都有一个可追踪的 ID。
  2. 注入身份。 从请求头取 X-User-IDX-User-Type 和路径参数 tenant_id,连同 trace 一起 WithTrace / WithIdentity 塞进 context.Context,再 c.Request = c.Request.WithContext(ctx) 挂回请求(trace.go:18-30)。

之后 controller 和 service 拿到的 ctx(RunSandboxController 里的 c 本身就是 context.Context)就带着这些信息;沙箱执行时的 slog.ErrorContext(ctx, ...)(见 §8)能自动把 traceID 打进日志,方便把一次执行的所有日志串起来。

Trace 排在并发闸门之后、controller 之前——被拒绝的请求不必浪费精力生成 trace,真正要执行的才打标。


7. Controller 层:校验入参 + 按语言分发

这节讲什么: 请求体怎么被解析校验,以及怎么根据 language 分流到不同语言的执行器。

7.1 binding 校验 required 字段

RunSandboxController 通过泛型辅助函数 BindRequest 解析请求体(internal/controller/run.go:10-16):

func RunSandboxController(c *gin.Context) {
BindRequest(c, func(req struct {
Language string `json:"language" form:"language" binding:"required"`
Code string `json:"code" form:"code" binding:"required"`
Preload string `json:"preload" form:"preload"`
EnableNetwork bool `json:"enable_network" form:"enable_network"`
}) {
switch req.Language { /* ... */ }
})
}
  • binding:"required" 让 gin 强制 languagecode 必填;缺任一个,绑定就报错。
  • BindRequestContent-Type 选择 BindJSON 还是 ShouldBind(表单),出错时返回 -400 错误信封(internal/controller/base.go:8-25)。

一个易被忽略的细节:BindRequest 校验失败时,HTTP 状态码用的是 200,靠 body 里的 code: -400 表达失败(base.go:21)。也就是说这个 API 大部分错误走的是"HTTP 200 + 负 code"的信封约定,而不是 HTTP 错误码——调用方要看 data.code,不能只看 HTTP 状态。(例外见 §5 的 MaxRequest 503 和 §4 的 Auth 401。)

7.2 按 language 分发

校验通过后,一个 switch 按语言分流(run.go:17-28):

language走向
"python3"service.RunPython3Code(...)
"nodejs"service.RunNodeJsCode(...)
其它c.JSON(400, ErrorResponse(-400, "unsupported language"))

EnableNetwork 被打包进 runner_types.RunnerOptions 一路传下去。注意不支持的语言这里返回的是真正的 HTTP 400(不是 200 信封),和 §7.1 的绑定错误处理不完全一致——同一层里两种错误风格并存,是可留意的小不一致。


8. Service 层入口:开关、超时与错误码映射

这节讲什么: 进入沙箱之前,service 层做的最后几件"守门"工作。以 RunPython3Code 为例(internal/service/python.go:14-39),nodejs 路径结构相同。

8.1 三道前置检查

func RunPython3Code(ctx context.Context, code string, preload string, options *runner_types.RunnerOptions) *types.DifySandboxResponse {
if err := checkOptions(options); err != nil { // ① 网络开关闸门
return types.ErrorResponse(-400, err.Error())
}
if !static.GetDifySandboxGlobalConfigurations().EnablePreload { // ② preload gate
preload = ""
}
timeout := time.Duration( // ③ 算执行超时
static.GetDifySandboxGlobalConfigurations().WorkerTimeout * int(time.Second),
)
runner := python.PythonRunner{}
result, err := runner.Run(ctx, code, timeout, nil, preload, options)
// ...
}
  • ① 网络开关。 checkOptions 检查:如果请求想 EnableNetwork 但全局配置 EnableNetwork=false,直接拒 -400(internal/service/check.go:14-22)。"请求想开网 vs 部署允不允许开网"在这里对齐。
  • ② preload gate。 只有全局 EnablePreload=true 时才保留调用方传的 preload(预加载代码,比如提前 import 一批库);否则强制清空(python.go:19-21)。这是防止调用方通过 preload 塞入意外代码的一道开关。
  • ③ 超时。 从配置 WorkerTimeout(秒)算出一个 time.Duration,传给 runner——这就是后面 §9 里 AfterFunc 定时 Kill 用的那个超时值。

8.2 错误码映射(-400 / -429 / -500)

runner.Run 返回后,service 把 Go 的 error 翻译成对外的负数错误码(python.go:31-38):

条件返回 code含义
checkOptions 失败(如网络被禁)-400请求参数/开关不合法
errors.Is(err, ErrUIDPoolExhausted)-429UID 池耗尽,沙箱没空槽了(类比"限流")
其它任何 err-500内部执行错误
无 errcode: 0 + SuccessResponse成功,附上采集到的结果

ErrUIDPoolExhausted 来自沙箱给每次执行分配的独立 UID 池(internal/core/runner/python/uid_pool.go:9),池空意味着并发执行槽用尽——这是比 §5 的 HTTP 闸门更靠里的一层资源限制,本章不深入(见 沙箱核心)。所有这些负 code 最终都装进 §1 那个统一 JSON 信封(internal/types/response.go),HTTP 状态仍是 200。


9. 响应装配与流式采集

这节讲什么: 代码在沙箱里边跑边产出 stdout/stderr,服务是怎么一边跑一边收、超时怎么杀进程、最后怎么拼出退出码和错误的。这是本章技术含量最高的一段。

9.1 为什么要"流式"

沙箱执行是异步的:CaptureOutput 起好读管道的 goroutine 后立即返回,runner.Run 随即把结果句柄交出去(internal/core/runner/python/python.go:117,126)。而输出通过一组无缓冲 channel 传递(output_capture.go:28-35)。无缓冲意味着:只要没人在另一端收,写入就会阻塞。所以采集方必须和执行方并发地跑,边产边收,否则子进程一写 stdout 就卡死。

9.2 生产端:CaptureOutput 的双 goroutine + WaitGroup

CaptureOutput 是"生产端",把子进程的输出源源不断喂进 channel(internal/core/runner/output_capture.go:105-226)。核心结构:

┌─ goroutine A: 读 stdout 管道 → WriteOutput → stdout chan
cmd.Start() ┤
└─ goroutine B: 读 stderr 管道 → WriteStderr → stderr chan

goroutine C(收尾): wg.Wait() 等 A、B 都 EOF
→ cmd.Process.Wait() 拿退出状态
→ SetExitCode / 翻译错误
→ done <- true
  • 两个读 goroutine 各自 1024 字节一块地读管道,读到 EOF 就 wg.Done();读到别的错误就写一条 execError 再退出(output_capture.go:149-186)。
  • WaitGroup 计数 2,收尾 goroutine 用 wg.Wait() 确保先把输出读干净,再去 cmd.Process.Wait() 取退出码——避免管道里还有数据没读完就判定结束(output_capture.go:145-146,189-196)。
  • 全部搞定后 done <- true 通知消费端"跑完了"(output_capture.go:222)。

9.3 超时用 AfterFunc 定时 Kill

超时不是靠 context,而是一个定时器(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() // 到点直接杀进程
}
})

到 §8 算出的 timeout 时刻,如果进程还活着,就设退出码 -1、写一条 timeout execError、直接 Kill。进程被杀后,读管道的 goroutine 会 EOF,收尾 goroutine 走完,done 照常发出。正常结束路径上 timer.Stop() 会取消这个定时器(output_capture.go:190)。

9.4 坏系统调用 → 翻译成"operation not permitted"

这是和沙箱强相关的一处巧妙翻译(output_capture.go:207-215):

} else if status != nil {
s.result.SetExitCode(status.ExitCode())
if status.ExitCode() != 0 {
exitString := status.String()
if strings.Contains(exitString, "bad system call") {
s.WriteExecError([]byte("error: operation not permitted\n"))
}
}
}

当代码触碰了 seccomp 白名单之外的系统调用,内核用 SIGSYS 杀掉进程,status.String() 里会带 "bad system call"。这里把这个内核术语翻译成人话 operation not permitted——调用方一看就懂"是被沙箱拦了",而不用去猜 bad system call 是啥。seccomp 白名单机制本身见 两道锁

9.5 消费端:collectRunCodeResponse 的双层 select

"消费端" collectRunCodeResponse 从那几个 channel 里把数据收拢成最终响应(internal/service/run_code.go:28-63)。它是一个双层 select 循环:

for {
select {
case <-result.GetDone(): // 收到"跑完"信号
for { // 内层:把残留数据抽干
select {
case out := <-result.GetStdout(): stdoutStr.Write(out)
case err := <-result.GetStderr(): stderrStr.Write(err)
case execErr := <-result.GetExecError(): execErrorStr.Write(execErr)
default: // channel 空了 → 组装并返回
exitCode := result.GetExitCode()
stderr := stderrStr.String()
return &RunCodeResponse{
Stdout: stdoutStr.String(), Stderr: stderr,
Error: buildExecutionError(exitCode, stderr, execErrorStr.String()),
ExitCode: exitCode,
}
}
}
case out := <-result.GetStdout(): stdoutStr.Write(out) // 平时:边跑边收
case err := <-result.GetStderr(): stderrStr.Write(err)
case execErr := <-result.GetExecError(): execErrorStr.Write(execErr)
}
}

两层各有分工:

  • 外层 select:进程还在跑时,持续从 stdout/stderr/execError 三个 channel 里读、往 builder 里拼;同时监听 done
  • 内层 select + default:收到 done 后,进程虽然结束了,但 channel 里可能还有没读完的残留数据(尤其无缓冲 channel,收尾那一刻可能还卡着几个写)。内层循环把三个 channel 继续抽干,直到某次 select 走进 default(说明三个 channel 当下都空了),才最终组装 RunCodeResponse 返回。

default 分支是关键:它是"channel 空了"的判据,保证不丢任何一段输出才返回。

9.6 buildExecutionError:拼装退出码 + stderr

最后 error 字段由 buildExecutionError 拼(run_code.go:65-88):

  • 退出码为 0(成功):error 只放 execError(通常为空);哪怕 stderr 里有日志输出,也不当成错误——注释明确说明"成功执行即使 stderr 有内容,Error 也保持空"(run_code.go:16-26)。
  • 退出码非 0:先写 process exited with code N,再依次拼上 execError 和 stderr 的细节(appendExecutionErrorDetail 负责按需加换行,run_code.go:78-88)。

于是调用方拿到的 error 字段是一句"人能读懂的失败摘要":退出码 + 具体报错。而 stdout/stderr 始终是进程的原始输出,和 error 分开——数据(原始流)与判定(是否失败)解耦。


10. 边界与局限(HTTP 层视角)

只讲本章这条主线上的边界,沙箱内部边界见 巧妙之处与边界

  • 单一共享密钥,非多租户。 Auth 是全局对称密钥,不区分调用方(§4)。定位是可信内网组件。
  • 错误码风格不完全统一。 大部分错误走"HTTP 200 + 负 code"信封(§7.1),但 Auth 走 401、MaxRequest 走 503、unsupported language 走真正的 400——调用方需同时看 HTTP 状态和 data.code
  • 配置装配时快照。 Auth 的 Key、两个闸门的上限都是在装配阶段读一次配置(router.go:42-43auth.go:9),运行中改配置不热生效。
  • 超时靠定时器 Kill,非协作式取消。 AfterFunc 到点强杀进程(§9.3),不给代码优雅退出的机会——对沙箱场景是合理的(不信任里面的代码),但意味着超时那一刻的部分输出可能已经采集、也可能没来得及。

11. 代码地图(导航索引)

主题文件路径符号名
服务启动、装配路由internal/server/server.goinitServer
路由分组(公开/私有)internal/controller/router.goSetup
run 路由中间件链internal/controller/router.goInitRunRouter
API Key 鉴权internal/middleware/auth.goAuth
并发闸门②(信号量排队)internal/middleware/cocrrent.goMaxWorker
并发闸门①(计数超限 503)internal/middleware/cocrrent.goMaxRequest / tryAcquire
trace / identity 注入internal/middleware/trace.goTraceMiddleware
入参校验(泛型绑定)internal/controller/base.goBindRequest
按语言分发internal/controller/run.goRunSandboxController
service 入口(超时/preload/错误码)internal/service/python.goRunPython3Code
网络开关校验internal/service/check.gocheckOptions
流式采集、组装响应internal/service/run_code.gocollectRunCodeResponse
拼装错误摘要internal/service/run_code.gobuildExecutionError
生产端:读管道/超时 Kill/坏调用翻译internal/core/runner/output_capture.goCaptureOutput
统一响应信封internal/types/response.goSuccessResponse / ErrorResponse

下一步: 请求已经流到"该真正执行代码"这一刻。代码到底怎么被关进笼子、UID 池/seccomp/chroot 怎么运作,见 沙箱核心两道锁