Async/await для контейнеров: как Trigger.dev приостанавливает и возобновляет выполняющиеся задачи
Я искал платформу для запуска рабочих процессов на базе ИИ — цепочек 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" });
},
});платформа может сохранить контрольную точку контейнера, освободить вычислительные ресурсы и восстановить его по завершении ожидания. Локальные переменные не нужно сериализовать вручную. Это относится к поддерживаемым операциям ожидания, а не к каждому JavaScript await; короткие таймеры могут обходиться без контрольной точки.
Trigger.dev называет это Checkpoint-Resume System. Снимок сохраняет состояние процесса, включая память и регистры CPU. Во время приостановки вычислений остаются хранение и оркестрация, а внешние сервисы не откатываются вместе с процессом.
Аналогия с асинхронными функциями полезна: приостановиться на время ожидания, затем продолжить. Отличается уровень освобождения ресурсов. Обычный await оставляет процесс работающим; поддерживаемое ожидание Trigger.dev позволяет приостановить контейнер и восстановить его на совместимом воркере.
Звучало это почти слишком хорошо, чтобы быть правдой, поэтому я попросил Claude изучить исходный код и понять, как оно устроено на самом деле. Ответ включает CRIU (Checkpoint/Restore In Userspace), экспериментальный checkpoint-API Docker, Buildah для создания OCI-образов и тщательно спроектированный конечный автомат, который всё это координирует.
В этой статье я хочу поделиться тем, что нашёл.
Проблема: долгие ожидания в бессерверных задачах
Сначала разберёмся, где вообще могут понадобиться контрольные точки. Рассмотрим задачу, которая проводит платёж, ждёт подтверждения, а затем отправляет чек:
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 };
},
});Многочасовое ожидание может превысить лимит времени одного вызова функции. Постоянно выделенный на это время воркер тоже стоит денег.
Обычные способы обработки такого ожидания:
- Держать контейнер запущенным, пока приходит подтверждение, — вы платите за вычисления всё это время, хотя родительская задача ничего не делает
- Разбить на несколько задач — разложить процесс на
chargeCustomer, запланированный триггер иsendReceipt, потеряв простоту одной функции. Оркестраторы вроде AWS Step Functions или Google Cloud Workflows могут помочь, но теперь вы отлаживаете конечные автоматы вместо асинхронных функций - Сериализовать состояние в базу данных — где-то сохранить
charge, запланировать последующую задачу, десериализовать при возобновлении — и вот вы уже строите движок рабочих процессов
Ответ Trigger.dev — вариант 4: заморозить память контейнера на диск, выключить его и восстановить позже.
Когда вы вызываете triggerAndWait, дочерняя задача запускается в отдельном контейнере, а родительская проходит через контрольную точку и приостанавливается — освобождая вычисления и конкурентность — до завершения дочерней. Родительская задача возобновляется с возвращённым дочерней значением, как при обычном await. Тот же механизм срабатывает и для таймерных ожиданий вроде await wait.for({ hours: 24 }).
Чем это отличается от традиционных подходов
Движки рабочих процессов по-разному сохраняют прогресс. Можно сохранять явное состояние приложения или воспроизводить историю событий. Ни то ни другое не равно сохранению образа процесса.
Воспроизведение истории событий, используемое Temporal, восстанавливает состояние через детерминированный код workflow и записанные события. Завершённые активности представлены сохранёнными результатами, а не повторным выполнением побочных эффектов. Произвольные замыкания и открытые сокеты сериализовать не нужно.
Контрольная точка контейнера сохраняет поддерживаемое состояние процесса, включая память локальных переменных и замыканий. Можно обойтись без повторного выполнения кода, но нужны совместимые ОС и среда выполнения. Файлы, сокеты и внешние ресурсы всё равно требуют отдельного рассмотрения.
Компромисс — между ограничениями воспроизведения и инфраструктурой снимков: контрольные точки упрощают работу со состоянием приложения, но требуют хранения, передачи и совместимости.
CRIU: технология в основе
CRIU — инструмент Linux для сохранения и восстановления поддерживаемого состояния: страниц памяти, регистров, файловых дескрипторов и связанного состояния ядра. Он может работать с деревом процессов.
CRIU работает ниже языковой среды, поэтому может сохранять программы на разных языках. Но не каждый процесс можно восстановить: существуют ограничения устройств, функций ядра, пространств имён и среды выполнения.
В Docker есть экспериментальная поддержка CRIU через docker checkpoint create, а в Kubernetes она доступна через CRI (Container Runtime Interface) командой crictl checkpoint. Trigger.dev использует оба варианта в зависимости от режима развёртывания. Когда CRIU недоступен вовсе — нет бинарника, ядро не поддерживает, экспериментальные возможности Docker не включены — Trigger.dev откатывается к docker pause, который приостанавливает контейнер, но не сохраняет состояние. Рабочий процесс продолжается, но если контейнер умрёт, запуск будет потерян. Этот запасной вариант существует для сред разработки, где настраивать CRIU непрактично.
Попробуйте сами
Демо запускает CRIU внутри привилегированного Docker-контейнера. Нужны совместимое ядро Linux и настройка CRIU; одного --privileged недостаточно для гарантии работы.
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, только обёрнутый в куда большее количество оркестрации.
Демо сохраняет процесс изнутри контейнера. В рассматриваемой далее реализации координатор снаружи запрашивает у среды контрольную точку задачи.
Как работает поток создания контрольной точки
Вот полный поток checkpoint-resume для родительской задачи, которая запускает дочернюю (на основе диаграммы из документации Trigger.dev):
Диаграмма показывает поток для triggerAndWait, но тот же механизм применим и к wait.for() — разница лишь в том, что разрешает точку ожидания (таймер или завершение дочерней задачи).
Ссылки ниже ведут на конкретные ревизии, использованные в разборе. Они описывают эту реализацию, а не гарантируют ту же структуру в текущих развёртываниях.
| Участник на диаграмме | Исходник | Класс |
|---|---|---|
| Trigger.dev | run-engine/engine/index.ts | RunEngine |
| Родительская/дочерняя задача | managed/controller.ts | ManagedRunController |
| Система CR | coordinator/checkpointer.ts | Checkpointer |
| Хранилище | coordinator/exec.ts | Buildah |
Система CR на диаграмме соответствует контейнеру Coordinator — вот как эти компоненты расположены на рабочей виртуальной машине:
Рабочая ВМ
├── Контейнер Supervisor (apps/supervisor)
│ ├── Забирает запуски из очереди платформы
│ ├── Создаёт контейнеры задач по требованию
│ └── Согласует создание контрольных точек с Coordinator
│
├── Контейнер Coordinator (apps/coordinator) ← «система CR» на диаграмме
│ ├── Запускает Checkpointer (CRIU, Buildah)
│ ├── Имеет доступ к демону Docker
│ └── Замораживает контейнеры задач снаружи
│
└── Контейнер задачи (эфемерный, по одному на запуск)
├── Controller (ManagedRunController) [точка входа]
│ └── Сигнализирует, когда задачу можно приостановить
│
└── Worker [дочерний процесс, порождён через IPC]
└── Код вашей задачи (task.run())В этой схеме контроллер сообщает о готовности, а координатор запрашивает контрольную точку снаружи контейнера задачи. Она включает состояние контроллера и воркера. Для восстановления на другом узле нужны совместимая среда и необходимое состояние файловой системы.
Пройдёмся по каждому шагу.
Шаг 1: запуск выполнения
Супервизор, работающий на рабочей ВМ, забирает запуск из очереди платформы и создаёт контейнер задачи через workloadManager.create(), передавая переменные окружения, например TRIGGER_SUPERVISOR_API_DOMAIN, чтобы процесс контроллера внутри контейнера задачи знал, как достучаться до Workload API супервизора по HTTP. Контроллер ManagedRunController — это основной процесс Node.js внутри контейнера задачи; он использует fork() из Node, чтобы породить дочерний процесс worker, который выполняет код вашей задачи. Эти два процесса общаются через IPC Node.js.
Шаг 2: запуск дочерней задачи
Когда ваш код вызывает await childTask.triggerAndWait(...), происходят две вещи:
- SDK, работающий внутри процесса worker, делает API-вызов напрямую к платформе Trigger.dev (участник «Trigger.dev» на диаграмме), минуя контроллер, — у worker есть собственный HTTP-клиент к платформе. Это ставит дочернюю задачу в очередь на выполнение и создаёт точку ожидания (waitpoint) — запись в базе данных платформы, которая говорит: «этот запуск ждёт завершения вот этой дочерней задачи».
- worker сигнализирует контроллеру через IPC, что его можно приостановить — то есть заморозить без потери данных.
Родительская задача не ждёт запуска дочерней; она просто говорит платформе «выполни это» и сигнализирует «меня уже можно сохранять». Обратите внимание на разделение: контроллер занимается жизненным циклом выполнения (сигналом о готовности к приостановке, управлением снапшотами), но API-вызовы SDK для запуска задач и создания точек ожидания обходят его полностью — они идут прямо от worker к платформе по HTTP. Для wait.for() точкой ожидания оказывается момент времени, а не дочерняя задача, но остальной поток идентичен.
Шаг 3: запрос снапшота
Контроллер внутри контейнера задачи вызывает suspendRun() в Workload API супервизора (по HTTP-соединению из шага 1). Супервизор делегирует задачу координатору через CheckpointClient. Координатор снаружи вызывает CRIU, чтобы заморозить контейнер задачи. Логика создания контрольной точки живёт в checkpointAndPush() и поддерживает два режима:
Режим Docker (локально/разработка):
docker checkpoint create --leave-running <container-name> <checkpoint-name>Режим Kubernetes (продакшен):
crictl checkpoint --export=/checkpoints/<identifier>.tar <container-id>Обе команды запрашивают контрольную точку процесса. В примере Docker задано --leave-running, поэтому создание снимка само по себе не останавливает исходный контейнер окончательно; приостановку и очистку обеспечивает оркестрация.
Шаг 4: сохранение снапшота
В продакшене (режим Kubernetes) контрольная точка экспортируется в виде tar-архива. Координатор оборачивает её в OCI-образ контейнера с помощью класса Buildah и отправляет в реестр:
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 передаёт артефакт контрольной точки. Для восстановления нужна совместимая среда с поддержкой таких снимков; упаковка в OCI не превращает его в обычный образ для любого узла.
Шаг 5: освобождение ресурсов
Как только образ контрольной точки сохранён, платформа:
- Обновляет статус запуска на
WAITING_TO_RESUMEв базе данных платформы (в той же, где на шаге 2 была создана точка ожидания) - Сохраняет запись TaskRunCheckpoint (тип, расположение, ссылка на образ)
- Освобождает занятые слоты конкурентного выполнения для этого запуска
Если у вас очередь с concurrencyLimit: 5 и три задачи приостановлены, эти три слота освобождаются. Приостановленные задачи не потребляют вычислительные ресурсы и не занимают слоты конкурентного выполнения.
Шаг 6: завершение дочерней задачи
Дочерняя задача выполняется в собственном контейнере. Когда она заканчивается, её контроллер вызывает completeRunAttempt() у супервизора, который сообщает результат платформе. Платформа разрешает точку ожидания родителя, созданную на шаге 2, и это запускает поток восстановления. Для wait.for() этот шаг заменяется истечением таймера, который разрешает точку ожидания точно так же.
Шаг 7: получение снапшота и восстановление состояния
Платформа запрашивает контрольную точку у системы CR, которая достаёт образ снапшота из хранилища. Из образа контрольной точки запускается новый контейнер — CRIU восстанавливает все процессы в их точное состояние памяти.
Восстановленный контроллер обнаруживает, что его восстановили, и вызывает continueRunExecution():
POST /api/runs/{runId}/continue
Body: { snapshotId: "...", workerId: "...", runnerId: "..." }Контейнер может восстановиться на другом совместимом воркере. Общий реестр обеспечивает доступ к артефакту; успех зависит от совместимости хоста и среды.
Шаг 8: возобновление и завершение выполнения
Бэкенд проверяет снапшот, переводит запуск в EXECUTING, и задача продолжается со строки после await. С точки зрения вашего кода ничего не произошло: await разрешился возвращаемым значением дочерней задачи, и выполнение идёт дальше как обычно.
Что может пойти не так
Контрольные точки — не волшебство. Есть краевые случаи:
Соединения могут устареть. Сохранение локального состояния сокета не поддерживает удалённую сторону. После долгого ожидания соединения с БД, HTTP или WebSocket могут требовать повторного подключения. Код должен учитывать это.
Снимки процесса и файловой системы различаются. Дескрипторы ссылаются на файлы, нужные содержимое и метаданные которых должны быть доступны при восстановлении. Сохранение записываемых слоёв и временных файлов зависит от среды и развёртывания, а не только от снимка памяти.
Большее потребление памяти обычно означает более крупные снимки. Размер не обязан равняться выделенной RAM: влияют резидентные страницы, сжатие, разреженные файлы и параметры среды. Большие снимки увеличивают объём хранения и время передачи и восстановления.
CRIU требует поддержки со стороны ядра. CRIU нужны определённые возможности ядра (пространства имён, cgroups) и экспериментальный режим Docker. В Kubernetes среда выполнения контейнеров (CRI-O или containerd) должна быть настроена на поддержку контрольных точек. Доступно это далеко не везде — поэтому у Trigger.dev и есть запасной вариант с имитацией.
Тот же приём, но на уровне виртуальных машин
CRIU работает на уровне процессов — он захватывает одно дерево процессов внутри контейнера. Но тот же приём checkpoint/restore работает и на уровне виртуальных машин. Firecracker, менеджер микро-ВМ, стоящий за AWS Lambda и Fly.io, умеет поставить целую виртуальную машину на паузу, сбросить всю её память и состояние устройств в файлы, а позже восстановиться из этих файлов — уже в совершенно новом процессе Firecracker.
Я проверил это в WSL2 с включённым KVM. Конфигурация: Firecracker v1.12.0, rootfs с Alpine Linux и shell-счётчиком, увеличивающимся раз в секунду, и готовое ядро Linux. Загрузив ВМ и дав счётчику дойти до 20, я поставил ВМ на паузу и создал снапшот через REST API Firecracker:
# Пауза
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 включают память гостя и состояние устройств, но восстановление всё равно имеет требования совместимости CPU, версий и хоста. AWS Lambda SnapStart использует снимки инициализированной среды для ускорения запуска. Это отличается от возобновления workflow посреди выполнения и не гарантирует старт каждой функции быстрее 100 мс.
В чём изящество подхода
Больше всего в этой конструкции мне нравится граница абстракции. С точки зрения разработчика:
await wait.for({ hours: 24 });За этим вызовом платформа координирует создание и хранение снимка, освобождение ресурсов и восстановление. Задача сохраняет обычный асинхронный поток, но всё равно должна учитывать внешние соединения, побочные эффекты и повторы.