1ms变200ms,拆解Dify代码节点的“逆天”操作

0 阅读10分钟

前言

最近因为一些技术调研,我集中用了几天 Dify 工作流。

整体体验不错,尤其是日志和流程图这两块:节点之间的数据怎么流、在哪一步出了问题,基本一眼就能看出来。这种可视化做得很友好。

但在实际接入时,我碰到了一个很疑惑的问题。

一段本地执行时间只有 1-2ms的代码逻辑。结果放进 Dify 代码节点之后,单次耗时直接到了200ms+。

本地跑测试用例耗时

节点图

dify节点耗时

这乍一看我还以为写出O(n³)的代码了。

如果只看业务逻辑本身,几毫秒的代码无论如何也跑不到 200ms 量级。

遇到这种量级明显不对的耗时,最直接的办法还是翻源码。所以我顺手 clone 了一把 dify

我带了这两个问题写这篇文章:

  1. Dify 的代码节点到底是怎么执行的?
  2. 这 200ms+ 的耗时,到底花在了哪里?

如果先把这次源码阅读后的结论说在前面,大概是:

至少从当前这套实现看,Dify 代码节点慢,通常不是慢在你的业务代码,而是慢在“为安全执行这段代码所付出的整套沙箱成本”。

先看调用链:代码节点并不是在主服务里直接执行

一开始的直觉是:会不会是多了一次服务间调用原因?

我先去翻了 Dify 源码,结果很快就能看到,代码执行并不是在主服务内部直接完成的,而是通过 HTTP 请求打到一个专门的代码执行服务。

相关代码

继续往下看,会发现这个能力拆到了另一个仓库里,也就是 dify-sandbox。这个仓库的主要语言是 Go。

dify-sandbox仓库

这个设计本身我觉得挺合理。

虽然我没设计过沙箱,但只要产品允许用户执行自定义代码,优先考虑隔离性和安全性,本来就是很自然的工程选择。

但新的问题也来了:

代码节点的额外耗时,主要是因为跨服务通信吗?

那肯定不是。

因为在同机或内网环境里,一次普通 HTTP 往返通常很难解释掉 200ms 这个量级。它可能贡献几毫秒,甚至十几毫秒,但如果总耗时已经上到 200ms+,那大头多半在别的地方。

绕过 Dify,直接请求 sandbox

为了把问题拆开,我没有继续只盯着工作流节点,而是决定直接测 dify-sandbox 自己的耗时。

不过这里有个现实问题:dify-sandbox 的实现完全依赖 Linux 特性,我本地模拟不太方便。

dify-sandbox运行要求

不过没事,我们直接采用了官方 Docker 镜像来模拟环境:

docker run --rm \
  -p 8194:8194 \
  --cpus="2" \
  --memory="4g" \
  --memory-swap="4g" \
  langgenius/dify-sandbox

dify-sandbox docker镜像

然后我用相同代码直接请求 sandbox 服务,循环跑了 1000 次。结果很直接:单次平均往返延时已经到了 100ms 以上。

本地请求sandbox服务1000次平均耗时

这里先强调一下边界:

  1. 这个结果是我在本地 Docker 环境下测出来的,不代表所有部署环境的绝对值都一样。
  2. 但它至少说明了一件事:即使绕开 Dify 工作流编排层,sandbox 本身也已经有明显固定成本。

换句话说,代码节点的慢,确实有很大一部分来自沙箱执行链路本身,而不是业务逻辑。

从源码看,sandbox 到底做了什么

下面开始看 dify-sandbox 的 NodeJS runner。

nodejs runner 代码

如果把这段执行流程压缩一下,大概可以整理成下面几步:

  1. sandbox 鉴权、路由、JSON 解析
  2. 创建临时目录并复制运行所需文件
  3. 生成 bootstrap 脚本
  4. 拉起 Node 子进程
  5. Node 启动并加载基础模块
  6. 通过 koffi 加载 nodejs.so
  7. 开启 seccomp / 切换 uid gid / 进入隔离环境
  8. 从 fd3 读取用户代码
  9. eval(code)
  10. 捕获 stdout / stderr,等待进程退出并回传结果

这里最重要的观察是:

用户代码执行并不是“主角”,而是整条执行链的最后一步。

这也是我翻到这里时,第一个比较明确的感受:前面那些准备动作,才是固定开销的主要来源。

前三步基本都不重

1. 读取全局配置

一开始先读 sandbox 的全局配置,比如:

  1. nodejs 可执行文件路径
  2. 允许哪些系统调用
  3. 是否允许联网
  4. 超时时间

这部分更像普通配置读取,性能影响通常可以忽略。

读全局配置代码

类型定义也比较直观。

config类型定义

2. 申请一个专属沙箱 UID

接着会从一个 UID 池里取出低权限用户 ID,后续执行代码时,会切到这个受限身份下面运行。

linux uid生成

这一步本身也不重。除非是在并发特别高的时候,可能会受到资源竞争影响?

3. 准备输出采集器

然后会初始化输出捕获逻辑,用来接收 stdoutstderr,同时挂上超时控制。

捕获output

这部分也更像“装配流程”,通常不是主要瓶颈。

所以如果只看前面三步,很难解释为什么一次执行会到 100ms 甚至 200ms。

真正开始变重,是从下一步开始。

第一个明显的固定成本:创建临时目录,复制运行时文件

这一段是我自己最先盯上的地方。

