Async/await для контейнерів: як Trigger.dev призупиняє та відновлює виконання завдань
Я шукав платформу для запуску робочих процесів на основі штучного інтелекту — ланцюжків LLM, конвеєрів даних, циклів агентів — без самостійного створення всієї інфраструктури черги, повторних спроб і планування. Ось як я натрапив на Trigger.dev, платформу з відкритим кодом для виконання фонових завдань у TypeScript. Це гарний проект, але одна функція особливо привернула мою увагу.
Якщо ваше завдання викликає wait.for() або очікує дочірнього завдання з triggerAndWait():
export const myTask = task({
id: "my-task",
run: async () => {
// Container is suspended here — you pay nothing for the hour
await wait.for({ hours: 1 });
// Container is suspended while the child task runs in its own container
const result = await childTask.triggerAndWait({ data: "some data" });
},
});платформа може перевірити контейнер завдань, звільнити його обчислення та відновити його, коли очікування вирішиться. Вам не потрібно самостійно серіалізувати локальні змінні. Це стосується підтримуваних операцій очікування, а не кожного JavaScript await; короткі очікування таймера можуть не мати контрольної точки.
Trigger.dev називає це своєю системою Checkpoint-Resume System. Знімок зберігає стан процесу, наприклад пам’ять і регістри ЦП. Зберігання та оркестровка залишаються необхідними, поки обчислення призупинено, а зовнішні служби не перемотують процес.
Корисна аналогія з асинхронними функціями: пауза під час очікування, а потім продовження, коли буде готово. Різниця полягає в рівні, на якому вивільняються ресурси. Звичайний await залишає процес запущеним; підтримуване очікування Trigger.dev може дозволити платформі призупинити контейнер завдань і відновити його на сумісному робочому пристрої.
Це звучало занадто добре, щоб бути правдою, тому я попросив Клода вивчити вихідний код, щоб зрозуміти, як він насправді працює. Відповідь включає CRIU (контрольна точка/відновлення в просторі користувача), експериментальний 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);
// Parent container is suspended while getConfirmation runs in its own container
// Could take hours or days — you don't pay for the wait
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, реконструює стан робочого циклу, відтворюючи записані події за допомогою детермінованого коду робочого процесу. Завершені дії представлені записаними результатами, а не повторними як звичайні побічні ефекти. Це не вимагає серіалізації довільних замикань або відкритих сокетів.
Контейнер контрольних точок зберігає підтримуваний стан процесу, включаючи пам’ять, що містить локальні змінні та закриття. Він може уникнути повторного відтворення коду робочого процесу, але вимагає сумісної операційної системи та підтримки середовища виконання. Файли, сокети та інші зовнішні ресурси все ще потребують чіткого розгляду.
Компроміс полягає між обмеженнями відтворення та інфраструктурою моментальних знімків: контрольні точки зменшують обробку стану на рівні програми, одночасно додаючи вимоги до зберігання, передачі та сумісності.
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, просто загорнутий у більш оркестровку.
Демонстраційна контрольна точка процесу зсередини контейнера. У вихідній реалізації, яка обговорюється далі, координатор просить середовище виконання контейнера перевірити завдання ззовні.
Як працює потік контрольних точок
Ось повний потік контрольної точки-відновлення для батьківського завдання, яке запускає дочірнє завдання (на основі діаграми з Документи Trigger.dev):
На діаграмі показано потік для triggerAndWait, але той самий механізм застосовується до wait.for() — єдина різниця полягає в тому, що визначає точку очікування (таймер чи виконання дочірнього завдання).
Наведені нижче посилання на джерела вказують на конкретні версії, використані для цього покрокового керівництва. Вони описують цю реалізацію, а не гарантію того, що поточні розгортання використовують той самий макет компонентів.
| Актор діаграми | Джерело | Клас |
|---|---|---|
| Trigger.dev | run-engine/engine/index.ts | RunEngine |
| Завдання «Батьки/Дитина». | керований/контролер.ts | ManagedRunController |
| Система CR | координатор/checkpointer.ts | Checkpointer |
| Зберігання | координатор/викон.ц | Buildah |
Система CR на схемі відображається в контейнері Координатор — ось як ці компоненти розміщені на робочій віртуальній машині:
Worker VM
├── Supervisor container (apps/supervisor)
│ ├── Dequeues runs from the platform
│ ├── Creates task containers on demand
│ └── Coordinates with Coordinator for checkpointing
│
├── Coordinator container (apps/coordinator) ← "CR System" in the diagram
│ ├── Runs the Checkpointer (CRIU, Buildah)
│ ├── Has access to Docker daemon
│ └── Freezes task containers from the outside
│
└── Task container (ephemeral, one per run)
├── Controller (ManagedRunController) [entry point]
│ └── Signals when task is suspendable
│
└── Worker [child process, forked via IPC]
└── Your task code (task.run())У цій конструкції контролер сигналізує про готовність, а координатор запитує контрольну точку поза контейнером завдань. Контрольна точка включає контролер і робочий стан. Відновлення на іншому робочому файлі також вимагає сумісного середовища виконання та необхідного стану файлової системи.
Давайте пройдемося по кожному кроку.
Крок 1: Почніть виконання
Супервізор, який працює на робочій віртуальній машині, знімає з черги запуск із платформи та створює контейнер завдань через workloadManager.create(), передаючи змінні середовища, напр. TRIGGER_SUPERVISOR_API_DOMAIN, тому процес контролера всередині контейнера завдань знає, як отримати доступ до API робочого навантаження супервізора через HTTP. Контролер ManagedRunController — це основний процес Node.js усередині контейнера завдань — він використовує fork() до створити робочий дочірній процес вузла, який запускає ваш код завдання. Два процеси спілкуються через Node.js IPC.
Крок 2: Запуск дочірнього завдання
Коли ваш код викликає await childTask.triggerAndWait(...), відбувається дві речі:
- SDK, що працює всередині робочого процесу, здійснює виклик API безпосередньо до платформи Trigger.dev (актор «Trigger.dev» на схемі), минаючи контролер — робочий має власний HTTP-клієнт для платформи. Це ставить дочірнє завдання в чергу на виконання та створює точку очікування — запис у базі даних платформи, який говорить: «цей запуск очікує завершення цього дочірнього завдання».
- працівник сигналізує контролеру через IPC, що це підвісний — готовий до заморожування без втрати даних.
Батьки не чекають, поки дитина почне; він просто повідомляє платформі “виконайте це” та сигналізує “я можу отримати контрольну точку зараз”. Зверніть увагу на поділ: контролер керує життєвим циклом виконання (сигналізація про призупинення, керування моментальними знімками), але виклики API SDK для запуску завдань і створення точок очікування повністю його обходять — вони переходять прямо від робочої системи на платформу через HTTP. Для wait.for() точкою очікування є дата-час замість дочірнього завдання, але решта потоку ідентична.
Крок 3: Надішліть запит на знімок
Контролер усередині контейнера завдань викликає suspendRun() в 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 може потребувати повторного підключення. Код додатка повинен підтримувати цю можливість.
Моментальні знімки процесу та знімки файлової системи відрізняються. Дескриптори файлів стосуються файлів, необхідний вміст і метадані яких також мають бути доступними під час відновлення. Чи зберігаються записувані шари контейнера або тимчасові файли, залежить від середовища виконання та розгортання; не виводьте це лише за знімком пам’яті.
Більші обсяги пам’яті зазвичай означають більші контрольні точки. Розмір знімка не обов’язково дорівнює виділеній оперативній пам’яті: на нього впливають резидентні сторінки, стиснення, розріджені файли та параметри виконання. Великі контрольні точки можуть збільшити час зберігання, передачі та відновлення.
CRIU вимагає підтримки ядра. CRIU потребує спеціальних функцій ядра (простори імен, контрольні групи) і експериментального режиму Docker. У Kubernetes середовище виконання контейнера (CRI-O або container) має бути налаштовано для підтримки контрольних точок. Це доступно не скрізь, тому Trigger.dev має запасний варіант симуляції.
Той самий шаблон, на рівні віртуальної машини
CRIU працює на рівні процесу — він фіксує одне дерево процесів усередині контейнера. Але той самий шаблон контрольної точки/відновлення також працює на рівні віртуальної машини. Firecracker, менеджер microVM, що стоїть за AWS Lambda та Fly.io, може призупинити всю віртуальну машину, вивести її повну пам’ять і стан пристрою у файли та відновити з цих файлів пізніше — у повністю новому процесі Firecracker.
Я перевірив це на WSL2 з увімкненим KVM. Налаштування: Firecracker v1.12.0, rootfs Alpine Linux із лічильником оболонки, що збільшується щосекунди, і попередньо зібране ядро Linux. Після завантаження віртуальної машини та дозволу лічильнику досягти 20 я призупинив роботу віртуальної машини та створив знімок через REST API Firecracker:
# Pause
curl --unix-socket /tmp/firecracker.socket -X PATCH \
http://localhost/vm -H 'Content-Type: application/json' \
-d '{"state": "Paused"}'
# Snapshot
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 включають гостьову пам’ять і стан пристрою, але відновлення все ще має вимоги до процесора, версії та сумісності з хостом. AWS Lambda SnapStart використовує знімки ініціалізованих середовищ, щоб зменшити затримку запуску. Це відрізняється від відновлення робочого процесу на півдорозі виконання, і не обіцяє запуску менше 100 мс для кожної функції Lambda.
Елегантність підходу
Що мені найбільше приваблює в цьому дизайні, так це межа абстракції. З точки зору розробника:
await wait.for({ hours: 24 });Платформа координує створення знімків, зберігання, вивільнення ресурсів і відновлення за викликом очікування. Завдання зберігає звичайний асинхронний потік керування, але все ще потребує обробки зовнішніх з’єднань, побічних ефектів і повторних спроб.