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 zawiesza cały kontener, przestaje naliczać opłaty za moc obliczeniową i wznawia wykonanie dokładnie w tym punkcie, w którym się zatrzymało — czy to godzinę później, czy gdy zadanie potomne się zakończy. Bez serializacji, bez zarządzania stanem, bez ponownego wykonywania wcześniejszych kroków.

Trigger.dev nazywa to systemem Checkpoint-Resume. Podczas oczekiwania na podzadanie albo zaprogramowaną pauzę system zapisuje punkt kontrolny całego stanu zadania — pamięć, rejestry procesora, otwarte deskryptory plików — i zwalnia wszystkie zasoby. Gdy oczekiwanie się kończy albo podzadanie zostaje ukończone, punkt kontrolny jest ładowany do nowego środowiska wykonawczego, przywracając zadanie do dokładnie tego stanu sprzed zawieszenia. Zadanie wznawia się w miejscu, w którym przerwało, a wyniki podzadania są bezszwowo wpięte.

Ciekawe jest to, że w gruncie rzeczy jest to asynchroniczna współbieżność zastosowana na poziomie infrastruktury. W JavaScripcie await zawiesza funkcję na czas wywołania sieciowego, zwalniając pętlę zdarzeń do innej pracy. Korutyny w Pythonie robią to samo. Trigger.dev bierze dokładnie ten sam wzorzec — wstrzymaj wykonanie, zwolnij zasoby, wznów gdy będzie gotowe — ale stosuje go do całych kontenerów zamiast do funkcji. await w kodzie Twojego zadania dosłownie zawiesza maszynę, a gdy wykonanie ruszy dalej, może to być już zupełnie inna maszyna wirtualna.

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 };
  },
});

Sedno problemu polega na tym, że funkcje serverless mają twarde limity czasu wykonania. AWS Lambda kończy po 15 minutach, GCP Cloud Functions po 60. Proces trwający dobę po prostu nie zmieści się w jednym wywołaniu funkcji. Standardowa rada to sięgnięcie po zupełnie inny model obliczeń — kontenery na ECS/GKE, maszyny wirtualne albo usługi wsadowe — ale wtedy tracisz prostotę „po prostu napisz funkcję”.

Nawet jeśli mieścisz się w limitach czasu, masz trzy kiepskie opcje:

  1. 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
  2. Podzielić na wiele zadań — rozbić proces na chargeCustomer, wyzwalacz zaplanowany i sendReceipt, 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
  3. 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ść

Większość silników procesów obsługuje długie oczekiwania, serializując stan do bazy danych. Oto jak wypada na tym tle mechanizm punktów kontrolnych:

Serializacja do bazy danych (Flowable, Temporal itp.):

  • To Ty (albo framework) decydujesz, co zapisać
  • Stan musi być serializowalny — domknięcia, uchwyty plików, otwarte połączenia przepadają
  • Przywracanie odtwarza proces i nawadnia go zapisanym stanem
  • Lekkie składowanie (kilka kilobajtów zserializowanych zmiennych)
  • Framework musi rozumieć środowisko uruchomieniowe Twojego języka

Punkty kontrolne kontenera (Trigger.dev):

  • CRIU zapisuje wszystko automatycznie — nie musisz o tym myśleć
  • Nic nie przepada — pamięć, stos wywołań, zmienne lokalne, domknięcia są zachowane
  • Przywracanie jest dokładne — proces nie wie, że go zapisano
  • Ciężkie składowanie (obraz pamięci kontenera, potencjalnie setki megabajtów)
  • Niezależne od języka — działa z dowolnym procesem uruchomionym w kontenerze

Kompromis jest jasny: punkty kontrolne są prostsze dla programisty (zerowy ciężar serializacji), ale droższe pod względem składowania i opóźnienia przywracania. Serializacja do bazy jest lekka, ale wymaga, by framework (albo programista) jawnie zarządzał tym, co zostaje zapisane.

Zakład Trigger.dev polega na tym, że prostota dla programisty jest warta kosztu infrastruktury. Piszesz zwykłą funkcję async z wywołaniami await, a resztą zajmuje się platforma. Żadnego DSL-a do procesów, żadnych klas stanu, żadnych interfejsów serializacji.

CRIU: technologia u podstaw

Fundamentem jest CRIU — Checkpoint/Restore In Userspace. CRIU to linuksowe narzędzie, które potrafi zamrozić działający proces (albo drzewo procesów), zapisać jego kompletny stan na dysk i przywrócić go później. „Kompletny stan” znaczy wszystko: strony pamięci, zawartość rejestrów, deskryptory plików, stan gniazd, obsługę sygnałów — całość.

CRIU działa na poziomie systemu operacyjnego. Nie wie i nie obchodzi go, w jakim języku napisany jest Twój kod, jakie zmienne masz w zasięgu ani jak wygląda stos wywołań. Przechwytuje surowe strony pamięci i stan jądra. To oznacza, że zapisać można dowolny program — Node.js, Python, C++, cokolwiek działa w kontenerze.

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

CRIU w działaniu można zobaczyć na prostym liczniku w Pythonie wewnątrz kontenera Dockera. Uruchom kontener z zainstalowanym CRIU (--privileged jest potrzebne, by CRIU miało dostęp do pamięci procesu):

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.sh