创建临时目录

WithTempDir 主要做两件事:

  1. 创建一个形如 /tmp/sandbox-<uuid> 的临时目录
  2. REQUIRED_FS 里的文件和目录用 cp -r 复制进去

REQUIRED_FS 包括这些内容:

  1. node_temp
  2. nodejs.so
  3. ca-certificates.crt
  4. nsswitch.conf
  5. resolv.conf
  6. stub-resolv.conf
  7. hosts

这里最重的显然不是那几个系统配置文件,而是 node_temp

这个目录里带着一整套 Node 运行时依赖。我本地看了下仓库里的内容,目录体积大概在 35MB 左右。

koffi仓库

这意味着什么?

意味着每次执行代码之前,sandbox 都不是“复用一个已经准备好的 Node 环境”,而是先准备一份新的、可隔离、可销毁的运行环境副本。

对长任务来说,这点固定成本可能不明显;但对一个本身只跑 1-2ms 的小脚本来说,这种文件复制成本就会非常刺眼。

按照经验判断,它显然已经具备成为主要耗时来源的条件:

  1. 有磁盘 I/O
  2. 有目录复制
  3. 复制体积不小
  4. 每次执行都要重复发生

第二个明显的固定成本:每次都要拉起一个新的 Node 进程

复制完运行环境之后,sandbox 还会写一个启动脚本,然后用 exec.Command 拉起一个新的 Node 子进程。

nodejs进程

这一步我也觉得很关键,因为它说明 Dify 这里走的不是“长驻 Worker 复用”的思路,而是每次执行都新起一次进程

这里执行的还不是用户代码本体,而是一层 bootstrap 脚本。

执行测试脚本

这一层会先做几件事:

  1. 加载基础模块
  2. 加载 koffi
  3. 再通过 koffi 加载 nodejs.so
  4. 然后才进入真正的沙箱逻辑

特征也很明显:

  1. 需要启动 Node 运行时
  2. 需要模块解析与加载
  3. 需要加载动态库
  4. 每次执行都要重新来一遍

从工程常识看,“拉起新进程 + 运行时初始化”本身就是典型固定成本。至于多少毫秒取决于设备。

真正执行用户代码之前,还要再过一道安全门

再往后,才终于轮到执行用户代码。

执行代码

但这里也不是一上来就 eval(code)

prescript.js 里,实际顺序大概是:

  1. 通过 koffi 加载本地动态库 nodejs.so
  2. 调用其中的 DifySeccomp(...)
  3. 完成 uid / gid、seccomp、网络权限等隔离设置
  4. 从 fd3 读取用户代码
  5. 最后执行 eval(code)

也就是说,真正的业务代码其实是最后才进场。

这也是为什么很多人在看代码节点耗时时会产生错觉:
明明自己的逻辑只有几行,为什么却慢得像跑了一个小服务?

因为从 sandbox 的视角看,它确实不是“帮你跑几行 JS”,而是在完整地执行一次受限代码任务

所以这 200ms+ 到底花在哪了?

如果把前面的实测和源码放在一起看,我认为可以得到一个比较稳妥的判断:

Dify 代码节点的主要耗时,并不在业务代码本身,而在每次执行前后的固定启动成本。

这些固定成本主要包括:

  1. HTTP 请求进入 sandbox 服务
  2. 创建临时目录
  3. 复制运行时文件
  4. 生成 bootstrap 脚本
  5. 启动 Node 子进程
  6. 加载模块和动态库
  7. 设置 seccomp / uid / gid / 网络隔离
  8. 采集输出并等待进程结束

其中最值得怀疑的大头,至少从实现方式上看,是两类:

  1. 文件系统准备成本:临时目录、cp -r、运行时副本
  2. 进程冷启动成本:Node 进程启动、模块加载、动态库加载

反过来说,这也解释了一个现象:

为什么短代码最容易觉得慢。

因为你的业务逻辑如果本来就只值 1-2ms,那么上面这些固定成本会被无限放大;但如果你执行的是一个本来就要跑几百毫秒甚至几秒的任务,这些固定成本在总时长里的占比反而会下降。

这或许是标准沙箱写法?优先安全大于性能

看到上面,很容易顺手吐槽一句:这也太重了。

但我更愿意把它理解成一个典型的工程权衡,而不是“为了写得更重”。

如果你要支持“用户上传任意代码并在线执行”,那你优先追求的一般不会是极限低延迟,而是:

  1. 隔离性
  2. 可控性
  3. 可回收性
  4. 出问题时不影响主服务

从这个目标看,Dify 把代码执行拆到单独 sandbox,再给每次执行都准备独立环境,其实是很符合安全思路的。

只是它带来的代价也很清楚:

安全边界越完整,固定成本往往越高。

最后

把结论简单过一下。

如果你也遇到过类似的困惑:

为什么我本地只跑 1-2ms 的代码,到了线上平台跑代码时却变成了 200ms+?

那以这次翻源码后的理解,一个比较接近事实的答案是:

慢的通常不是你的代码,而是沙箱。

更准确一点说,是为了安全地运行这段代码,系统必须额外完成一整套隔离和启动流程。而对毫秒级小任务来说,这些固定成本远大于业务代码本身,于是你看到的最终效果就会像“慢了几百倍”。

所以如果你的代码节点只是做一些非常轻的小判断、小转换、小规则处理,看到这个量级的耗时。

很遗憾,这或许是正常的。