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" });
},
});платформа приостанавливает контейнер целиком, прекращает тарифицировать вычисления и возобновляет выполнение ровно с того места, где оно остановилось, — будь то час спустя или в момент завершения дочерней задачи. Никакой сериализации, никакого управления состоянием, никакого повторного выполнения предыдущих шагов.
В Trigger.dev это называется системой Checkpoint-Resume. Пока задача ждёт подзадачу или запрограммированную паузу, система сохраняет контрольную точку всего её состояния — память, регистры процессора, открытые файловые дескрипторы — и освобождает все ресурсы. Когда ожидание заканчивается или подзадача завершается, контрольная точка загружается в новую среду выполнения, восстанавливая задачу ровно в том состоянии, в каком она была до приостановки. Задача продолжается с того места, где остановилась, а результаты подзадачи бесшовно встраиваются в неё.
Что здесь интересно: по сути это асинхронная конкурентность, применённая на уровне инфраструктуры. В JavaScript await приостанавливает функцию, пока выполняется сетевой вызов, освобождая событийный цикл для другой работы. Корутины в Python делают то же самое. Trigger.dev берёт ровно этот приём — приостановить выполнение, освободить ресурсы, возобновить, когда всё готово — но применяет его к целым контейнерам, а не к функциям. await в коде вашей задачи буквально приостанавливает машину, а когда выполнение возобновится, это может быть уже совсем другая виртуальная машина.
Звучало это почти слишком хорошо, чтобы быть правдой, поэтому я попросил 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 };
},
});Основная проблема в том, что у бессерверных функций есть жёсткие тайм-ауты выполнения. AWS Lambda ограничена 15 минутами, GCP Cloud Functions — 60 минутами. Рабочий процесс длиной в сутки просто не поместится в один вызов функции. Стандартный совет — перейти на совершенно другую модель вычислений: контейнеры в ECS/GKE, виртуальные машины или batch-сервисы, — но тогда теряется простота «просто напиши функцию».
Даже если вы укладываетесь в тайм-ауты, у вас есть три неудачных варианта:
- Держать контейнер запущенным, пока приходит подтверждение, — вы платите за вычисления всё это время, хотя родительская задача ничего не делает
- Разбить на несколько задач — разложить процесс на
chargeCustomer, запланированный триггер иsendReceipt, потеряв простоту одной функции. Оркестраторы вроде AWS Step Functions или Google Cloud Workflows могут помочь, но теперь вы отлаживаете конечные автоматы вместо асинхронных функций - Сериализовать состояние в базу данных — где-то сохранить
charge, запланировать последующую задачу, десериализовать при возобновлении — и вот вы уже строите движок рабочих процессов
Ответ Trigger.dev — вариант 4: заморозить память контейнера на диск, выключить его и восстановить позже.
Когда вы вызываете triggerAndWait, дочерняя задача запускается в отдельном контейнере, а родительская проходит через контрольную точку и приостанавливается — освобождая вычисления и конкурентность — до завершения дочерней. Родительская задача возобновляется с возвращённым дочерней значением, как при обычном await. Тот же механизм срабатывает и для таймерных ожиданий вроде await wait.for({ hours: 24 }).
Чем это отличается от традиционных подходов
Большинство движков рабочих процессов обрабатывают долгие ожидания, сериализуя состояние в базу данных. Вот как с этим соотносится контрольная точка:
Сериализация в базу данных (Flowable, Temporal и т. п.):
- Вы (или фреймворк) решаете, что сохранять
- Состояние должно быть сериализуемым — замыкания, файловые дескрипторы, открытые соединения теряются
- Восстановление пересоздаёт процесс и наполняет его сохранённым состоянием
- Лёгкое хранилище (несколько килобайт сериализованных переменных)
- Фреймворк должен понимать рантайм вашего языка
Контрольные точки контейнера (Trigger.dev):
- CRIU сохраняет всё автоматически — вам не нужно об этом думать
- Ничего не теряется — память, стек вызовов, локальные переменные, замыкания сохраняются
- Восстановление точное — процесс не знает, что его сохраняли
- Тяжёлое хранилище (образ памяти контейнера, потенциально сотни мегабайт)
- Не зависит от языка — работает с любым процессом, запущенным в контейнере
Компромисс очевиден: контрольные точки проще для разработчика (нулевая нагрузка по сериализации), но дороже по хранилищу и задержке восстановления. Сериализация в базу легче, но требует, чтобы фреймворк (или разработчик) явно управлял тем, что сохраняется.
Ставка Trigger.dev в том, что простота для разработчика стоит инфраструктурных затрат. Вы пишете обычную async-функцию с вызовами await, а остальное берёт на себя платформа. Никакого DSL для рабочих процессов, никаких классов состояния, никаких интерфейсов сериализации.
CRIU: технология в основе
Фундамент — CRIU, Checkpoint/Restore In Userspace. CRIU — это инструмент для Linux, который умеет заморозить работающий процесс (или дерево процессов), сохранить его полное состояние на диск и восстановить позже. «Полное состояние» означает всё: страницы памяти, содержимое регистров, файловые дескрипторы, состояние сокетов, обработчики сигналов — всё целиком.
CRIU работает на уровне ОС. Ему неизвестно и неважно, на каком языке написан ваш код, какие переменные у вас в области видимости и как выглядит стек вызовов. Он захватывает сырые страницы памяти и состояние ядра. Это значит, что сохранить можно любую программу — Node.js, Python, C++, что угодно, что работает в контейнере.
В Docker есть экспериментальная поддержка CRIU через docker checkpoint create, а в Kubernetes она доступна через CRI (Container Runtime Interface) командой crictl checkpoint. Trigger.dev использует оба варианта в зависимости от режима развёртывания. Когда CRIU недоступен вовсе — нет бинарника, ядро не поддерживает, экспериментальные возможности Docker не включены — Trigger.dev откатывается к docker pause, который приостанавливает контейнер, но не сохраняет состояние. Рабочий процесс продолжается, но если контейнер умрёт, запуск будет потерян. Этот запасной вариант существует для сред разработки, где настраивать CRIU непрактично.
Попробуйте сами
Увидеть CRIU в действии можно на простом счётчике на Python внутри Docker-контейнера. Запустите контейнер с установленным 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 внутри контейнера задачи — супервизор снаружи вызывает docker checkpoint create или crictl checkpoint, и уже они применяют CRIU к контейнеру целиком.
Как работает поток создания контрольной точки
Вот полный поток 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())Контейнер задачи не может сохранить контрольную точку сам себе. Контроллер сигнализирует, что его можно приостановить, супервизор сообщает координатору, и координатор замораживает контейнер снаружи. Когда CRIU снимает контрольную точку контейнера, он захватывает оба процесса и канал IPC — при восстановлении оба возобновляются одновременно. Именно это разделение и делает возможным восстановление на другой машине: образ контрольной точки отправляется в реестр, и любой узел может его скачать и восстановить.
Пройдёмся по каждому шагу.
Шаг 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>Обе команды говорят среде выполнения контейнеров вызвать CRIU, который замораживает все процессы, сбрасывает все страницы памяти на диск и сохраняет состояние ядра (файловые дескрипторы, сокеты, таймеры).
Шаг 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-образ контейнера, отправленный в реестр контейнеров (участник «Хранилище» на диаграмме — тот же тип реестра, что используется для Docker-образов). Любой узел кластера может его скачать и восстановить контрольную точку.
Шаг 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 разрешился возвращаемым значением дочерней задачи, и выполнение идёт дальше как обычно.
Что может пойти не так
Контрольные точки — не волшебство. Есть краевые случаи:
Сетевые соединения не переживают приостановку. TCP-сокеты CRIU сохраняет, но к моменту восстановления контейнера (минуты, часы или дни спустя) удалённая сторона давно закрыла соединение. Любые открытые HTTP-соединения, соединения с базой данных или WebSocket-соединения окажутся протухшими. Рантайм Trigger.dev справляется с этим для своих собственных соединений (заново поднимает WebSocket к супервизору и HTTP-клиент), но если код вашей задачи держит открытые соединения через wait, при возобновлении они отвалятся.
Изменения в файловой системе эфемерны. Контрольная точка захватывает память, а не диск. Если ваша задача записала временные файлы до ожидания, после восстановления их не будет (контейнер работает на свежей файловой системе из образа). Проектируйте задачи так, чтобы они были самодостаточными по обе стороны от границы ожидания.
Размер контрольной точки растёт вместе с потреблением памяти. Контейнер, использующий 2 ГБ оперативной памяти, даёт образ контрольной точки на 2 ГБ. Для задач с большими наборами данных в памяти это означает существенные объёмы хранилища и время передачи. Обрезка MAXLEN ~ при отправке образа контрольной точки помогает, но большие контрольные точки по своей природе медленнее создаются и восстанавливаются.
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 тяжелее (вы сохраняете память всей ВМ, а не одного процесса), но они и более переносимы — никаких требований к возможностям ядра, кроме KVM, никаких экспериментальных флагов, никакой настройки среды выполнения контейнеров. Именно так Lambda добивается холодных стартов меньше 100 мс: заранее снять снапшот ВМ с загруженной функцией, а при вызове восстановиться из него.
В чём изящество подхода
Больше всего в этой конструкции мне нравится граница абстракции. С точки зрения разработчика:
await wait.for({ hours: 24 });Вот и всё. Одна строка. А за ней: CRIU замораживает все процессы, страницы памяти сбрасываются на диск, Buildah оборачивает их в OCI-образ, образ отправляется в реестр, слоты конкурентности освобождаются, конечный автомат отслеживает жизненный цикл снапшота, а спустя часы из образа контрольной точки запускается новый контейнер — возможно, на другой машине, — процесс восстанавливается, соединения поднимаются заново, и await разрешается.
Вся сложность целиком остаётся за границей платформы. Ваша задача — это просто асинхронная функция.