容器版的 async/await:Trigger.dev 如何挂起并恢复正在运行的任务

我一直在找一个能运行 AI 工作流的平台——LLM 链、数据管道、智能体循环——又不想自己把队列、重试和调度那一整套基础设施都搭一遍。就这样我遇到了 Trigger.dev,一个用 TypeScript 运行后台任务的开源平台。这个项目做得不错,但其中有一个特性尤其吸引我。

如果你的任务调用了 wait.for(),或者用 triggerAndWait() 等待一个子任务:

export const myTask = task({
  id: "my-task",
  run: async () => {
    // 容器在这里被挂起——这一小时你不用付任何费用
    await wait.for({ hours: 1 });

    // 子任务在它自己的容器里运行期间,本容器处于挂起状态
    const result = await childTask.triggerAndWait({ data: "some data" });
  },
});

平台会把整个容器挂起,停止就计算资源向你计费,并从暂停的确切位置恢复执行——不管是一小时之后,还是子任务完成之时。不需要序列化,不需要状态管理,也不需要重跑前面的步骤。

Trigger.dev 把它称为 Checkpoint-Resume 系统。在等待子任务或预设暂停期间,系统会为任务的全部状态创建检查点——内存、CPU 寄存器、打开的文件描述符——然后释放所有资源。等待结束或子任务完成后,检查点被加载进一个新的执行环境,把任务还原到挂起前的确切状态。任务从中断处继续,子任务的结果被无缝衔接进来。

有意思的地方在于:这本质上就是把异步并发用到了基础设施这一层。在 JavaScript 里,await 会在网络调用完成期间挂起函数,把事件循环让给其他工作。Python 的协程做的是同一件事。Trigger.dev 拿的正是这个套路——暂停执行、释放资源、就绪后恢复——只不过把它用在整个容器上,而不是函数上。你任务代码里的 await 字面意义上挂起了机器;等到恢复时,它可能已经在一台完全不同的虚拟机上了。

这听上去好得有点不真实,于是我请 Claude 去读了 源代码,弄清它究竟是怎么工作的。答案涉及 CRIU(Checkpoint/Restore In Userspace)、Docker 的实验性 checkpoint API、用于创建 OCI 镜像的 Buildah,以及一套精心设计、把这一切协调起来的状态机。

这篇文章我想分享我的发现。

问题:无服务器任务中的漫长等待

先弄清楚检查点可能在哪些地方派得上用场。设想一个任务:先处理付款,再等待确认,然后发送收据:

import { task, wait } from "@trigger.dev/sdk";

export const processPayment = task({
  id: "process-payment",
  run: async (payload) => {
    const charge = await chargeCustomer(payload);

    // getConfirmation 在它自己的容器里运行期间,父容器处于挂起状态。
    // 这可能耗时数小时甚至数天——等待期间你不付费
    const confirmation = await getConfirmation.triggerAndWait({
      chargeId: charge.id,
    });

    await sendReceipt(charge, confirmation);

    return { success: true };
  },
});

核心问题在于,无服务器函数有硬性的执行超时。AWS Lambda 上限是 15 分钟,GCP Cloud Functions 是 60 分钟。一个跑 24 小时的工作流根本不可能装进一次函数调用里。常规建议是干脆换一种计算模型——ECS/GKE 上的容器、虚拟机,或者批处理服务——但这样你就失去了「只管写个函数」的简洁。

即便你没超时,也只剩三个糟糕的选项:

  1. 让容器一直跑着等确认到来——这段时间你一直在为计算资源付费,尽管父任务什么也没做
  2. 拆成多个任务——把工作流拆成 chargeCustomer、一个定时触发器和 sendReceipt,失去单个函数的简洁。AWS Step Functions 或 Google Cloud Workflows 这类工作流编排器能帮上忙,但你现在调试的是状态机,而不是异步函数
  3. 把状态序列化到数据库——把 charge 存到某处,安排一个后续作业,恢复时再反序列化——这时你已经在造工作流引擎了

