前言
最近因为一些技术调研,我集中用了几天 Dify 工作流。
整体体验不错,尤其是日志和流程图这两块:节点之间的数据怎么流、在哪一步出了问题,基本一眼就能看出来。这种可视化做得很友好。
但在实际接入时,我碰到了一个很疑惑的问题。
一段本地执行时间只有 1-2ms的代码逻辑。结果放进 Dify 代码节点之后,单次耗时直接到了200ms+。
这乍一看我还以为写出O(n³)的代码了。
如果只看业务逻辑本身,几毫秒的代码无论如何也跑不到 200ms 量级。
遇到这种量级明显不对的耗时,最直接的办法还是翻源码。所以我顺手 clone 了一把 dify 。
我带了这两个问题写这篇文章:
- Dify 的代码节点到底是怎么执行的?
- 这 200ms+ 的耗时,到底花在了哪里?
如果先把这次源码阅读后的结论说在前面,大概是:
至少从当前这套实现看,Dify 代码节点慢,通常不是慢在你的业务代码,而是慢在“为安全执行这段代码所付出的整套沙箱成本”。
先看调用链:代码节点并不是在主服务里直接执行
一开始的直觉是:会不会是多了一次服务间调用原因?
我先去翻了 Dify 源码,结果很快就能看到,代码执行并不是在主服务内部直接完成的,而是通过 HTTP 请求打到一个专门的代码执行服务。
继续往下看,会发现这个能力拆到了另一个仓库里,也就是 dify-sandbox。这个仓库的主要语言是 Go。
这个设计本身我觉得挺合理。
虽然我没设计过沙箱,但只要产品允许用户执行自定义代码,优先考虑隔离性和安全性,本来就是很自然的工程选择。
但新的问题也来了:
代码节点的额外耗时,主要是因为跨服务通信吗?
那肯定不是。
因为在同机或内网环境里,一次普通 HTTP 往返通常很难解释掉 200ms 这个量级。它可能贡献几毫秒,甚至十几毫秒,但如果总耗时已经上到 200ms+,那大头多半在别的地方。
绕过 Dify,直接请求 sandbox
为了把问题拆开,我没有继续只盯着工作流节点,而是决定直接测 dify-sandbox 自己的耗时。
不过这里有个现实问题:dify-sandbox 的实现完全依赖 Linux 特性,我本地模拟不太方便。
不过没事,我们直接采用了官方 Docker 镜像来模拟环境:
docker run --rm \
-p 8194:8194 \
--cpus="2" \
--memory="4g" \
--memory-swap="4g" \
langgenius/dify-sandbox
然后我用相同代码直接请求 sandbox 服务,循环跑了 1000 次。结果很直接:单次平均往返延时已经到了 100ms 以上。
这里先强调一下边界:
- 这个结果是我在本地 Docker 环境下测出来的,不代表所有部署环境的绝对值都一样。
- 但它至少说明了一件事:即使绕开 Dify 工作流编排层,sandbox 本身也已经有明显固定成本。
换句话说,代码节点的慢,确实有很大一部分来自沙箱执行链路本身,而不是业务逻辑。
从源码看,sandbox 到底做了什么
下面开始看 dify-sandbox 的 NodeJS runner。
如果把这段执行流程压缩一下,大概可以整理成下面几步:
- sandbox 鉴权、路由、JSON 解析
- 创建临时目录并复制运行所需文件
- 生成 bootstrap 脚本
- 拉起 Node 子进程
- Node 启动并加载基础模块
- 通过
koffi加载nodejs.so - 开启 seccomp / 切换 uid gid / 进入隔离环境
- 从 fd3 读取用户代码
eval(code)- 捕获 stdout / stderr,等待进程退出并回传结果
这里最重要的观察是:
用户代码执行并不是“主角”,而是整条执行链的最后一步。
这也是我翻到这里时,第一个比较明确的感受:前面那些准备动作,才是固定开销的主要来源。
前三步基本都不重
1. 读取全局配置
一开始先读 sandbox 的全局配置,比如:
nodejs可执行文件路径- 允许哪些系统调用
- 是否允许联网
- 超时时间
这部分更像普通配置读取,性能影响通常可以忽略。
类型定义也比较直观。
2. 申请一个专属沙箱 UID
接着会从一个 UID 池里取出低权限用户 ID,后续执行代码时,会切到这个受限身份下面运行。
这一步本身也不重。除非是在并发特别高的时候,可能会受到资源竞争影响?
3. 准备输出采集器
然后会初始化输出捕获逻辑,用来接收 stdout、stderr,同时挂上超时控制。
这部分也更像“装配流程”,通常不是主要瓶颈。
所以如果只看前面三步,很难解释为什么一次执行会到 100ms 甚至 200ms。
真正开始变重,是从下一步开始。
第一个明显的固定成本:创建临时目录,复制运行时文件
这一段是我自己最先盯上的地方。
WithTempDir 主要做两件事:
- 创建一个形如
/tmp/sandbox-<uuid>的临时目录 - 把
REQUIRED_FS里的文件和目录用cp -r复制进去
REQUIRED_FS 包括这些内容:
node_tempnodejs.soca-certificates.crtnsswitch.confresolv.confstub-resolv.confhosts
这里最重的显然不是那几个系统配置文件,而是 node_temp。
这个目录里带着一整套 Node 运行时依赖。我本地看了下仓库里的内容,目录体积大概在 35MB 左右。
这意味着什么?
意味着每次执行代码之前,sandbox 都不是“复用一个已经准备好的 Node 环境”,而是先准备一份新的、可隔离、可销毁的运行环境副本。
对长任务来说,这点固定成本可能不明显;但对一个本身只跑 1-2ms 的小脚本来说,这种文件复制成本就会非常刺眼。
按照经验判断,它显然已经具备成为主要耗时来源的条件:
- 有磁盘 I/O
- 有目录复制
- 复制体积不小
- 每次执行都要重复发生
第二个明显的固定成本:每次都要拉起一个新的 Node 进程
复制完运行环境之后,sandbox 还会写一个启动脚本,然后用 exec.Command 拉起一个新的 Node 子进程。
这一步我也觉得很关键,因为它说明 Dify 这里走的不是“长驻 Worker 复用”的思路,而是每次执行都新起一次进程。
这里执行的还不是用户代码本体,而是一层 bootstrap 脚本。
这一层会先做几件事:
- 加载基础模块
- 加载
koffi - 再通过
koffi加载nodejs.so - 然后才进入真正的沙箱逻辑
特征也很明显:
- 需要启动 Node 运行时
- 需要模块解析与加载
- 需要加载动态库
- 每次执行都要重新来一遍
从工程常识看,“拉起新进程 + 运行时初始化”本身就是典型固定成本。至于多少毫秒取决于设备。
真正执行用户代码之前,还要再过一道安全门
再往后,才终于轮到执行用户代码。
但这里也不是一上来就 eval(code)。
在 prescript.js 里,实际顺序大概是:
- 通过
koffi加载本地动态库nodejs.so - 调用其中的
DifySeccomp(...) - 完成 uid / gid、seccomp、网络权限等隔离设置
- 从 fd3 读取用户代码
- 最后执行
eval(code)
也就是说,真正的业务代码其实是最后才进场。
这也是为什么很多人在看代码节点耗时时会产生错觉:
明明自己的逻辑只有几行,为什么却慢得像跑了一个小服务?
因为从 sandbox 的视角看,它确实不是“帮你跑几行 JS”,而是在完整地执行一次受限代码任务。
所以这 200ms+ 到底花在哪了?
如果把前面的实测和源码放在一起看,我认为可以得到一个比较稳妥的判断:
Dify 代码节点的主要耗时,并不在业务代码本身,而在每次执行前后的固定启动成本。
这些固定成本主要包括:
- HTTP 请求进入 sandbox 服务
- 创建临时目录
- 复制运行时文件
- 生成 bootstrap 脚本
- 启动 Node 子进程
- 加载模块和动态库
- 设置 seccomp / uid / gid / 网络隔离
- 采集输出并等待进程结束
其中最值得怀疑的大头,至少从实现方式上看,是两类:
- 文件系统准备成本:临时目录、
cp -r、运行时副本 - 进程冷启动成本:Node 进程启动、模块加载、动态库加载
反过来说,这也解释了一个现象:
为什么短代码最容易觉得慢。
因为你的业务逻辑如果本来就只值 1-2ms,那么上面这些固定成本会被无限放大;但如果你执行的是一个本来就要跑几百毫秒甚至几秒的任务,这些固定成本在总时长里的占比反而会下降。
这或许是标准沙箱写法?优先安全大于性能
看到上面,很容易顺手吐槽一句:这也太重了。
但我更愿意把它理解成一个典型的工程权衡,而不是“为了写得更重”。
如果你要支持“用户上传任意代码并在线执行”,那你优先追求的一般不会是极限低延迟,而是:
- 隔离性
- 可控性
- 可回收性
- 出问题时不影响主服务
从这个目标看,Dify 把代码执行拆到单独 sandbox,再给每次执行都准备独立环境,其实是很符合安全思路的。
只是它带来的代价也很清楚:
安全边界越完整,固定成本往往越高。
最后
把结论简单过一下。
如果你也遇到过类似的困惑:
为什么我本地只跑 1-2ms 的代码,到了线上平台跑代码时却变成了 200ms+?
那以这次翻源码后的理解,一个比较接近事实的答案是:
慢的通常不是你的代码,而是沙箱。
更准确一点说,是为了安全地运行这段代码,系统必须额外完成一整套隔离和启动流程。而对毫秒级小任务来说,这些固定成本远大于业务代码本身,于是你看到的最终效果就会像“慢了几百倍”。
所以如果你的代码节点只是做一些非常轻的小判断、小转换、小规则处理,看到这个量级的耗时。
很遗憾,这或许是正常的。