Activepieces 沙箱池演进决策剖析:纯 execute() 形态的由来、废弃与遗产
【免费下载链接】activepiecesAI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces
Activepieces 的沙箱执行架构经历了一次从"远程执行池"到"Worker 即沙箱"的显著转型。本文以决策记录 000013-sandbox-pool-is-a-pure-execute-cloud-run-pool-superseded.md 为核心,完整还原这场决策的前因后果:为什么沙箱池被设计为"纯粹的 execute()",为什么基于 Cloud Run 的远程池方案最终被放弃,以及这项决策留下的两个重要遗产(pieces 链接分发机制与 Worker→App 回路由规则)如何在今天的源码中继续生效。读完本文,你将理解 Activepieces 执行引擎的输入物化边界、AP_FRONTEND_URL与PUBLIC_URL的分工逻辑,以及如何从一份"已废弃"的 ADR 中提取仍然有效的架构原则。
一、决策原文:沙箱池是一个纯 execute(),Cloud Run 池方案已被取代
这份 ADR 的status字段明确标记为superseded(被取代),但它的价值恰恰在于记录了一次"失败探索"的完整逻辑链。决策核心可以压缩为一句话:
沙箱池是一个纯粹的
execute()——它不持有apiClient,不持有应用连接(app connection),所有输入都以请求/响应数据的形式传入(flow bundle + pieces 以 S3 引用形式传递)。Worker 是唯一的 Resolver,在调用execute()之前完成输入物化。GCP Cloud Run 曾经就是这样的池(concurrency 1,背后一个/execute端点),但那个远程池如今已不存在。
这一段包含四个关键信息点:
- 纯 execute 契约:池对执行环境的唯一职责是"拿到完整输入 → 执行 → 返回结果",不需要主动访问任何外部服务;
- 无连接持有:池不持有
apiClient,不做鉴权、不拉取数据,所有需要的数据由调用方(Worker)提前备好; - 输入物化前置:把"flow bundle + pieces 引用"解析成可执行形态的工作,由 Worker 侧的 Resolver 完成;
- 历史坐标:Cloud Run 曾是这种池的落地载体,现已移除。
二、背景:为什么曾想跑到 Cloud Run 上
决策的 Context 部分交代了探索动机与物理约束:
曾探索过把池远程运行在 Cloud Run 上,但 Cloud Run 无法保持按请求(per-request)的亲和性——因此生命周期无法跨网络拆分为 provision/run/dispose 三个阶段,只能是一次自包含的调用。
这段技术推理非常关键。一个沙箱的理想生命周期是:预备(provision)→ 执行(run)→ 清理(dispose)。在本地进程内,这三个阶段可以分步进行,甚至可以把一个沙箱复用于多次执行。但 Cloud Run 这类无服务器容器平台对每个请求的处理是独立的:请求之间没有亲和性,同一容器可能被回收、扩容或调度到别的实例,你无法假设"上一个请求预备好的沙箱还在"。因此,跨网络的远程池方案被迫把整个生命周期压缩成一次自包含调用——这正是"纯 execute()"形态诞生的直接原因。
三、为什么选纯 execute():两个被否决的替代方案
3.1 纯 execute 的优势
纯
execute()让本地与远程拥有完全一致的签名,且不需要应用连接(所有输入都是传入的数据)。
"同一签名"意味着:无论沙箱跑在本地进程内,还是(如果未来需要)跑在远程主机上,调用方看到的接口都是一样的。这把"部署位置"从接口设计问题中剥离出来,是一种典型的依赖倒置——执行环境不关心数据从哪来,只关心拿到的是完整、健康的输入。
3.2 被否决的方案一:Cloud Run 反向连接 App
已否决:Cloud Run 打开自己的 socket 连回 App(等于在一个无状态运行时上钉住一条持久连接)。
如果池自己维护一条到 App 的长连接(如 Socket.IO),它就能像本地 Worker 一样拉取数据。但这违背了无服务器平台的假设:长连接需要跨请求保持状态,而 Cloud Run 实例的存活与请求无关,连接随时可能被回收,运维上也无法保证这条连接只被单个实例使用。
3.3 被否决的方案二:大体积产物内联进请求体
已否决:把沉重的产物(artifact)内联塞进请求体(选择了 S3 引用,由池直接拉取)。
一个 flow bundle 可能包含编译后的代码、多个 piece 包,体积可达数 MB 甚至更大。把它们以 base64 内联进 HTTP 请求体,既浪费带宽,也拖慢冷启动。最终选择的是"S3 引用":请求体里只携带指向 S3 对象的 URL,池在需要时自行拉取。这与 000006-pieces-are-distributed-as-links-resolved-lazily.md 中"一切皆链接"的思路一脉相承。
四、源码验证:Worker 是唯一的 Resolver,池只消费物化好的输入
4.1 Resolver 的职责边界
决策中"Worker 是唯一 Resolver"这一条,在源码中有非常清晰的落点。resolver.ts 的文件头注释直接点明了设计意图:
The Resolver is the worker-side, Runtime-Kind-independent half of the seam. It owns the only apiClient and turns a job into a fully-materialized ProvisionInput before
executeis ever called, so the pool only sees healthy, complete inputs.
翻译过来:Resolver 是 Worker 侧、与运行时种类无关的那半边接缝,它独占唯一的apiClient,在execute()被调用之前就把一个 job 变成完全物化的ProvisionInput,因此池只看到健康、完整的输入。
从createResolver的实现可以看到物化的具体动作:
- 通过
flowProvisioning.resolve()获取FlowVersion,并解析该 flow 用到的所有 piece 与代码步骤; - 若命中已发布的 flow bundle,则直接使用 bundle 内的
flowVersion与pieces(代码已物化); - 冷路径下先在本机编译代码步骤,全部成功后才发布 bundle(
publishBundle),编译失败不会让运行失败——池会从源码重新编译; - 最终组装出
ProvisionInput:platformId、flowVersionId、pieces、codes、publicApiUrl、engineToken。
也就是说,池(沙箱)拿到的ProvisionInput已经是"纯数据":不再需要 Resolver 之外的任何网络调用。
4.2 execute() 的槽位生命周期
sandbox.ts 中的createSandboxRuntime给出了纯execute()在本地实现中的完整生命周期,注释同样点明与 ADR 的对应关系:
execute owns the slot lifecycle: acquire -> provision -> run -> release on success / invalidate on throw(
execute拥有槽位生命周期:获取 → 预备 → 运行 → 成功时释放 / 抛错时作废)。
关键实现要点:
createSandboxRuntime({ concurrency, ... })按并发数创建等量的SandboxManager(每个 box 一个 manager);execute()按workerIndex路由到对应 manager,acquire()取沙箱;- 先
localExecutionCache.provision(...)把 pieces / 代码 / publicApiUrl / engineToken 落到本地缓存,失败则manager.invalidate()并抛出; - 之后才
sandbox.start()(fork 引擎子进程 + 启动)与sandbox.execute(operationType, operation, ...); - 成功
release(),异常invalidate()。
这印证了"池只消费已解析输入"的论断:execute的参数provision是调用方(worker.ts 中的createResolver)物化好的结果,沙箱内部不再触碰apiClient。
4.3 Worker 侧的接线
worker.ts 的executeJob()展示了两个 URL 的最终分流:
const apiUrl = getApiUrl() const { PUBLIC_URL: publicUrl } = await workerSettings.waitForSettings() const publicApiUrl = ensurePublicApiUrl(publicUrl) // The engine forks in-process inside this worker, so it reaches the app over the worker's own // app URL (cluster-internal when co-located with the app, the public URL for a standalone worker). const internalApiUrl = apiUrl const ctx: JobContext = { apiClient, runtime, resolver: createResolver({...}), internalApiUrl, publicApiUrl, ... }其中internalApiUrl承担"引擎→App"的所有回调,而publicApiUrl只作为字符串注入与 bundle 下载地址。这正是决策 Consequences 中"Worker→app routing"一节在代码里的投影。
五、遗产一:pieces 链接分发(ADR 0002)的动机变化
决策 Consequences 明确指出,远程池方案催生或强化了"pieces 是链接"的决策(即 000006-pieces-are-distributed-as-links-resolved-lazily.md,ADR 文件编号 0002):
bundle link 基于
publicApiUrl构建,其理由是"从分离的远程池可达"。如今远程池已不存在,这个理由随之消失——但 "links" 决策依然凭借其他优点成立(传输统一的安装方式、S3 惰性预热、自托管者总能拿到可用链接),只是 public-URL 这个基础现在变成了附带性考虑,未来可以迁移到 internal URL。
拆开看,这个"链接分发"决策本身的价值并不依赖远程池:
- 传输统一:所有 piece(官方 npm 包、S3 缓存对象、自定义 ARCHIVE piece)统一走"一个可下载链接",池用
bun install消费,删除旧的 socket 字节通道(getPieceArchive/fetchArchive已被移除); - 惰性预热:S3 缓存未命中时立即返回 npm / 文件存储链接,并去重触发一个
SYSTEM预热任务(按bundle:<platformId|global>:<name>:<version>去重),避免全量批量同步; - 自托管友好:S3 只是纯优化,没有 S3 的自托管者仍能得到可用链接。
这也解释了为什么 ADR 0001/0002 被标记为 superseded 而非删除:它们记录的"为什么",一部分已经失效,但结论本身仍有生命力。ADR 文件保留(2026-07-16 标记),是为了让后人能追溯动机变化的完整脉络。
六、遗产二:Worker→App 回路由 —— AP_FRONTEND_URL 与 PUBLIC_URL 的分工
这是本决策对今天运维影响最大的一条,原文如下:
对于独立 Worker(
AP_CONTAINER_TYPE=WORKER),AP_FRONTEND_URL是所有按执行(per-execution)引擎→App 回调的基础(internalApiUrl:worker/project、populated-flows、connections/props、run-progress、file I/O、piece 的serverContext.apiUrl)。publicApiUrl(来自 App 的PUBLIC_URL)只是字符串注入(webhook URL、serverContext.publicUrl)加上缓存用的、provisioning 时刻的 bundle 下载——不是按请求的路径。
6.1 两种 URL,两种职责
AP_FRONTEND_URL→internalApiUrl:引擎执行期间所有需要回访 App 的请求都打到这里。包括拉取 project / worker 信息、获取已发布的 flow bundle、解析 connection 与属性(props)、上报 run-progress、文件读写,以及 piece 运行时通过serverContext.apiUrl发起的调用。这些请求每个 flow run 都会发生,属于高频热路径。PUBLIC_URL→publicApiUrl:仅用于两类场景——把"面向用户"的 URL 注入给引擎(如 webhook 地址、serverContext.publicUrl),以及 provisioning 阶段下载 bundle(有缓存,非按请求)。它不参与每次执行的回调。
6.2 指向集群内部服务,规避 hairpin
因此把
AP_FRONTEND_URL指向集群内部服务,能让每个回调都不经过公共端点(避免 DNS + TLS + LB 的回环),而且这是安全的——因为面向用户/公共的 URL 独立来自PUBLIC_URL。
这在 configs.ts 中有直接实现。getApiUrl()按容器类型分叉:
function getApiUrl(): string { const containerType = system.get(WorkerSystemProp.CONTAINER_TYPE) ?? 'WORKER_AND_APP' if (containerType === 'WORKER_AND_APP') { const port = process.env[WorkerSystemProp.PORT] ?? system.get(WorkerSystemProp.PORT) return `http://127.0.0.1:${port}/api/` } const frontendUrl = system.getOrThrow(WorkerSystemProp.FRONTEND_URL).replace(/\/+$/, '') return frontendUrl + '/api/' }- 当 Worker 与 App 同容器(默认
WORKER_AND_APP)时,回调直接走http://127.0.0.1:${AP_PORT:-3000}/api/,零网络开销; - 当
AP_CONTAINER_TYPE=WORKER(独立 Worker)时,回调走AP_FRONTEND_URL + '/api/'。在 Kubernetes 等集群环境中,把该变量设为http://<app-service>.<namespace>.svc.cluster.local这类集群内部地址,就能让高频回调留在集群内网,避免"出公网再回来"的 hairpin 损耗。
6.3 配置速查表
结合 configs.ts 中的WorkerSystemProp枚举,独立 Worker 场景下的关键配置如下:
| 环境变量 | 默认值 | 在独立 Worker(AP_CONTAINER_TYPE=WORKER)下的作用 |
|---|---|---|
AP_CONTAINER_TYPE | WORKER_AND_APP | 设为WORKER时启用独立 Worker 模式 |
AP_FRONTEND_URL | 无(getOrThrow必填) | 构成internalApiUrl的全部引擎→App 回调基础,应指向集群内部服务 |
AP_WORKER_TOKEN | 无 | 连接 App Socket.IO 的鉴权令牌 |
AP_WORKER_CONCURRENCY | 5 | 过渡兼容模式的并发数;最终形态是 concurrency 1 + 副本横向扩展 |
AP_PORT | 3000 | 同容器模式下的本地回环端口,也是健康检查端口 |
PUBLIC_URL(App 侧) | 无 | 只作为publicApiUrl字符串注入与 provisioning 期 bundle 下载地址 |
注意:getApiUrl()中AP_FRONTEND_URL通过system.getOrThrow读取,意味着独立 Worker 模式下该变量缺失会直接抛错——这是刻意的 fail-fast 设计,防止回调地址静默失效。
七、现状:纯 execute 形态幸存,远程池彻底退役
决策的最终结论分两层:
被废除的:远程 Cloud Run / 分离池托管方式整体移除。替代方案是 000001-worker-is-the-sandbox-one-job-per-worker-scale-by-replicas.md 确立的"Worker 即沙箱"模型:Worker 与沙箱合并为一个单元,一个 Worker 同时只轮询一个 job(concurrency 1),并行性完全依靠 N 个 Worker 副本横向扩展,每个副本上限 0.5 CPU / 1 GB。该模型同时移除了旧方案最大的运维风险——Docker socket 依赖,并删除了/execute跳转、远程传输、provisioner 与 HTTP 信封等一整条边界。
被保留下来的:纯 execute 的形态。沙箱至今不持有应用连接,只消费已解析的输入——这一点在 resolver.ts 与 sandbox.ts 的代码注释中被反复强调,且与"Worker 是唯一 Resolver"的现状完全一致。
八、给读者与运维者的实践清单
- 部署独立 Worker 时:务必显式设置
AP_CONTAINER_TYPE=WORKER与AP_FRONTEND_URL,并让后者指向集群内部可达的 App 服务地址,而不是公网域名; - 不要把
PUBLIC_URL与AP_FRONTEND_URL混为一谈:前者服务用户可见 URL(webhook、serverContext.publicUrl),后者服务执行期回调,二者独立配置、互不替代; - 理解"已废弃"不等于"错误":ADR 000013 记录了远程池方案的完整推理链,其核心洞察(输入物化前置、无连接执行、链接分发)至今仍是 Activepieces 执行引擎的设计基石;
- 追溯决策时:结合 000001-worker-is-the-sandbox-one-job-per-worker-scale-by-replicas.md 与 000006-pieces-are-distributed-as-links-resolved-lazily.md 一起阅读,可以看到从"远程池"到"Worker 即沙箱"的完整演化弧线——这也是本项目用 superseded ADR 保留历史决策动机的用意所在。
【免费下载链接】activepiecesAI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考