Trigger.dev 的答案是第 4 个选项:把容器的内存冻结到磁盘,关掉它,稍后再恢复。

当你调用 triggerAndWait 时,子任务会在一个独立容器中启动,随后父任务被创建检查点并挂起——释放其计算资源与并发额度——直到子任务完成。父任务带着子任务的返回值恢复,就像一次普通的 await。同样的机制也适用于 await wait.for({ hours: 24 }) 这类定时等待。

与传统做法的对比

大多数工作流引擎处理长时间等待的办法,是把状态序列化到数据库。检查点机制与之相比是这样的:

数据库序列化(Flowable、Temporal 等):

  • 由你(或框架)决定保存什么
  • 状态必须可序列化——闭包、文件句柄、打开的连接都会丢失
  • 恢复时重建进程,再用保存的状态把它「注水」还原
  • 存储开销轻(几 KB 的序列化变量)
  • 框架必须理解你所用语言的运行时

容器检查点(Trigger.dev):

  • CRIU 自动保存一切——你根本不用去想
  • 什么都不会丢——内存、调用栈、局部变量、闭包全部保留
  • 恢复是精确的——进程并不知道自己被做过检查点
  • 存储开销重(容器内存镜像,可能有几百 MB)
  • 与语言无关——对容器里跑的任何进程都有效

取舍很清楚:检查点对开发者更简单(零序列化负担),但在存储和恢复延迟上更贵。数据库序列化很轻,但要求框架(或开发者)显式管理保存什么。

Trigger.dev 押的是:开发者的简洁值得这份基础设施成本。你写一个带 await 调用的普通 async 函数,剩下的交给平台。没有工作流 DSL,没有状态类,没有序列化接口。

CRIU:底层的技术

基础是 CRIU——Checkpoint/Restore In Userspace。CRIU 是一个 Linux 工具,能冻结一个正在运行的进程(或进程树),把它的完整状态保存到磁盘,之后再恢复。「完整状态」意味着一切:内存页、寄存器内容、文件描述符、套接字状态、信号处理器,全都算上。

CRIU 工作在操作系统层面。它不知道也不关心你的代码是用什么语言写的、作用域里有哪些变量、调用栈长什么样。它抓取的是原始内存页和内核状态。这意味着任何程序都可以被做检查点——Node.js、Python、C++,容器里跑什么都行。

Docker 通过 docker checkpoint create 提供了对 CRIU 的实验性支持,Kubernetes 则通过 CRI(Container Runtime Interface)用 crictl checkpoint 支持它。Trigger.dev 视部署模式两者都用。当 CRIU 完全不可用时——二进制文件缺失、内核不支持,或者 Docker 的实验特性没开——Trigger.dev 会退回到 docker pause,它只挂起容器,并不捕获状态。工作流照样继续,但如果容器挂了,这次运行就丢了。这个退路是给那些搭建 CRIU 不现实的开发环境准备的。

自己动手试试

用 Docker 容器里一个简单的 Python 计数器就能看到 CRIU 的效果。启动一个装好 CRIU 的容器(--privileged 是 CRIU 访问进程内存所必需的):

docker run -d --name criu-demo --privileged python:3.12-slim bash -c 'apt-get update -qq && apt-get install -y -qq criu > /dev/null 2>&1 && sleep infinity'

拷入计数器脚本——它只是把一个数字加一,每秒写进文件一次:

docker exec criu-demo bash -c 'cat > /counter.py << "EOF"
import time
count = 0
while True:
    count += 1
    with open("/output.txt", "a") as f:
        f.write(f"count = {count}\n")
    time.sleep(1)
EOF'

拷入演示脚本——它启动计数器、做检查点,然后再恢复它:

docker exec criu-demo bash -c 'cat > /demo.sh << "EOF"
#!/bin/bash
python3 /counter.py &
PID=$!
disown
sleep 5

echo "--- before checkpoint ---"
cat /output.txt

mkdir -p /checkpoint
criu dump -t $PID -D /checkpoint --shell-job -v0

