这两年用 n8n 做流程自动化的人越来越多,但大部分人把 n8n 部署好以后就默认开工了,根本不会注意到一个关键组件——任务执行器。n8n 从 2.x 版本开始引入了外部执行器机制,简单说就是把 Code 节点、自定义 JS、Python 脚本这类代码运行环境从主实例里拆出去,用独立的容器去跑。2026 年 2 月这个 2.9.2 版本,我实际部署下来感觉外部执行器已经非常成熟,但网上能找到的教程大多只讲了“怎么把 n8n 跑起来”,很少真正讲清楚“怎么让代码节点跑在外部执行器里”。这篇文章会先讲外部执行器的运行架构和它到底解决了什么问题,再给出一套可以直接抄的 docker compose 配置,然后重点拆解验证方法、踩坑链路和优化思路。适合已经跑过 n8n 基础 Docker 部署、想进一步做隔离加固的人,也适合正在规划生产环境、想把代码执行和主进程彻底解耦的团队参考。1. 先搞清一件事:外部执行器不是横向扩容,而是代码沙箱外置很多第一次接触外部执行器的人都会问同一个问题:这不就是给 n8n 多开了几个 worker 吗?还真不是。理解这一点,比拿到任何 YAML 配置文件都重要,因为后面的网络规划、资源分配、故障排查全都要建立在这个认知上。1.1 默认架构里 Code 节点在哪儿跑默认情况下,n8n 的代码执行器是以子进程的方式存在于主容器内部的。也就是说,当你往工作流里拖一个 Code 节点,写一段 JavaScript 或者 Python 脚本,这段代码并不是“魔法般地”在某个远程沙箱里运行,而是在当前 n8n 容器内被一个 runner 子进程加载执行。这个设计本身没什么问题,小规模使用很省心。但一旦流程复杂起来,后果就会显现:一个写了死循环或者内存泄漏的代码节点,可能直接把主容器的 CPU 和内存吃满,而主容器同时还承担着 Webhook 触发、工作流调度、连接器管理这些核心任务。主实例一崩,所有工作流全部受影响,不只是你调试的那一条。外部执行器登场后,职责就清晰了。主实例只负责编排、触发、连接服务和数据流转,代码执行全部交给一个独立的 task runner 容器。两个容器之间通过 gRPC 长连接通信,代码在任意一端崩掉,都不会立刻拖垮另一端。1.2 外部执行器带来的三个实际收益第一个收益是故障隔离。代码节点跑在独立容器里,最坏情况下也就是这个容器 OOM 或者崩溃,主实例通过心跳发现执行器失联后,会重新拉起或者等下一个心跳恢复,其他工作流不会跟着遭殃。第二个收益是资源可控。因为执行器是独立容器,你可以直接用 Docker 或者 Kubernetes 的 limit 把内存和 CPU 限制住。比如给执行器容器设一个mem_limit: 1024m,那么所有代码节点加起来最多只能吃掉 1GB 内存,主进程永远不会被不可控的脚本拖垮。第三个收益是执行环境独立。主实例容器里可以保持最小化,不装乱七八糟的运行时;执行器容器可以单独准备 Python 环境、Node 依赖、系统库。以后想升级执行器环境,只动执行器镜像就行,不需要重建主实例,也不用担心影响其他服务。关于第三点我再多说一句,很多人喜欢在主容器里用 apt 装一堆包,这种做法在外部执行器模式下会失效。你的代码节点最终是扔给执行器容器跑的,所以依赖必须装在执行器镜像里,或者通过执行器容器的 volumes 挂载进去。这是一个和传统认知差别很大的地方,后面配置部分会再展开。1.3 它和 Worker 节点之间的区别为了彻底划清边界,这里把横向扩容的 worker 机制拿出来对比一下。worker 模式对应的是 n8n 的队列模式:主实例把整个工作流封装成任务,丢到 Redis 队列里,然后多个 worker 容器各自消费这些任务,执行完整的工作流。Worker 之间通过队列负载均衡,这是横向扩容的方案。外部执行器则不涉及工作流分发,也不依赖 Redis。它是一条独立的 gRPC 通道,主实例把 Code 节点中的代码片段发给执行器,执行器跑完把结果返回。换句话说,worker 换的是一整个“执行工作流的员工”,外部执行器换的只是“帮你跑某段代码的工具人”。理论上你可以同时开队列模式和外部执行器:多个 worker 消费工作流,每个 worker 再连接外部执行器跑代码。这是比较完整的生产形态,不过对中小团队来说不是必需的,单主实例加外部执行器已经能解决大部分资源隔离问题。2. 部署前必须确认的三件事:版本、触发模式、网络外部执行器本身不是新东西,但在不同小版本之间,环境变量和默认行为会有一些细节差别。2.9.2 这个版本我实测下来稳定性可以,下面这些准备步骤是我觉得动手前必须完成的,能帮你少走一半弯路。2.1 版本匹配问题:执行器镜像必须和主实例一致第一个要确认的是版本。外部执行器容器和主实例之间有 gRPC 协议通信,而协议是跟着版本走的。主实例是 2.9.2,执行器镜像也应该用 2.9.2,最好不要主实例跑 2.9.x、执行器跑 2.8.x 这种组合。我见过不少人在生产环境栽过这个坑:主实例自动升级了,执行器镜像 tag 还是旧的,结果所有代码节点要么执行超时,要么直接报 protocol error。所以建议部署时把镜像 tag 写死,不要用 latest,并且每次升级时两台容器一起升。2.2 两种触发模式:托管式与独立式外部执行器在 Docker 环境下有两种跑法,先看对比表再往下走。模式主实例配置文件要点执行器容器优点缺点托管模式挂载/var/run/docker.sock,设置EXTERNAL_EXECUTOR_IMAGE由主实例自动拉起少写一堆配置;执行器生命周期由主实例管理主进程持有 Docker Socket 权限,安全面大;不适合 K8s独立模式设置EXTERNAL_EXECUTOR_STANDALONE_MODE=true手动 docker compose 起一个容器权限最小化,执行器可以单独扩缩容需要自己维护心跳、重连、健康检查托管模式最大的诱惑是省事。主实例看到你在主容器里配置了外部执行器镜像,就会通过 Docker Socket 自动帮你启动一个执行器容器。对于本地测试和低风险环境,这样足够快。但我个人强烈建议生产环境用独立模式。原因很现实:Docker Socket 属于高危挂载,一旦主容器被代码漏洞或者恶意工作流攻破,攻击者基本等于拿到了宿主机的 Docker 权限。而且托管模式下执行器容器的资源限制不好精细控制,你只能在主实例的环境变量里指定镜像名,不能给它单独设置mem_limit和cpuset。独立模式就是下面的主角:两个服务都写在 docker compose 里,主实例通过网络去连执行器,不碰 Docker Socket,执行器的资源配额交给 compose 管理。2.3 容器网络与主机名解析独立模式下主实例要能访问到执行器容器的 9100 端口,所以两边必须处在同一个 Docker 网络里。最简单的做法是用 docker compose 自动创建的默认网络,这样服务名本身就可以充当主机名。主实例里设置EXTERNAL_EXECUTOR_HOST=executor,compose 里的服务名就叫executor,Docker 内置 DNS 会把它解析成执行器容器的 IP。这个网络必须是 bridge 类型,不能用 host 模式。执行器容器如果用了 host 网络,gRPC 监听行为会变得比较诡异,而且服务名解析就失效了。如果你是在 K8s 环境,那就别用EXTERNAL_EXECUTOR_HOST指向服务名了,直接指向 headless service 的 DNS 域名,原理是一样的。3. 完整 docker compose 配置与逐行解释下面这套配置是我在 2.9.2 上验证过的写作基线,你完全可以直接复制,改掉令牌和存储路径就能跑起来。我特意把注释写得详细一点,方便你理解每一行的目的。3.1 目录结构与认证令牌先建一个工作目录,里面放docker-compose.yml和.env。认证令牌务必放在.env里,不要写死在docker-compose.yml中。外部执行器和主实例之间是需要认证的,两边必须持有同一个令牌,否则连接会被直接拒绝。生成令牌的方式随意,用系统自带的随机源就行。openssl rand -hex 32把输出结果填进.env:N8N_EXECUTOR_SECRET=这里填你生成的随机令牌主实例容器会读取这个令牌去连执行器,执行器容器会拿这个令牌验证主实例的身份。令牌泄露等于你的代码执行端点裸奔,所以提交到 Git 仓库之前务必检查.env是否被忽略了。3.2 主 n8n 容器配置主容器继续承担原来的全部职责,但要多加几个外部执行器相关变量。这里设置EXTERNAL_EXECUTOR_STANDALONE_MODE=true的意图很明确:告诉主实例“不要自己拉起内部执行器,也不要尝试通过 Docker Socket 托管外部容器,你要做的就是去连我已经准备好的那个独立容器”。services: n8n: image: n8nio/n8n:2.9.2 container_name: n8n-main restart: unless-stopped ports: - "5678:5678" environment: - EXTERNAL_EXECUTOR_STANDALONE_MODE=true - EXTERNAL_EXECUTOR_HOST=executor - EXTERNAL_EXECUTOR_PORT=9100 - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL=30 - EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT=90 - GENERIC_TIMEZONE=Asia/Shanghai volumes: - n8n_data:/home/node/.n8n depends_on: - executorEXTERNAL_EXECUTOR_HOST和EXTERNAL_EXECUTOR_PORT是主实例连接执行器的地址。端口 9100 是执行器容器的 gRPC 监听端口,这个端口不需要映射到宿主机,只要容器网络内互通即可。depends_on在这里不是严格的启动顺序控制,只是做一个基本的依赖声明。真正的保证要靠执行器容器的健康检查和主实例的自动重连机制,后面踩坑部分会说。3.3 task-runner 执行器容器配置执行器容器的核心就两个:镜像和命令。镜像同样必须用 2.9.2,命令则直接调用镜像内置的task-runner入口,让容器以纯执行器模式启动,而不是再启动一套完整的 n8n 应用。executor: image: n8nio/n8n:2.9.2 container_name: n8n-executor restart: unless-stopped command: ["task-runner"] environment: - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_PORT=9100 volumes: - executor_tmp:/tmp mem_limit: 1024m cpus: 1.0执行器容器需要的环境变量很少。它只需要知道用自己的哪个令牌做认证、监听哪个端口就够了。其余配置项是在主实例一侧控制的,比如心跳参数。资源限制我在这里给了mem_limit: 1024m和cpus: 1.0,这是我认为比较稳妥的初始值。如果你经常跑 NLP 模型推理、PDF 解析这类重负载代码节点,可以把内存调高到 2G;如果只是轻量字段转换,512M 也够。这个配置的好处是,即使工作流里有人写了while(true){},它也只能把这 1 核 CPU 吃满,碰不到主实例。卷部分挂了一个executor_tmp,给执行器留出临时文件目录。很多代码节点要落临时文件,容器默认用户的写入权限不够会报错,挂一个可写卷是省心做法。注意不要挂载主实例的n8n_data进去,执行器不应该直接读写主实例的工作流数据库。3.4 完整 compose 文件把上面两块拼起来,再加上网络和卷定义,就是完整的一套。网络这里我没有显式声明,docker compose 会默认创建一个项目名命名的 bridge 网络,n8n服务和executor服务自动在同一个网络里,服务名互通。version: "3.8" services: n8n: image: n8nio/n8n:2.9.2 container_name: n8n-main restart: unless-stopped ports: - "5678:5678" environment: - EXTERNAL_EXECUTOR_STANDALONE_MODE=true - EXTERNAL_EXECUTOR_HOST=executor - EXTERNAL_EXECUTOR_PORT=9100 - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL=30 - EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT=90 - GENERIC_TIMEZONE=Asia/Shanghai volumes: - n8n_data:/home/node/.n8n depends_on: - executor executor: image: n8nio/n8n:2.9.2 container_name: n8n-executor restart: unless-stopped command: ["task-runner"] environment: - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_PORT=9100 volumes: - executor_tmp:/tmp mem_limit: 1024m cpus: 1.0 volumes: n8n_data: executor_tmp:启动命令不需要额外技巧:docker compose up -d docker compose logs -f n8n executor第一次启动时,建议盯着两个容器的日志看,确认主实例有没有成功连上执行器。后面验证部分会教你具体看哪些关键字。3.5 托管模式的可选配置如果你确实只是本地测试,不想手动维护执行器容器,托管模式可以这样写。注意这里把独立模式变量换成EXTERNAL_EXECUTOR_IMAGE,并且把 Docker Socket 挂进去,主实例会自动启动一个镜像相同的执行器容器。services: n8n: image: n8nio/n8n:2.9.2 container_name: n8n-main restart: unless-stopped ports: - "5678:5678" environment: - EXTERNAL_EXECUTOR_IMAGE=n8nio/n8n:2.9.2 - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL=30 - EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT=90 volumes: - n8n_data:/home/node/.n8n - /var/run/docker.sock:/var/run/docker.sock请记住,这只是测试偷懒用的。一旦把它放到多人协作的生产环境,等于把宿主机 Docker 控制权交给了 n8n 进程,我建议你们团队评估好风险再决定。4. 启动后的验证步骤:如何确认代码节点真的跑在外部执行器里配置写完了,容器也起来了,但“容器起来了”不等于“外部执行器生效了”。很多人栽在这里:主实例连不上执行器,但它不会拒绝启动,只是内部静默回退到某种降级状态。所以验证这一步不能省。4.1 通过日志确认注册成功主实例日志是最直接的证据。正常连接的情况下,你会在 n8n 主容器日志里看到执行器注册成功之类的记录,后面跟着runner的标识信息。如果连接失败,常见提示包括连接被拒绝、认证失败、心跳超时等关键字。执行器容器的日志也会显示它已经开始监听某个地址,并等待主实例建立连接。如果执行器日志长时间没有任何输出,可能就是网络不通或者端口没监听,需要回到网络配置排查。我用docker compose logs -f n8n executor把两个容器的日志同时推到终端,一边是连接方,一边是服务方,谁那边出错一眼就能对上。4.2 用一段“自报家门”的 Code 节点测试日志只是第一层证据,更可靠的验证方式是在 n8n 工作流里写一个能暴露运行环境信息的 Code 节点。随便建一个工作流,手动触发节点,让它输出所在容器的 hostname 和进程 ID:return { hostname: process.env.HOSTNAME, pid: process.pid, ppid: process.ppid, };如果外部执行器配置正确,返回结果里的hostname应该是n8n-executor容器的主机名,而不是n8n-main的。这个测试最直观,因为它直接在运行时告诉你代码到底在哪个容器里执行。同样道理,你可以在执行器容器里执行ls /tmp,看看里面是否出现了代码节点生成的临时文件,进一步佐证执行确实发生了执行器容器内。4.3 用 docker top 观察进程归属还有一个静态检查方法。正常的外部执行器模式下,主容器内不应该有活跃的代码执行子进程。在主实例空闲的时候执行:docker top n8n-main你看到的是 n8n 主进程和它必要的辅助进程。然后你可以在 n8n 工作流里放一个带setTimeout延迟的代码节点,在它执行期间再次执行docker top n8n-main,如果代码真在外部执行器跑,主容器里依然不会有代码节点的子进程冒出来。同时docker top n8n-executor里能看到执行器进程在工作。这个方法能帮你绕开日志干扰项,直接验证“代码不在主容器里跑”这个核心事实。4.4 大规模数据下的性能观察如果你的工作流里有循环处理大批量数据的代码节点,还可以做一个额外观察:在主实例容器上设置一个资源监控,执行器容器上设置另一个资源监控,然后触发一个数据量较大的流程,看看 CPU 和内存的波动到底出现在哪个容器上。我用docker stats做过对比,效果非常直观。跑一个处理 5 万条 JSON 记录的去重脚本,主容器 CPU 几乎平稳,执行器容器的 CPU 则冲高到 80% 以上。这说明代码负载被成功隔离到了执行器上,外部执行器配置基本没跑偏。5. 部署中最容易踩的五个坑与排查链路这部分是全文最值钱的内容。2.9.2 的外部执行器部署出现问题时,错误信息往往不明显,很多人会一直怀疑工作流逻辑有问题,结果折腾半天发现是环境配置的锅。下面五个坑是我亲身排查过的高频问题,每条都给出完整的定位思路。5.1 认证令牌不一致导致握手被拒现象是主实例日志里频繁出现认证相关错误,执行器容器日志也有对应的拒绝记录。但两个容器都在正常运行,工作流也能创建,就是所有代码节点都执行失败。排查链路其实很短:先确认.env里的令牌是否被正确加载,然后对比两个容器实际拿到的环境变量值。有时候是.env文件里多了空格或者引号,导致主实例和执行器读到的值不完全相同。可以用这个命令单独拉出某个服务的环境变量来做对比:docker compose exec n8m sh -c 'echo $EXTERNAL_EXECUTOR_AUTH_TOKEN' docker compose exec executor sh -c 'echo $EXTERNAL_EXECUTOR_AUTH_TOKEN'两侧输出必须完全一致。这个坑十有八九是复制令牌时混入了不可见字符,建议重新用openssl rand -hex 32生成一次,然后注意别在.env里给值加引号。5.2 镜像版本错位导致的 protocol error第二个高频坑是升级遗留问题。主实例之前跑的是 2.8.x,后来镜像 tag 改成了 2.9.2,但执行器容器的镜像 tag 忘了改。两个容器都在运行,代码节点却开始随机报错,错误信息里经常出现 protocol、decode 这类字眼。这类问题的可怕之处在于它不是必现的。简单的代码节点运气好能跑通,复杂的代码节点就挂,非常迷惑。正确的排查方式是对比两个容器的镜像 digest,而不是只相信 tag:docker inspect --format='{{.Image}}' n8n-main docker inspect --format='{{.Image}}' n8n-executor两个 digest 一致,才能说明它们真是同一套代码。不一致的话,把两边版本对齐,重建执行器容器即可。5.3 心跳参数不合理导致执行器被摘除主实例通过心跳机制判断执行器是否存活。默认值在很多场景下够用,但如果你跑过长时间运行的代码节点,比如处理超大 Excel 文件或者调用慢接口,可能会遇到执行器在任务中途被主实例判定失联的情况。EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL是心跳发送间隔,EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT是主实例容忍多少次心跳之内无响应才把执行器标记为失联。如果你设置了非常激进的值,比如HEARTBEAT_INTERVAL=5、HEARTBEAT_MAX_TIMEOUT=10,那么代码节点只要忙超过 10 秒没来得及响应心跳,就会被误杀。这个参数的排查方式也简单:把代码节点换成一个固定setTimeout的脚本,分别跑 10 秒、30 秒、60 秒,结合日志看执行器在哪个时间点被摘除,然后调整心跳超时。生产环境我建议用30秒间隔、90秒超时的保守组合,宁失常一点,也不要误杀正在执行的任务。5.4 执行器容器缺少代码运行依赖这是把工作流逻辑问题错怪到部署头上的最常见场景。配置一切正常,外部执行器也确认生效,但代码节点一跑就报找不到模块、找不到系统库。原因在于你把原来的依赖全部装在主容器里了,而外部执行器是一个干净镜像,只包含 n8n 自带运行环境。解决思路有两种。一种是把依赖做进执行器镜像。写一个 Dockerfile,基于n8nio/n8n:2.9.2,把需要的东西加进去:FROM n8nio/n8n:2.9.2 USER root RUN apk add --no-cache python3 py3-pip jq COPY requirements.txt /tmp/requirements.txt RUN pip install --break-system-packages -r /tmp/requirements.txt USER node然后docker build -t my-n8n-executor:2.9.2 .,再把 compose 里执行器镜像名改成my-n8n-executor:2.9.2重建执行器。注意基础镜像的用户态:n8n 官方镜像默认非 root 运行,安装依赖阶段要切换到 root,装完再切回 node,否则运行时权限会出问题。另一种是直接把宿主机的目录挂载进去,比如把 Python 虚拟环境目录挂到执行器容器对应位置。这种方式适合临时调试,生产不太建议,因为可复现性差。做进镜像里才是正确方向。5.5 端口监听地址与容器网络冲突最后一个是网络层面的坑。执行器容器如果设置了一些奇怪的监听绑定,比如只监听127.0.0.1,那么主实例通过容器网络访问它的 9100 端口时,流量永远到不了执行器的服务端,因为执行器只听回环地址。这个问题的排查路径是先确认端口确实在监听:docker exec n8n-executor netstat -tlnp | grep 9100如果监听的地址不是0.0.0.0:9100,就需要调整执行器容器的监听配置,让它绑定到容器对外网卡。大部分情况下,使用默认配置并且不额外指定BIND_ADDRESS之类的变量是最稳妥的,除非你真的明确知道自己在干什么。另外,9100 端口不需要映射到宿主机,我在排查时见过有人手滑写了ports: ["9100:9100"],结果冲突不断,这个端口只在容器网络内可见就好。6. 优化方向:容量规划、安全加固、后续扩展外部执行器跑通是第一步,接下来就是让它跑得更稳、更安全。这一节整理几个我实际部署中总结出来的优化经验和后续扩展方向,按优先级排列。6.1 执行器容器的扩缩容思路外部执行器支持增加并行度来提升代码节点的吞吐能力。对于单机 Docker Compose 部署,最简单的方式是用 docker compose 的 scale 参数把执行器服务复制成多个实例:docker compose up -d --scale executor=2扩容之后,主实例会尝试连接多个执行器地址。如果你的主实例配置的是固定EXTERNAL_EXECUTOR_HOST=executor,那么复制之后 Docker 内置 DNS 会把这个服务名解析到多个容器 IP 上,主实例可以在这些 IP 之间做连接分配。不过这里要打个预防针:多执行器实例的连接分配和任务复用语义在不同小版本里略有差异,2.9.2 上扩大规模后最好做一轮压测,确认所有执行器确实都在接收任务。如果你发现自己需要频繁地横向扩缩执行器,那其实已经说明工作流负载到了新的量级,这时候应该考虑把 n8n 迁移到 K8s,用 Deployment 管理执行器副本,然后通过 service 把执行器地址稳定暴露给主实例。6.2 权限与镜像加固安全方面,我一直强调独立模式优先。除此之外还有几个加固点。执行器容器尽量不要使用privileged: true,也不要挂载docker.sock。一个纯粹的代码执行沙箱,应该连 Docker 客户端的影子都看不到。文件系统层面可以设置read_only: true,再单独挂载可写目录给/tmp和需要写缓存的目录即可。n8n 镜像默认用 node 用户运行,这是对的,别为了装依赖就顺手把整个容器的默认用户改成 root。安装依赖的 Dockerfile 里可以用多阶段构建或者临时切用户,但运行态继续保持非 root。这样即使代码节点被注入恶意脚本,攻击者拿到的也是一个受限用户,而不是宿主机的 root。如果团队里有多个项目共用同一套 n8n,认证令牌也需要区分。不同环境用独立的令牌,避免一个令牌泄露导致所有环境的执行器都暴露。6.3 与队列模式组合的完整生产形态如果你所在团队的业务量已经上来了,单主实例扛不住工作流并发,那就需要考虑在外部执行器之上叠加队列模式。形态是:一个主实例负责触发和分发,N 个 worker 消费工作流,每个 worker 连接一个执行器容器执行代码片段。这个方案的配置要点是要在 worker 容器里同样设置EXTERNAL_EXECUTOR_STANDALONE_MODE=true,并且把EXTERNAL_EXECUTOR_HOST指向同一个执行器服务。注意队列模式下,所有 worker 共享同一个外部执行器,那执行器就可能成为新的瓶颈,扩缩容逻辑要跟着调整。对于绝大多数团队来说,先跑通外部执行器、做好资源限制和版本锁定,已经能解决 80% 的稳定性问题。队列模式属于流量到了一定量级后的自然演进,不用一开始就上。我在 2.9.2 上折腾这套部署的实际体会是:外部执行器最大的价值不是性能提升,而是把“不可控的代码故障”从核心实例中剥离出去。自从把执行器独立出来之后,我可以在不打扰主实例的前提下,随时重启执行器、调整资源限制、升级执行环境,这种操作自由度是单容器时代很难想象的。最后分享一个小技巧:如果你不确定镜像里的task-runner命令行为是否正常,可以先手动跑一条简化版启动命令,让它在前台运行看看日志,排查清楚之后再回到 compose 体系,很多莫名其妙的问题其实在第一步就能暴露。
这两年用 n8n 做流程自动化的人越来越多,但大部分人把 n8n 部署好以后就默认开工了,根本不会注意到一个关键组件——任务执行器。n8n 从 2.x 版本开始引入了外部执行器机制,简单说就是把 Code 节点、自定义 JS、Python 脚本这类代码运行环境从主实例里拆出去,用独立的容器去跑。2026 年 2 月这个 2.9.2 版本,我实际部署下来感觉外部执行器已经非常成熟,但网上能找到的教程大多只讲了“怎么把 n8n 跑起来”,很少真正讲清楚“怎么让代码节点跑在外部执行器里”。这篇文章会先讲外部执行器的运行架构和它到底解决了什么问题,再给出一套可以直接抄的 docker compose 配置,然后重点拆解验证方法、踩坑链路和优化思路。适合已经跑过 n8n 基础 Docker 部署、想进一步做隔离加固的人,也适合正在规划生产环境、想把代码执行和主进程彻底解耦的团队参考。1. 先搞清一件事:外部执行器不是横向扩容,而是代码沙箱外置很多第一次接触外部执行器的人都会问同一个问题:这不就是给 n8n 多开了几个 worker 吗?还真不是。理解这一点,比拿到任何 YAML 配置文件都重要,因为后面的网络规划、资源分配、故障排查全都要建立在这个认知上。1.1 默认架构里 Code 节点在哪儿跑默认情况下,n8n 的代码执行器是以子进程的方式存在于主容器内部的。也就是说,当你往工作流里拖一个 Code 节点,写一段 JavaScript 或者 Python 脚本,这段代码并不是“魔法般地”在某个远程沙箱里运行,而是在当前 n8n 容器内被一个 runner 子进程加载执行。这个设计本身没什么问题,小规模使用很省心。但一旦流程复杂起来,后果就会显现:一个写了死循环或者内存泄漏的代码节点,可能直接把主容器的 CPU 和内存吃满,而主容器同时还承担着 Webhook 触发、工作流调度、连接器管理这些核心任务。主实例一崩,所有工作流全部受影响,不只是你调试的那一条。外部执行器登场后,职责就清晰了。主实例只负责编排、触发、连接服务和数据流转,代码执行全部交给一个独立的 task runner 容器。两个容器之间通过 gRPC 长连接通信,代码在任意一端崩掉,都不会立刻拖垮另一端。1.2 外部执行器带来的三个实际收益第一个收益是故障隔离。代码节点跑在独立容器里,最坏情况下也就是这个容器 OOM 或者崩溃,主实例通过心跳发现执行器失联后,会重新拉起或者等下一个心跳恢复,其他工作流不会跟着遭殃。第二个收益是资源可控。因为执行器是独立容器,你可以直接用 Docker 或者 Kubernetes 的 limit 把内存和 CPU 限制住。比如给执行器容器设一个mem_limit: 1024m,那么所有代码节点加起来最多只能吃掉 1GB 内存,主进程永远不会被不可控的脚本拖垮。第三个收益是执行环境独立。主实例容器里可以保持最小化,不装乱七八糟的运行时;执行器容器可以单独准备 Python 环境、Node 依赖、系统库。以后想升级执行器环境,只动执行器镜像就行,不需要重建主实例,也不用担心影响其他服务。关于第三点我再多说一句,很多人喜欢在主容器里用 apt 装一堆包,这种做法在外部执行器模式下会失效。你的代码节点最终是扔给执行器容器跑的,所以依赖必须装在执行器镜像里,或者通过执行器容器的 volumes 挂载进去。这是一个和传统认知差别很大的地方,后面配置部分会再展开。1.3 它和 Worker 节点之间的区别为了彻底划清边界,这里把横向扩容的 worker 机制拿出来对比一下。worker 模式对应的是 n8n 的队列模式:主实例把整个工作流封装成任务,丢到 Redis 队列里,然后多个 worker 容器各自消费这些任务,执行完整的工作流。Worker 之间通过队列负载均衡,这是横向扩容的方案。外部执行器则不涉及工作流分发,也不依赖 Redis。它是一条独立的 gRPC 通道,主实例把 Code 节点中的代码片段发给执行器,执行器跑完把结果返回。换句话说,worker 换的是一整个“执行工作流的员工”,外部执行器换的只是“帮你跑某段代码的工具人”。理论上你可以同时开队列模式和外部执行器:多个 worker 消费工作流,每个 worker 再连接外部执行器跑代码。这是比较完整的生产形态,不过对中小团队来说不是必需的,单主实例加外部执行器已经能解决大部分资源隔离问题。2. 部署前必须确认的三件事:版本、触发模式、网络外部执行器本身不是新东西,但在不同小版本之间,环境变量和默认行为会有一些细节差别。2.9.2 这个版本我实测下来稳定性可以,下面这些准备步骤是我觉得动手前必须完成的,能帮你少走一半弯路。2.1 版本匹配问题:执行器镜像必须和主实例一致第一个要确认的是版本。外部执行器容器和主实例之间有 gRPC 协议通信,而协议是跟着版本走的。主实例是 2.9.2,执行器镜像也应该用 2.9.2,最好不要主实例跑 2.9.x、执行器跑 2.8.x 这种组合。我见过不少人在生产环境栽过这个坑:主实例自动升级了,执行器镜像 tag 还是旧的,结果所有代码节点要么执行超时,要么直接报 protocol error。所以建议部署时把镜像 tag 写死,不要用 latest,并且每次升级时两台容器一起升。2.2 两种触发模式:托管式与独立式外部执行器在 Docker 环境下有两种跑法,先看对比表再往下走。模式主实例配置文件要点执行器容器优点缺点托管模式挂载/var/run/docker.sock,设置EXTERNAL_EXECUTOR_IMAGE由主实例自动拉起少写一堆配置;执行器生命周期由主实例管理主进程持有 Docker Socket 权限,安全面大;不适合 K8s独立模式设置EXTERNAL_EXECUTOR_STANDALONE_MODE=true手动 docker compose 起一个容器权限最小化,执行器可以单独扩缩容需要自己维护心跳、重连、健康检查托管模式最大的诱惑是省事。主实例看到你在主容器里配置了外部执行器镜像,就会通过 Docker Socket 自动帮你启动一个执行器容器。对于本地测试和低风险环境,这样足够快。但我个人强烈建议生产环境用独立模式。原因很现实:Docker Socket 属于高危挂载,一旦主容器被代码漏洞或者恶意工作流攻破,攻击者基本等于拿到了宿主机的 Docker 权限。而且托管模式下执行器容器的资源限制不好精细控制,你只能在主实例的环境变量里指定镜像名,不能给它单独设置mem_limit和cpuset。独立模式就是下面的主角:两个服务都写在 docker compose 里,主实例通过网络去连执行器,不碰 Docker Socket,执行器的资源配额交给 compose 管理。2.3 容器网络与主机名解析独立模式下主实例要能访问到执行器容器的 9100 端口,所以两边必须处在同一个 Docker 网络里。最简单的做法是用 docker compose 自动创建的默认网络,这样服务名本身就可以充当主机名。主实例里设置EXTERNAL_EXECUTOR_HOST=executor,compose 里的服务名就叫executor,Docker 内置 DNS 会把它解析成执行器容器的 IP。这个网络必须是 bridge 类型,不能用 host 模式。执行器容器如果用了 host 网络,gRPC 监听行为会变得比较诡异,而且服务名解析就失效了。如果你是在 K8s 环境,那就别用EXTERNAL_EXECUTOR_HOST指向服务名了,直接指向 headless service 的 DNS 域名,原理是一样的。3. 完整 docker compose 配置与逐行解释下面这套配置是我在 2.9.2 上验证过的写作基线,你完全可以直接复制,改掉令牌和存储路径就能跑起来。我特意把注释写得详细一点,方便你理解每一行的目的。3.1 目录结构与认证令牌先建一个工作目录,里面放docker-compose.yml和.env。认证令牌务必放在.env里,不要写死在docker-compose.yml中。外部执行器和主实例之间是需要认证的,两边必须持有同一个令牌,否则连接会被直接拒绝。生成令牌的方式随意,用系统自带的随机源就行。openssl rand -hex 32把输出结果填进.env:N8N_EXECUTOR_SECRET=这里填你生成的随机令牌主实例容器会读取这个令牌去连执行器,执行器容器会拿这个令牌验证主实例的身份。令牌泄露等于你的代码执行端点裸奔,所以提交到 Git 仓库之前务必检查.env是否被忽略了。3.2 主 n8n 容器配置主容器继续承担原来的全部职责,但要多加几个外部执行器相关变量。这里设置EXTERNAL_EXECUTOR_STANDALONE_MODE=true的意图很明确:告诉主实例“不要自己拉起内部执行器,也不要尝试通过 Docker Socket 托管外部容器,你要做的就是去连我已经准备好的那个独立容器”。services: n8n: image: n8nio/n8n:2.9.2 container_name: n8n-main restart: unless-stopped ports: - "5678:5678" environment: - EXTERNAL_EXECUTOR_STANDALONE_MODE=true - EXTERNAL_EXECUTOR_HOST=executor - EXTERNAL_EXECUTOR_PORT=9100 - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL=30 - EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT=90 - GENERIC_TIMEZONE=Asia/Shanghai volumes: - n8n_data:/home/node/.n8n depends_on: - executorEXTERNAL_EXECUTOR_HOST和EXTERNAL_EXECUTOR_PORT是主实例连接执行器的地址。端口 9100 是执行器容器的 gRPC 监听端口,这个端口不需要映射到宿主机,只要容器网络内互通即可。depends_on在这里不是严格的启动顺序控制,只是做一个基本的依赖声明。真正的保证要靠执行器容器的健康检查和主实例的自动重连机制,后面踩坑部分会说。3.3 task-runner 执行器容器配置执行器容器的核心就两个:镜像和命令。镜像同样必须用 2.9.2,命令则直接调用镜像内置的task-runner入口,让容器以纯执行器模式启动,而不是再启动一套完整的 n8n 应用。executor: image: n8nio/n8n:2.9.2 container_name: n8n-executor restart: unless-stopped command: ["task-runner"] environment: - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_PORT=9100 volumes: - executor_tmp:/tmp mem_limit: 1024m cpus: 1.0执行器容器需要的环境变量很少。它只需要知道用自己的哪个令牌做认证、监听哪个端口就够了。其余配置项是在主实例一侧控制的,比如心跳参数。资源限制我在这里给了mem_limit: 1024m和cpus: 1.0,这是我认为比较稳妥的初始值。如果你经常跑 NLP 模型推理、PDF 解析这类重负载代码节点,可以把内存调高到 2G;如果只是轻量字段转换,512M 也够。这个配置的好处是,即使工作流里有人写了while(true){},它也只能把这 1 核 CPU 吃满,碰不到主实例。卷部分挂了一个executor_tmp,给执行器留出临时文件目录。很多代码节点要落临时文件,容器默认用户的写入权限不够会报错,挂一个可写卷是省心做法。注意不要挂载主实例的n8n_data进去,执行器不应该直接读写主实例的工作流数据库。3.4 完整 compose 文件把上面两块拼起来,再加上网络和卷定义,就是完整的一套。网络这里我没有显式声明,docker compose 会默认创建一个项目名命名的 bridge 网络,n8n服务和executor服务自动在同一个网络里,服务名互通。version: "3.8" services: n8n: image: n8nio/n8n:2.9.2 container_name: n8n-main restart: unless-stopped ports: - "5678:5678" environment: - EXTERNAL_EXECUTOR_STANDALONE_MODE=true - EXTERNAL_EXECUTOR_HOST=executor - EXTERNAL_EXECUTOR_PORT=9100 - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL=30 - EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT=90 - GENERIC_TIMEZONE=Asia/Shanghai volumes: - n8n_data:/home/node/.n8n depends_on: - executor executor: image: n8nio/n8n:2.9.2 container_name: n8n-executor restart: unless-stopped command: ["task-runner"] environment: - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_PORT=9100 volumes: - executor_tmp:/tmp mem_limit: 1024m cpus: 1.0 volumes: n8n_data: executor_tmp:启动命令不需要额外技巧:docker compose up -d docker compose logs -f n8n executor第一次启动时,建议盯着两个容器的日志看,确认主实例有没有成功连上执行器。后面验证部分会教你具体看哪些关键字。3.5 托管模式的可选配置如果你确实只是本地测试,不想手动维护执行器容器,托管模式可以这样写。注意这里把独立模式变量换成EXTERNAL_EXECUTOR_IMAGE,并且把 Docker Socket 挂进去,主实例会自动启动一个镜像相同的执行器容器。services: n8n: image: n8nio/n8n:2.9.2 container_name: n8n-main restart: unless-stopped ports: - "5678:5678" environment: - EXTERNAL_EXECUTOR_IMAGE=n8nio/n8n:2.9.2 - EXTERNAL_EXECUTOR_AUTH_TOKEN=${N8N_EXECUTOR_SECRET} - EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL=30 - EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT=90 volumes: - n8n_data:/home/node/.n8n - /var/run/docker.sock:/var/run/docker.sock请记住,这只是测试偷懒用的。一旦把它放到多人协作的生产环境,等于把宿主机 Docker 控制权交给了 n8n 进程,我建议你们团队评估好风险再决定。4. 启动后的验证步骤:如何确认代码节点真的跑在外部执行器里配置写完了,容器也起来了,但“容器起来了”不等于“外部执行器生效了”。很多人栽在这里:主实例连不上执行器,但它不会拒绝启动,只是内部静默回退到某种降级状态。所以验证这一步不能省。4.1 通过日志确认注册成功主实例日志是最直接的证据。正常连接的情况下,你会在 n8n 主容器日志里看到执行器注册成功之类的记录,后面跟着runner的标识信息。如果连接失败,常见提示包括连接被拒绝、认证失败、心跳超时等关键字。执行器容器的日志也会显示它已经开始监听某个地址,并等待主实例建立连接。如果执行器日志长时间没有任何输出,可能就是网络不通或者端口没监听,需要回到网络配置排查。我用docker compose logs -f n8n executor把两个容器的日志同时推到终端,一边是连接方,一边是服务方,谁那边出错一眼就能对上。4.2 用一段“自报家门”的 Code 节点测试日志只是第一层证据,更可靠的验证方式是在 n8n 工作流里写一个能暴露运行环境信息的 Code 节点。随便建一个工作流,手动触发节点,让它输出所在容器的 hostname 和进程 ID:return { hostname: process.env.HOSTNAME, pid: process.pid, ppid: process.ppid, };如果外部执行器配置正确,返回结果里的hostname应该是n8n-executor容器的主机名,而不是n8n-main的。这个测试最直观,因为它直接在运行时告诉你代码到底在哪个容器里执行。同样道理,你可以在执行器容器里执行ls /tmp,看看里面是否出现了代码节点生成的临时文件,进一步佐证执行确实发生了执行器容器内。4.3 用 docker top 观察进程归属还有一个静态检查方法。正常的外部执行器模式下,主容器内不应该有活跃的代码执行子进程。在主实例空闲的时候执行:docker top n8n-main你看到的是 n8n 主进程和它必要的辅助进程。然后你可以在 n8n 工作流里放一个带setTimeout延迟的代码节点,在它执行期间再次执行docker top n8n-main,如果代码真在外部执行器跑,主容器里依然不会有代码节点的子进程冒出来。同时docker top n8n-executor里能看到执行器进程在工作。这个方法能帮你绕开日志干扰项,直接验证“代码不在主容器里跑”这个核心事实。4.4 大规模数据下的性能观察如果你的工作流里有循环处理大批量数据的代码节点,还可以做一个额外观察:在主实例容器上设置一个资源监控,执行器容器上设置另一个资源监控,然后触发一个数据量较大的流程,看看 CPU 和内存的波动到底出现在哪个容器上。我用docker stats做过对比,效果非常直观。跑一个处理 5 万条 JSON 记录的去重脚本,主容器 CPU 几乎平稳,执行器容器的 CPU 则冲高到 80% 以上。这说明代码负载被成功隔离到了执行器上,外部执行器配置基本没跑偏。5. 部署中最容易踩的五个坑与排查链路这部分是全文最值钱的内容。2.9.2 的外部执行器部署出现问题时,错误信息往往不明显,很多人会一直怀疑工作流逻辑有问题,结果折腾半天发现是环境配置的锅。下面五个坑是我亲身排查过的高频问题,每条都给出完整的定位思路。5.1 认证令牌不一致导致握手被拒现象是主实例日志里频繁出现认证相关错误,执行器容器日志也有对应的拒绝记录。但两个容器都在正常运行,工作流也能创建,就是所有代码节点都执行失败。排查链路其实很短:先确认.env里的令牌是否被正确加载,然后对比两个容器实际拿到的环境变量值。有时候是.env文件里多了空格或者引号,导致主实例和执行器读到的值不完全相同。可以用这个命令单独拉出某个服务的环境变量来做对比:docker compose exec n8m sh -c 'echo $EXTERNAL_EXECUTOR_AUTH_TOKEN' docker compose exec executor sh -c 'echo $EXTERNAL_EXECUTOR_AUTH_TOKEN'两侧输出必须完全一致。这个坑十有八九是复制令牌时混入了不可见字符,建议重新用openssl rand -hex 32生成一次,然后注意别在.env里给值加引号。5.2 镜像版本错位导致的 protocol error第二个高频坑是升级遗留问题。主实例之前跑的是 2.8.x,后来镜像 tag 改成了 2.9.2,但执行器容器的镜像 tag 忘了改。两个容器都在运行,代码节点却开始随机报错,错误信息里经常出现 protocol、decode 这类字眼。这类问题的可怕之处在于它不是必现的。简单的代码节点运气好能跑通,复杂的代码节点就挂,非常迷惑。正确的排查方式是对比两个容器的镜像 digest,而不是只相信 tag:docker inspect --format='{{.Image}}' n8n-main docker inspect --format='{{.Image}}' n8n-executor两个 digest 一致,才能说明它们真是同一套代码。不一致的话,把两边版本对齐,重建执行器容器即可。5.3 心跳参数不合理导致执行器被摘除主实例通过心跳机制判断执行器是否存活。默认值在很多场景下够用,但如果你跑过长时间运行的代码节点,比如处理超大 Excel 文件或者调用慢接口,可能会遇到执行器在任务中途被主实例判定失联的情况。EXTERNAL_EXECUTOR_HEARTBEAT_INTERVAL是心跳发送间隔,EXTERNAL_EXECUTOR_HEARTBEAT_MAX_TIMEOUT是主实例容忍多少次心跳之内无响应才把执行器标记为失联。如果你设置了非常激进的值,比如HEARTBEAT_INTERVAL=5、HEARTBEAT_MAX_TIMEOUT=10,那么代码节点只要忙超过 10 秒没来得及响应心跳,就会被误杀。这个参数的排查方式也简单:把代码节点换成一个固定setTimeout的脚本,分别跑 10 秒、30 秒、60 秒,结合日志看执行器在哪个时间点被摘除,然后调整心跳超时。生产环境我建议用30秒间隔、90秒超时的保守组合,宁失常一点,也不要误杀正在执行的任务。5.4 执行器容器缺少代码运行依赖这是把工作流逻辑问题错怪到部署头上的最常见场景。配置一切正常,外部执行器也确认生效,但代码节点一跑就报找不到模块、找不到系统库。原因在于你把原来的依赖全部装在主容器里了,而外部执行器是一个干净镜像,只包含 n8n 自带运行环境。解决思路有两种。一种是把依赖做进执行器镜像。写一个 Dockerfile,基于n8nio/n8n:2.9.2,把需要的东西加进去:FROM n8nio/n8n:2.9.2 USER root RUN apk add --no-cache python3 py3-pip jq COPY requirements.txt /tmp/requirements.txt RUN pip install --break-system-packages -r /tmp/requirements.txt USER node然后docker build -t my-n8n-executor:2.9.2 .,再把 compose 里执行器镜像名改成my-n8n-executor:2.9.2重建执行器。注意基础镜像的用户态:n8n 官方镜像默认非 root 运行,安装依赖阶段要切换到 root,装完再切回 node,否则运行时权限会出问题。另一种是直接把宿主机的目录挂载进去,比如把 Python 虚拟环境目录挂到执行器容器对应位置。这种方式适合临时调试,生产不太建议,因为可复现性差。做进镜像里才是正确方向。5.5 端口监听地址与容器网络冲突最后一个是网络层面的坑。执行器容器如果设置了一些奇怪的监听绑定,比如只监听127.0.0.1,那么主实例通过容器网络访问它的 9100 端口时,流量永远到不了执行器的服务端,因为执行器只听回环地址。这个问题的排查路径是先确认端口确实在监听:docker exec n8n-executor netstat -tlnp | grep 9100如果监听的地址不是0.0.0.0:9100,就需要调整执行器容器的监听配置,让它绑定到容器对外网卡。大部分情况下,使用默认配置并且不额外指定BIND_ADDRESS之类的变量是最稳妥的,除非你真的明确知道自己在干什么。另外,9100 端口不需要映射到宿主机,我在排查时见过有人手滑写了ports: ["9100:9100"],结果冲突不断,这个端口只在容器网络内可见就好。6. 优化方向:容量规划、安全加固、后续扩展外部执行器跑通是第一步,接下来就是让它跑得更稳、更安全。这一节整理几个我实际部署中总结出来的优化经验和后续扩展方向,按优先级排列。6.1 执行器容器的扩缩容思路外部执行器支持增加并行度来提升代码节点的吞吐能力。对于单机 Docker Compose 部署,最简单的方式是用 docker compose 的 scale 参数把执行器服务复制成多个实例:docker compose up -d --scale executor=2扩容之后,主实例会尝试连接多个执行器地址。如果你的主实例配置的是固定EXTERNAL_EXECUTOR_HOST=executor,那么复制之后 Docker 内置 DNS 会把这个服务名解析到多个容器 IP 上,主实例可以在这些 IP 之间做连接分配。不过这里要打个预防针:多执行器实例的连接分配和任务复用语义在不同小版本里略有差异,2.9.2 上扩大规模后最好做一轮压测,确认所有执行器确实都在接收任务。如果你发现自己需要频繁地横向扩缩执行器,那其实已经说明工作流负载到了新的量级,这时候应该考虑把 n8n 迁移到 K8s,用 Deployment 管理执行器副本,然后通过 service 把执行器地址稳定暴露给主实例。6.2 权限与镜像加固安全方面,我一直强调独立模式优先。除此之外还有几个加固点。执行器容器尽量不要使用privileged: true,也不要挂载docker.sock。一个纯粹的代码执行沙箱,应该连 Docker 客户端的影子都看不到。文件系统层面可以设置read_only: true,再单独挂载可写目录给/tmp和需要写缓存的目录即可。n8n 镜像默认用 node 用户运行,这是对的,别为了装依赖就顺手把整个容器的默认用户改成 root。安装依赖的 Dockerfile 里可以用多阶段构建或者临时切用户,但运行态继续保持非 root。这样即使代码节点被注入恶意脚本,攻击者拿到的也是一个受限用户,而不是宿主机的 root。如果团队里有多个项目共用同一套 n8n,认证令牌也需要区分。不同环境用独立的令牌,避免一个令牌泄露导致所有环境的执行器都暴露。6.3 与队列模式组合的完整生产形态如果你所在团队的业务量已经上来了,单主实例扛不住工作流并发,那就需要考虑在外部执行器之上叠加队列模式。形态是:一个主实例负责触发和分发,N 个 worker 消费工作流,每个 worker 连接一个执行器容器执行代码片段。这个方案的配置要点是要在 worker 容器里同样设置EXTERNAL_EXECUTOR_STANDALONE_MODE=true,并且把EXTERNAL_EXECUTOR_HOST指向同一个执行器服务。注意队列模式下,所有 worker 共享同一个外部执行器,那执行器就可能成为新的瓶颈,扩缩容逻辑要跟着调整。对于绝大多数团队来说,先跑通外部执行器、做好资源限制和版本锁定,已经能解决 80% 的稳定性问题。队列模式属于流量到了一定量级后的自然演进,不用一开始就上。我在 2.9.2 上折腾这套部署的实际体会是:外部执行器最大的价值不是性能提升,而是把“不可控的代码故障”从核心实例中剥离出去。自从把执行器独立出来之后,我可以在不打扰主实例的前提下,随时重启执行器、调整资源限制、升级执行环境,这种操作自由度是单容器时代很难想象的。最后分享一个小技巧:如果你不确定镜像里的task-runner命令行为是否正常,可以先手动跑一条简化版启动命令,让它在前台运行看看日志,排查清楚之后再回到 compose 体系,很多莫名其妙的问题其实在第一步就能暴露。