Wynik:

--- 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

Punkt 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.

Zwróć uwagę, że kontener potrzebuje --privileged, by CRIU miało dostęp do pamięci procesu. Na produkcji Trigger.dev nie uruchamia CRIU wewnątrz kontenera zadania — nadzorca wywołuje docker checkpoint create albo crictl checkpoint z zewnątrz, a te uruchamiają CRIU na całym kontenerze.

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).

Oto kluczowe pliki źródłowe stojące za każdym z aktorów:

Aktor na diagramieŹródłoKlasa
Trigger.devrun-engine/engine/index.tsRunEngine
Zadanie rodzica/potomnemanaged/controller.tsManagedRunController
System CRcoordinator/checkpointer.tsCheckpointer
Składowaniecoordinator/exec.tsBuildah

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())

Kontener zadania nie może zapisać punktu kontrolnego samego siebie. Kontroler sygnalizuje, że można go zawiesić, nadzorca informuje koordynatora, a koordynator zamraża kontener z zewnątrz. Gdy CRIU zapisuje punkt kontrolny kontenera, przechwytuje oba procesy i kanał IPC — przy przywracaniu oba wznawiają się jednocześnie. To właśnie ten rozdział umożliwia przywracanie na innej maszynie: obraz punktu kontrolnego trafia do rejestru, a dowolny węzeł może go pobrać i przywrócić.

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 każą środowisku uruchomieniowemu kontenerów wywołać CRIU, które zamraża wszystkie procesy, zrzuca wszystkie strony pamięci na dysk i zapisuje stan jądra (deskryptory plików, gniazda, timery).

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>

Wynikiem jest standardowy obraz kontenera OCI wypchnięty do rejestru kontenerów (aktor „Składowanie” na diagramie — ten sam rodzaj rejestru, którego używa się dla obrazów Dockera). Dowolny węzeł w klastrze może go pobrać i przywrócić punkt kontrolny.

Krok 5: Zwolnienie zasobów

Gdy obraz punktu kontrolnego jest już zapisany, platforma:

  1. Aktualizuje status przebiegu na WAITING_TO_RESUME w bazie danych platformy (tej samej, w której w kroku 2 powstał punkt oczekiwania)
  2. Zapisuje rekord TaskRunCheckpoint (typ, lokalizacja, referencja do obrazu)
  3. 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 znaleźć się na innym fizycznym węźle niż pierwotny — obraz punktu kontrolnego leży w rejestrze, a pobrać go może dowolny węzeł.

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 sieciowe nie przeżywają zawieszenia. Gniazda TCP są zapisywane przez CRIU, ale zanim kontener zostanie przywrócony (minuty, godziny albo dni później), druga strona dawno zamknęła połączenie. Wszelkie otwarte połączenia HTTP, połączenia z bazą danych czy WebSockety będą nieaktualne. Środowisko uruchomieniowe Trigger.dev radzi sobie z tym dla własnych połączeń (odtwarza WebSocket do nadzorcy i klienta HTTP), ale jeśli kod Twojego zadania trzyma otwarte połączenia przez wait, przy wznowieniu padną.

Zmiany w systemie plików są efemeryczne. Punkt kontrolny przechwytuje pamięć, nie dysk. Jeśli Twoje zadanie zapisało pliki tymczasowe przed oczekiwaniem, po przywróceniu ich nie będzie (kontener działa na świeżym systemie plików z obrazu). Projektuj zadania tak, by były samowystarczalne po obu stronach granicy oczekiwania.

Rozmiar punktu kontrolnego rośnie wraz ze zużyciem pamięci. Kontener używający 2 GB RAM-u daje obraz punktu kontrolnego o rozmiarze 2 GB. Dla zadań z dużymi zbiorami danych w pamięci oznacza to znaczne składowanie i czas transferu. Przycinanie MAXLEN ~ przy wypychaniu obrazu punktu kontrolnego pomaga, ale duże punkty kontrolne z natury wolniej się tworzy i przywraca.

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 Firecrackera są cięższe (zapisujesz pamięć całej VM, nie jednego procesu), ale są też bardziej przenośne — żadnych wymagań co do funkcji jądra poza KVM, żadnych eksperymentalnych flag, żadnej konfiguracji środowiska uruchomieniowego kontenerów. Właśnie tak Lambda osiąga zimne starty poniżej 100 ms: wcześniej wykonać migawkę VM z załadowaną funkcją, a przy wywołaniu przywrócić ją z migawki.

Elegancja tego podejścia

Najbardziej przekonuje mnie w tym projekcie granica abstrakcji. Z perspektywy programisty:

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

I tyle. Jedna linia. A za nią: CRIU zamraża wszystkie procesy, strony pamięci są zrzucane na dysk, Buildah opakowuje je w obraz OCI, obraz trafia do rejestru, sloty współbieżności zostają zwolnione, automat stanów śledzi cykl życia migawki, a wiele godzin później z obrazu punktu kontrolnego startuje nowy kontener — potencjalnie na innej maszynie — proces zostaje przywrócony, połączenia odtworzone, a await się rozwiązuje.

Cała złożoność pozostaje po drugiej stronie granicy platformy. Twoje zadanie to po prostu funkcja asynchroniczna.