echo "--- checkpointed, process killed ---"
> /output.txt

criu restore -d -D /checkpoint --shell-job -v0
sleep 5

echo "--- after restore ---"
cat /output.txt
EOF
chmod +x /demo.sh'

运行这个演示:

docker exec criu-demo /demo.sh

输出:

--- before checkpoint ---
count = 1
count = 2
count = 3
count = 4
count = 5
--- checkpointed, process killed ---
--- after restore ---
count = 7
count = 8
count = 9
count = 10
count = 11

计数器在写完 5 之后被做了检查点,进程随即被杀死,然后 CRIU 从检查点把它还原——它像什么都没发生过一样继续计数。变量 count 原本躺在 Python 的堆内存里,而 CRIU 捕获并还原了整个内存状态。这正是 Trigger.dev 所用的机制,只是外面裹了多得多的编排逻辑。

注意,容器需要 --privileged,CRIU 才能访问进程内存。在生产环境中,Trigger.dev 并不在任务容器内部运行 CRIU——由 supervisor 从外部调用 docker checkpoint createcrictl checkpoint,再由它们对整个容器施加 CRIU。

检查点流程是怎么走的

下面是父任务触发子任务时完整的 checkpoint-resume 流程(依据 Trigger.dev 文档中的示意图):

图中展示的是 triggerAndWait 的流程,但同样的机制也适用于 wait.for()——唯一的区别在于由什么来解除等待点(定时器,还是子任务完成)。

下面是每个参与方背后的关键源码文件:

图中参与方源码
Trigger.devrun-engine/engine/index.tsRunEngine
父任务/子任务managed/controller.tsManagedRunController
CR 系统coordinator/checkpointer.tsCheckpointer
存储coordinator/exec.tsBuildah

图中的 CR 系统 对应的是 Coordinator 容器——这些组件在工作节点虚拟机上的布局如下:

工作节点 VM
├── Supervisor 容器 (apps/supervisor)
│   ├── 从平台队列中取出运行任务
│   ├── 按需创建任务容器
│   └── 与 Coordinator 协同完成检查点

├── Coordinator 容器 (apps/coordinator)  ← 图中的「CR 系统」
│   ├── 运行 Checkpointer(CRIU、Buildah)
│   ├── 拥有访问 Docker 守护进程的权限
│   └── 从外部冻结任务容器

└── 任务容器(临时的,每次运行一个)
    ├── Controller (ManagedRunController)  [入口点]
    │   └── 在任务可挂起时发出信号

    └── Worker  [子进程,通过 IPC 派生]
        └── 你的任务代码 (task.run())

任务容器无法给自己做检查点。控制器发信号说自己可以被挂起,supervisor 告知 coordinator,再由 coordinator 从外部冻结容器。CRIU 给容器做检查点时,会同时捕获这两个进程以及它们之间的 IPC 通道——恢复时两者会同时被唤起。正是这种分离让跨机器恢复成为可能:检查点镜像被推送到镜像仓库,任何节点都能拉取并恢复它。

我们逐步走一遍。

第 1 步:开始执行

运行在工作节点 VM 上的 supervisor 从平台队列取出这次运行,并通过 workloadManager.create() 创建任务容器,同时传入环境变量(例如 TRIGGER_SUPERVISOR_API_DOMAIN),好让任务容器内的控制器进程知道如何通过 HTTP 访问 supervisor 的 Workload API。控制器 ManagedRunController 是任务容器内的主 Node.js 进程——它用 Node 的 fork() 派生出一个 worker 子进程来运行你的任务代码。两个进程通过 Node.js 的 IPC 通信。

第 2 步:触发子任务

当你的代码调用 await childTask.triggerAndWait(...) 时,会发生两件事:

  • 运行在 worker 进程中的 SDK 直接向 Trigger.dev 平台(图中的「Trigger.dev」参与方)发起 API 调用,绕过控制器——worker 有自己通往平台的 HTTP 客户端。这会把子任务排入执行队列,并创建一个等待点(waitpoint)——平台数据库中的一条记录,表示「这次运行正在等待这个子任务完成」。
  • worker 通过 IPC 告知控制器:它可以被挂起了——即可以在不丢数据的前提下冻结。

