Async/await dla kontenerów: jak Trigger.dev zawiesza i wznawia działające zadania
Szukałem platformy do uruchamiania procesów opartych na AI — łańcuchów LLM, potoków danych, pętli agentowych — bez budowania całej infrastruktury kolejek, ponowień i harmonogramowania samodzielnie. Tak trafiłem na Trigger.dev, otwartoźródłową platformę do uruchamiania zadań w tle w TypeScripcie. To fajny projekt, ale szczególnie jedna funkcja przykuła moją uwagę.
Jeśli Twoje zadanie wywołuje wait.for() albo czeka na zadanie potomne przez triggerAndWait():
export const myTask = task({
id: "my-task",
run: async () => {
// Tutaj kontener zostaje zawieszony — za tę godzinę nie płacisz nic
await wait.for({ hours: 1 });
// Kontener jest zawieszony, gdy zadanie potomne działa we własnym kontenerze
const result = await childTask.triggerAndWait({ data: "some data" });
},
});platforma może zapisać punkt kontrolny kontenera, zwolnić zasoby obliczeniowe i odtworzyć go po zakończeniu oczekiwania. Nie musisz sam serializować zmiennych lokalnych. Dotyczy to obsługiwanych operacji oczekiwania, a nie każdego await w JavaScripcie; krótkie timery mogą nie tworzyć punktu kontrolnego.
Trigger.dev nazywa to Checkpoint-Resume System. Migawka zachowuje stan procesu, w tym pamięć i rejestry CPU. Podczas wstrzymania obliczeń nadal potrzebne są przechowywanie i orkiestracja, a usługi zewnętrzne nie cofają się razem z procesem.
Analogia do funkcji asynchronicznych jest użyteczna: przerwij na czas oczekiwania, potem kontynuuj. Różnica dotyczy zwalnianych zasobów. Zwykłe await pozostawia proces uruchomiony; obsługiwane oczekiwanie Trigger.dev pozwala platformie wstrzymać kontener i odtworzyć go na zgodnym workerze.
Brzmiało to niemal zbyt dobrze, żeby było prawdziwe, więc poprosiłem Claude’a o zbadanie kodu źródłowego, żeby zrozumieć, jak to naprawdę działa. Odpowiedź obejmuje CRIU (Checkpoint/Restore In Userspace), eksperymentalne API checkpointów Dockera, Buildah do tworzenia obrazów OCI oraz starannie zaprojektowany automat stanów, który to wszystko koordynuje.
W tym artykule chcę się podzielić tym, co znalazłem.
Problem: długie oczekiwania w zadaniach serverless
Najpierw zrozummy, gdzie punkty kontrolne mogą być potrzebne. Rozważmy zadanie, które przetwarza płatność, czeka na potwierdzenie, a następnie wysyła paragon:
import { task, wait } from "@trigger.dev/sdk";
export const processPayment = task({
id: "process-payment",
run: async (payload) => {
const charge = await chargeCustomer(payload);
// Kontener rodzica jest zawieszony, gdy getConfirmation
// działa we własnym kontenerze.
// Może to potrwać godziny albo dni — za oczekiwanie nie płacisz
const confirmation = await getConfirmation.triggerAndWait({
chargeId: charge.id,
});
await sendReceipt(charge, confirmation);
return { success: true };
},
});Wielogodzinne oczekiwanie może przekroczyć limit czasu pojedynczego wywołania funkcji. Utrzymywanie workera przez cały ten czas również kosztuje.
Typowe sposoby obsługi takiego oczekiwania to:
- Zostawić kontener działający, dopóki nie nadejdzie potwierdzenie — płacisz za moc obliczeniową przez cały ten czas, choć zadanie rodzica nic nie robi
- Podzielić na wiele zadań — rozbić proces na
chargeCustomer, wyzwalacz zaplanowany isendReceipt, tracąc prostotę jednej funkcji. Orkiestratory w rodzaju AWS Step Functions czy Google Cloud Workflows mogą pomóc, ale teraz debugujesz automaty stanów zamiast funkcji asynchronicznych - Serializować stan do bazy danych — gdzieś zapisać
charge, zaplanować zadanie następcze, zdeserializować przy wznowieniu — i oto budujesz silnik procesów
Odpowiedź Trigger.dev to opcja 4: zamrozić pamięć kontenera na dysk, wyłączyć go i przywrócić później.
Gdy wywołujesz triggerAndWait, zadanie potomne rusza w osobnym kontenerze, a rodzic przechodzi przez punkt kontrolny i zostaje zawieszony — zwalniając swoją moc obliczeniową i współbieżność — aż potomek się zakończy. Rodzic wznawia się z wartością zwróconą przez potomka, tak jak przy zwykłym await. Ten sam mechanizm uruchamia się przy oczekiwaniach czasowych w rodzaju await wait.for({ hours: 24 }).
Jak to się ma do tradycyjnych podejść
Silniki workflow zachowują postęp na różne sposoby. Można zapisywać jawny stan aplikacji albo odtwarzać historię zdarzeń. Żadne z tych rozwiązań nie jest tym samym co zapis obrazu procesu.
Odtwarzanie historii zdarzeń, stosowane przez Temporal, rekonstruuje stan przez wykonanie deterministycznego kodu workflow na zapisanych zdarzeniach. Zakończone aktywności mają zapisane wyniki zamiast ponownie wykonywanych efektów ubocznych. Nie wymaga to serializacji dowolnych domknięć ani otwartych gniazd.
Punkty kontrolne kontenera zapisują obsługiwany stan procesu, w tym pamięć zmiennych lokalnych i domknięć. Pozwalają uniknąć odtwarzania kodu, lecz wymagają zgodnego systemu i runtime’u. Pliki, gniazda oraz zasoby zewnętrzne nadal wymagają uwagi.
Kompromis dotyczy ograniczeń odtwarzania i infrastruktury migawek: punkty kontrolne ograniczają ręczną obsługę stanu, lecz wymagają przechowywania, transferu i zgodności środowisk.
CRIU: technologia u podstaw
CRIU to narzędzie Linuksa zapisujące i odtwarzające obsługiwany stan procesu: strony pamięci, rejestry, deskryptory plików oraz część powiązanego stanu jądra. Może działać na drzewie procesów.
CRIU działa poniżej runtime’u języka, więc obsługuje programy napisane w wielu językach. Nie oznacza to jednak, że można zapisać każdy proces: urządzenia, funkcje jądra, przestrzenie nazw i runtime ograniczają możliwości odtworzenia.
Docker ma eksperymentalne wsparcie dla CRIU przez docker checkpoint create, a Kubernetes obsługuje je przez CRI (Container Runtime Interface) poleceniem crictl checkpoint. Trigger.dev korzysta z obu, zależnie od trybu wdrożenia. Gdy CRIU jest w ogóle niedostępne — brak binarki, nieobsługiwane jądro albo niewłączone eksperymentalne funkcje Dockera — Trigger.dev cofa się do docker pause, które zawiesza kontener, ale nie przechwytuje stanu. Proces trwa dalej, ale jeśli kontener padnie, przebieg jest stracony. To rozwiązanie awaryjne istnieje dla środowisk deweloperskich, gdzie konfigurowanie CRIU jest niepraktyczne.
Spróbuj sam
Demo uruchamia CRIU w uprzywilejowanym kontenerze Dockera. Wymaga zgodnego jądra Linuksa i konfiguracji CRIU; samo --privileged nie gwarantuje działania.
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'Skopiuj skrypt licznika — po prostu zwiększa liczbę i co sekundę zapisuje ją do pliku:
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'Skopiuj skrypt demonstracyjny — uruchamia licznik, zapisuje punkt kontrolny, a potem go przywraca:
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'Uruchom demo:
docker exec criu-demo /demo.shWynik:
--- 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 = 11Punkt kontrolny został zapisany po wypisaniu wartości 5, proces zabito, a potem CRIU przywróciło go z punktu kontrolnego — i licznik liczył dalej, jakby nic się nie stało. Zmienna count siedziała na stercie Pythona, a CRIU przechwyciło i przywróciło cały stan pamięci. To dokładnie ten mechanizm, którego używa Trigger.dev, tylko opakowany w znacznie więcej orkiestracji.
Demo zapisuje proces od środka kontenera. W omawianej dalej implementacji koordynator z zewnątrz prosi runtime o punkt kontrolny zadania.
Jak działa przepływ punktu kontrolnego
Oto pełny przepływ checkpoint-resume dla zadania rodzica, które wyzwala zadanie potomne (na podstawie diagramu z dokumentacji Trigger.dev):
Diagram pokazuje przepływ dla triggerAndWait, ale ten sam mechanizm dotyczy wait.for() — jedyna różnica polega na tym, co rozwiązuje punkt oczekiwania (timer albo zakończenie zadania potomnego).
Poniższe linki prowadzą do konkretnych rewizji wykorzystanych w opisie. Przedstawiają tę implementację, a nie gwarancję identycznej struktury we współczesnych wdrożeniach.
| Aktor na diagramie | Źródło | Klasa |
|---|---|---|
| Trigger.dev | run-engine/engine/index.ts | RunEngine |
| Zadanie rodzica/potomne | managed/controller.ts | ManagedRunController |
| System CR | coordinator/checkpointer.ts | Checkpointer |
| Składowanie | coordinator/exec.ts | Buildah |
System CR z diagramu odpowiada kontenerowi Coordinator — oto jak te komponenty są rozłożone na roboczej maszynie wirtualnej:
Robocza VM
├── Kontener Supervisor (apps/supervisor)
│ ├── Pobiera przebiegi z kolejki platformy
│ ├── Tworzy kontenery zadań na żądanie
│ └── Uzgadnia tworzenie punktów kontrolnych z Coordinatorem
│
├── Kontener Coordinator (apps/coordinator) ← „system CR" na diagramie
│ ├── Uruchamia Checkpointer (CRIU, Buildah)
│ ├── Ma dostęp do demona Dockera
│ └── Zamraża kontenery zadań z zewnątrz
│
└── Kontener zadania (efemeryczny, jeden na przebieg)
├── Controller (ManagedRunController) [punkt wejścia]
│ └── Sygnalizuje, kiedy zadanie można zawiesić
│
└── Worker [proces potomny, uruchomiony przez IPC]
└── Kod Twojego zadania (task.run())W tym projekcie kontroler sygnalizuje gotowość, a koordynator żąda punktu kontrolnego spoza kontenera zadania. Zapis obejmuje stan kontrolera i workera. Odtworzenie na innym węźle wymaga też zgodnego runtime’u i potrzebnego stanu systemu plików.
Przejdźmy przez każdy krok.
Krok 1: Rozpoczęcie wykonania
Nadzorca działający na roboczej VM pobiera przebieg z kolejki platformy i tworzy kontener zadania przez workloadManager.create(), przekazując zmienne środowiskowe, np. TRIGGER_SUPERVISOR_API_DOMAIN, żeby proces kontrolera wewnątrz kontenera zadania wiedział, jak dosięgnąć Workload API nadzorcy po HTTP. Kontroler ManagedRunController to główny proces Node.js wewnątrz kontenera zadania — używa fork() z Node’a, by uruchomić proces potomny worker, który wykonuje kod Twojego zadania. Oba procesy komunikują się przez IPC Node.js.
Krok 2: Wyzwolenie zadania potomnego
Gdy Twój kod wywołuje await childTask.triggerAndWait(...), dzieją się dwie rzeczy:
- SDK działający wewnątrz procesu worker wykonuje wywołanie API bezpośrednio do platformy Trigger.dev (aktor „Trigger.dev” na diagramie), omijając kontroler — worker ma własnego klienta HTTP do platformy. To kolejkuje zadanie potomne do wykonania i tworzy punkt oczekiwania (waitpoint) — wpis w bazie danych platformy mówiący: „ten przebieg czeka na zakończenie tego zadania potomnego”.
- worker sygnalizuje kontrolerowi przez IPC, że można go zawiesić — czyli zamrozić bez utraty danych.
Rodzic nie czeka, aż potomek wystartuje; po prostu mówi platformie „uruchom to” i sygnalizuje „można mnie teraz zapisać”. Zwróć uwagę na podział: kontroler zajmuje się cyklem życia wykonania (sygnalizowaniem gotowości do zawieszenia, zarządzaniem migawkami), ale wywołania API z SDK, które wyzwalają zadania i tworzą punkty oczekiwania, omijają go całkowicie — idą prosto od workera do platformy po HTTP. Dla wait.for() punktem oczekiwania jest data i godzina zamiast zadania potomnego, ale reszta przepływu jest identyczna.
Krok 3: Żądanie migawki
Kontroler wewnątrz kontenera zadania wywołuje suspendRun() w Workload API nadzorcy (przez połączenie HTTP z kroku 1). Nadzorca deleguje zadanie do koordynatora przez CheckpointClient. Koordynator wywołuje CRIU z zewnątrz, żeby zamrozić kontener zadania. Logika punktu kontrolnego mieszka w checkpointAndPush() i ma dwa tryby:
Tryb Docker (lokalnie/deweloperski):
docker checkpoint create --leave-running <container-name> <checkpoint-name>Tryb Kubernetes (produkcyjny):
crictl checkpoint --export=/checkpoints/<identifier>.tar <container-id>Oba polecenia żądają punktu kontrolnego procesu. Przykład Dockera używa --leave-running, więc samo wykonanie migawki nie zatrzymuje trwale oryginalnego kontenera; wstrzymanie i sprzątanie obsługuje orkiestracja.
Krok 4: Zapisanie migawki
Na produkcji (tryb Kubernetes) punkt kontrolny jest eksportowany jako archiwum tar. Koordynator opakowuje je w obraz kontenera OCI za pomocą klasy Buildah i wypycha do rejestru:
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>Rejestr OCI służy do przesyłania punktu kontrolnego. Odtworzenie wymaga zgodnego runtime’u z obsługą checkpointów; opakowanie w obraz OCI nie czyni go zwykłym obrazem uruchamialnym na dowolnym węźle.
Krok 5: Zwolnienie zasobów
Gdy obraz punktu kontrolnego jest już zapisany, platforma:
- Aktualizuje status przebiegu na
WAITING_TO_RESUMEw bazie danych platformy (tej samej, w której w kroku 2 powstał punkt oczekiwania) - Zapisuje rekord TaskRunCheckpoint (typ, lokalizacja, referencja do obrazu)
- Zwalnia całą współbieżność dla tego przebiegu
Jeśli masz kolejkę z concurrencyLimit: 5 i trzy zadania są zawieszone, te trzy sloty zostają zwolnione. Zawieszone zadania nie zużywają ani mocy obliczeniowej, ani współbieżności.
Krok 6: Zakończenie zadania potomnego
Zadanie potomne działa we własnym kontenerze. Gdy się kończy, jego kontroler wywołuje completeRunAttempt() u nadzorcy, który raportuje wynik platformie. Platforma rozwiązuje punkt oczekiwania rodzica utworzony w kroku 2, co uruchamia przepływ przywracania. Dla wait.for() ten krok zastępuje wygaśnięcie timera, które rozwiązuje punkt oczekiwania w ten sam sposób.
Krok 7: Pobranie migawki i przywrócenie stanu
Platforma żąda punktu kontrolnego od systemu CR, który pobiera obraz migawki ze składowania. Z obrazu punktu kontrolnego startuje nowy kontener — CRIU przywraca wszystkie procesy do ich dokładnego stanu pamięci.
Przywrócony kontroler wykrywa, że został przywrócony, i wywołuje continueRunExecution():
POST /api/runs/{runId}/continue
Body: { snapshotId: "...", workerId: "...", runnerId: "..." }Kontener może zostać odtworzony na innym zgodnym workerze. Wspólny rejestr zapewnia dostęp do artefaktu; zgodność hosta i runtime’u decyduje o powodzeniu.
Krok 8: Wznowienie i zakończenie wykonania
Backend weryfikuje migawkę, przestawia przebieg na EXECUTING, a zadanie kontynuuje od linii po await. Z perspektywy Twojego kodu nic się nie wydarzyło — await rozwiązał się wartością zwróconą przez zadanie potomne i wykonanie toczy się dalej normalnie.
Co może pójść nie tak
Punkty kontrolne to nie magia. Są przypadki brzegowe:
Połączenia mogą stracić ważność. Zachowanie lokalnego stanu gniazda nie utrzymuje zdalnego końca połączenia. Po długim oczekiwaniu połączenia z bazą, HTTP czy WebSocket mogą wymagać odnowienia. Kod aplikacji powinien to obsłużyć.
Migawka procesu i systemu plików to różne rzeczy. Deskryptory wskazują pliki, których potrzebna treść i metadane muszą być dostępne przy odtworzeniu. Zachowanie zapisywalnych warstw i plików tymczasowych zależy od runtime’u i wdrożenia, a nie od samej migawki pamięci.
Większe zużycie pamięci zwykle oznacza większy checkpoint. Rozmiar nie musi być równy przydzielonemu RAM: wpływają na niego strony rezydentne, kompresja, pliki rzadkie i opcje runtime’u. Duże zapisy zwiększają koszt przechowywania oraz czas przesyłania i odtwarzania.
CRIU wymaga wsparcia jądra. CRIU potrzebuje określonych funkcji jądra (przestrzenie nazw, cgroups) oraz trybu eksperymentalnego Dockera. W Kubernetesie środowisko uruchomieniowe kontenerów (CRI-O albo containerd) musi być skonfigurowane do obsługi punktów kontrolnych. Nie wszędzie jest to dostępne — i dlatego Trigger.dev ma awaryjną symulację.
Ten sam wzorzec, na poziomie maszyn wirtualnych
CRIU działa na poziomie procesów — przechwytuje pojedyncze drzewo procesów wewnątrz kontenera. Ale ten sam wzorzec checkpoint/restore działa też na poziomie maszyn wirtualnych. Firecracker, menedżer mikro-VM stojący za AWS Lambda i Fly.io, potrafi wstrzymać całą maszynę wirtualną, zrzucić jej pełną pamięć i stan urządzeń do plików, a później przywrócić z tych plików — już w zupełnie nowym procesie Firecrackera.
Przetestowałem to na WSL2 z włączonym KVM. Konfiguracja: Firecracker v1.12.0, rootfs z Alpine Linuksem i shellowym licznikiem zwiększającym się co sekundę oraz gotowe jądro Linuksa. Po uruchomieniu VM i doprowadzeniu licznika do 20 wstrzymałem maszynę i utworzyłem migawkę przez REST API Firecrackera:
# Wstrzymanie
curl --unix-socket /tmp/firecracker.socket -X PATCH \
http://localhost/vm -H 'Content-Type: application/json' \
-d '{"state": "Paused"}'
# Migawka
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"}'Potem całkowicie zabiłem proces Firecrackera, uruchomiłem świeży i wczytałem migawkę:
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}'Licznik wznowił od 21. Przywracanie zajęło ~29 ms.
Migawki Firecracker obejmują pamięć gościa i stan urządzeń, ale odtworzenie nadal ma wymagania zgodności CPU, wersji i hosta. AWS Lambda SnapStart używa migawek zainicjalizowanych środowisk, aby ograniczyć opóźnienie startu. To co innego niż wznowienie workflow w połowie pracy i nie gwarantuje startu każdej funkcji poniżej 100 ms.
Elegancja tego podejścia
Najbardziej przekonuje mnie w tym projekcie granica abstrakcji. Z perspektywy programisty:
await wait.for({ hours: 24 });Za tym wywołaniem platforma koordynuje tworzenie i zapis migawki, zwalnianie zasobów oraz odtworzenie. Zadanie zachowuje zwykły asynchroniczny przepływ, ale nadal musi obsługiwać połączenia zewnętrzne, efekty uboczne i ponowienia.