父任务并不等子任务启动;它只是告诉平台「运行这个」,然后发信号说「现在可以给我做检查点了」。注意这里的分工:控制器负责执行的生命周期(可挂起信号、快照管理),但 SDK 用来触发任务和创建等待点的 API 调用完全绕过它——它们直接由 worker 经 HTTP 发往平台。对 wait.for() 而言,等待点是一个时间点而不是子任务,但流程的其余部分完全一致。

第 3 步:请求快照

任务容器内的控制器在 supervisor 的 Workload API 上调用 suspendRun()(使用第 1 步建立的 HTTP 连接)。supervisor 通过 CheckpointClient 把任务委派给 coordinator。coordinator 从外部调用 CRIU 来冻结任务容器。检查点逻辑位于 checkpointAndPush(),有两种模式:

Docker 模式(本地/开发):

docker checkpoint create --leave-running <container-name> <checkpoint-name>

Kubernetes 模式(生产):

crictl checkpoint --export=/checkpoints/<identifier>.tar <container-id>

这两条命令都是让容器运行时去调用 CRIU,由它冻结所有进程、把所有内存页转储到磁盘,并保存内核状态(文件描述符、套接字、定时器)。

第 4 步:存储快照

在生产环境(Kubernetes 模式)中,检查点会被导出为 tar 归档。coordinator 用 Buildah 类把它封装成一个 OCI 容器镜像,并推送到镜像仓库:

buildah from scratch
buildah add <container> /checkpoints/<identifier>.tar /
buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=<shortCode> <container>
buildah commit <container> <registry>/<namespace>/<project>:<version>.prod-<shortCode>
buildah push --tls-verify <imageRef>

结果是一个标准的 OCI 容器镜像,被推送到容器镜像仓库(图中的「存储」参与方——和放 Docker 镜像的是同一类仓库)。集群中的任何节点都能拉取它并恢复检查点。

第 5 步:释放资源

检查点镜像存好之后,平台会:

  1. 在平台数据库中把运行状态更新WAITING_TO_RESUME(就是第 2 步创建等待点的那个数据库)
  2. 存入一条 TaskRunCheckpoint 记录(类型、位置、镜像引用)
  3. 释放这次运行占用的全部并发额度

如果你的队列设了 concurrencyLimit: 5,而有三个任务处于挂起状态,那三个槽位就会被腾出来。挂起的任务既不消耗计算资源,也不占用并发额度。

第 6 步:子任务完成

子任务在自己的容器里运行。它结束时,其控制器会在 supervisor 上调用 completeRunAttempt(),由 supervisor 把结果上报给平台。平台解除第 2 步创建的父任务等待点,从而触发恢复流程。对 wait.for() 来说,这一步换成定时器到期,它以同样的方式解除等待点。

第 7 步:取回快照并恢复状态

平台向 CR 系统请求检查点,后者从存储中取回快照镜像。以该检查点镜像启动一个新容器——CRIU 把所有进程还原到它们确切的内存状态。

被还原的控制器会察觉自己是恢复而来,于是调用 continueRunExecution()

POST /api/runs/{runId}/continue
Body: { snapshotId: "...", workerId: "...", runnerId: "..." }

这个容器可能位于与原来不同的物理节点上——检查点镜像在仓库里,任何节点都能拉取。

第 8 步:恢复并完成执行

后端校验快照,把运行状态更新为 EXECUTING,任务从 await 之后的那一行继续。从你代码的视角看,什么都没发生过——await 以子任务的返回值完成,执行照常往下走。

可能出什么问题

检查点不是魔法。也有一些边界情况:

网络连接活不下来。 TCP 套接字确实会被 CRIU 保存,但等到容器恢复时(几分钟、几小时甚至几天之后),远端早就把连接关掉了。任何打开着的 HTTP 连接、数据库连接或 WebSocket 连接都会失效。Trigger.dev 的运行时会为它自己的连接处理这一点(重新建立到 supervisor 的 WebSocket 和 HTTP 客户端),但如果你的任务代码跨着 wait 持有打开的连接,恢复后它们就会失败。

文件系统的改动是临时的。 检查点捕获的是内存,不是磁盘。如果你的任务在等待之前写了临时文件,恢复之后它们并不存在(容器是在镜像提供的全新文件系统上运行的)。请把任务设计成在等待边界两侧都能自给自足。

检查点体积随内存用量增长。 一个占用 2GB 内存的容器会产生一个 2GB 的检查点镜像。对于在内存中持有大数据集的任务,这意味着可观的存储与传输时间。推送检查点镜像时的 MAXLEN ~ 裁剪能有所帮助,但大检查点在创建和恢复上本质上就更慢。

CRIU 需要内核支持。 CRIU 需要特定的内核特性(命名空间、cgroups)以及 Docker 的实验模式。在 Kubernetes 中,容器运行时(CRI-O 或 containerd)必须配置为支持检查点。这并非到处都有——这也正是 Trigger.dev 准备了模拟退路的原因。

同样的套路,用在虚拟机层面

CRIU 工作在进程层面——它捕获的是容器内的单个进程树。但同样的 checkpoint/restore 套路在虚拟机层面也成立。Firecracker——AWS Lambda 和 Fly.io 背后的微虚拟机管理器——能够暂停整台虚拟机,把它完整的内存和设备状态转储成文件,之后再从这些文件恢复,而且是在一个全新的 Firecracker 进程里。

我在开启了 KVM 的 WSL2 上试了一下。配置是:Firecracker v1.12.0、一个带有每秒自增 shell 计数器的 Alpine Linux rootfs,以及一个预编译的 Linux 内核。启动虚拟机、让计数器数到 20 之后,我暂停虚拟机并通过 Firecracker 的 REST API 创建了快照:

# 暂停
curl --unix-socket /tmp/firecracker.socket -X PATCH \
  http://localhost/vm -H 'Content-Type: application/json' \
  -d '{"state": "Paused"}'

# 快照
curl --unix-socket /tmp/firecracker.socket -X PUT \
  http://localhost/snapshot/create -H 'Content-Type: application/json' \
  -d '{"snapshot_type": "Full", "snapshot_path": "./snapshot_file", "mem_file_path": "./mem_file"}'

然后我把 Firecracker 进程彻底杀掉,启动一个新的,并加载这份快照:

curl --unix-socket /tmp/firecracker.socket -X PUT \
  http://localhost/snapshot/load -H 'Content-Type: application/json' \
  -d '{"snapshot_path": "./snapshot_file", "mem_file_path": "./mem_file", "enable_diff_snapshots": false, "resume_vm": true}'

计数器从 21 继续。恢复耗时约 29 毫秒。

Firecracker 的快照更重(你保存的是整台虚拟机的内存,而不是单个进程),但也更具可移植性——除 KVM 之外不需要任何内核特性,不需要实验性标志,也不需要配置容器运行时。Lambda 正是这样做到 100 毫秒以内冷启动的:预先为已加载函数的虚拟机做好快照,调用时再从快照恢复。

这套设计的优雅之处

这个设计中最打动我的是它的抽象边界。从开发者的视角看:

await wait.for({ hours: 24 });

就这样,一行。而它的背后是:CRIU 冻结所有进程,内存页被转储到磁盘,Buildah 把它们封装成 OCI 镜像,镜像被推送到仓库,并发槽位被释放,一台状态机跟踪着快照的生命周期;数小时之后,一个新容器从检查点镜像启动——可能是在另一台机器上——进程被还原,连接被重新建立,然后那个 await 完成了。

全部复杂度都留在平台边界的另一侧。你的任务,不过是一个异步函数。