Poprzedni wpis z tej serii opisywał pipeline automatyzujący bloga. Trigger.dev napędza dużą część tego pipeline’u. Obsługuje też checker do marzeń o terminie na prawo jazdy z rozwiązywaniem CAPTCHA, synchronizację danych z paneli słonecznych, demona ServiceNow i kilkanaście innych tasków. Wszystko na jednej płytce ARM64, która jednocześnie hostuje n8n, serwer Samba, Ollama, AdGuard Home i runner GitHub Actions.

Ten wpis opisuje, jak to działa, ile kosztuje w RAM i gdzie się sypie.

Sprzęt

6-rdzeniowy ARMv8, 7,4 GB RAM, 916 GB NVMe. Ubuntu 22.04.5 LTS, Docker 29.0.2. Płytka pobiera około 10W w idle i mieści się w kącie półki. Używam jej jako serwera homelab od jakiegoś czasu. Więcej szczegółów jest w poście o moim setupie z Claude Code.

Kluczowy fakt dla tego wpisu: to ARM64 (aarch64), a dokumentacja Trigger.dev niewiele mówi o obsłudze ARM. Obrazy są multi-arch. Działa. Ale tuning jest inny niż na maszynie x86 z 32 GB RAM.

Co tak naprawdę uruchamia Trigger.dev v4

Jedenaście kontenerów. Oto ile każdy z nich kosztuje w RAM w stanie stabilnym:

KontenerObrazRAM
webapptriggerdotdev/trigger.dev:v4-beta780 MB
supervisortriggerdotdev/supervisor:v4-beta109 MB
clickhousebitnamilegacy/clickhouse1,27 GB
miniobitnamilegacy/minio234 MB
postgrespostgres:14121 MB
browserlessghcr.io/browserless/chromium287 MB
buildx buildkitmoby/buildkit121 MB
electricelectricsql/electric:1.2.479 MB
registryregistry:212 MB
docker-proxytecnativa/docker-socket-proxy6 MB
redisredis:714 MB

Łącznie: około 3 GB. Na maszynie, która hostuje też wszystko inne, to większość dostępnego RAM. Jest ciasno.

Dwie rzeczy mają największe znaczenie: ClickHouse z 1,27 GB i webapp z 780 MB. Reszta jest do opanowania.

ClickHouse na maszynie z ograniczoną pamięcią

ClickHouse to magazyn historii uruchomień i metryk. Domyślnie konfiguruje się pod serwer z dużą ilością RAM. Na 7,4 GB maszynie z kilkunastoma serwisami słowo “dużo” jest względne.

Skończyłem na takich ustawieniach:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<clickhouse>
  <mark_cache_size>524288000</mark_cache_size>
  <max_concurrent_queries>4</max_concurrent_queries>
  <max_server_memory_usage_to_ram_ratio>0.15</max_server_memory_usage_to_ram_ratio>
  <background_pool_size>1</background_pool_size>
  <background_schedule_pool_size>1</background_schedule_pool_size>
  <merge_tree>
    <max_bytes_to_merge_at_max_space_in_pool>1073741824</max_bytes_to_merge_at_max_space_in_pool>
  </merge_tree>
  <profiles>
    <default>
      <max_block_size>8192</max_block_size>
      <max_threads>1</max_threads>
      <input_format_parallel_parsing>0</input_format_parallel_parsing>
      <output_format_parallel_formatting>0</output_format_parallel_formatting>
    </default>
  </profiles>
</clickhouse>

Najbardziej wpływowe ustawienia to max_server_memory_usage_to_ram_ratio (0.15 ogranicza ClickHouse do około 1,1 GB) i wyłączenie równoległego parsowania i formatowania. Gdy równoległe przetwarzanie jest włączone, ClickHouse chętnie tworzy wątki aż do momentu, gdy OOM killer kończy dyskusję.

Obraz Bitnami (bitnamilegacy/clickhouse) działa na ARM64. Oficjalny obraz ClickHouse też działa, ale struktura konfiguracji Bitnami jest łatwiejsza do nadpisania przez wolumeny Docker.

Sterta Node.js dla webappu

Kontener webapp to aplikacja Node.js. Bez jawnej konfiguracji domyślny limit sterty Node.js wynosi około 512 MB, co wystarczy, żeby webapp padł pod obciążeniem. Ta zmienna środowiskowa to naprawia:

1
2
environment:
  NODE_MAX_OLD_SPACE_SIZE: 3072

3 GB może wyglądać hojnie dla webappu obsługującego kilkadziesiąt tasków, ale od kiedy to ustawiłem, nie miałem ani jednego crasha OOM. Wcześniej zdarzały się mniej więcej raz w tygodniu podczas deploymentu.

Problem z Docker socket

Supervisor Trigger.dev musi rozmawiać z Dockerem, żeby uruchamiać runnery tasków. Naiwne podejście to bezpośrednie zamontowanie socketa:

1
2
volumes:
  - /var/run/docker.sock:/var/run/docker.sock

To daje kontenerowi pełny dostęp do Dockera, co oznacza, że może zrobić cokolwiek na hoście. Dla procesu wykonującego dowolny kod użytkownika to problem.

Lepsze podejście to socket proxy. tecnativa/docker-socket-proxy siedzi między supervisorem a właściwym socketem, a ty konfigurujesz, do których endpointów API może dotrzeć:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
docker-proxy:
  image: tecnativa/docker-socket-proxy
  environment:
    CONTAINERS: 1
    POST: 1
    IMAGES: 1
    NETWORKS: 1
    VOLUMES: 1
    INFO: 1
    TASKS: 1
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock:ro

Supervisor łączy się z proxy zamiast bezpośrednio z socketem. Nadal może to, co musi, ale promień rażenia jest mniejszy.

Bootstrap mode i worker token

Jeden szczegół setupu Trigger.dev v4, który nie jest oczywisty z dokumentacji: webapp potrzebuje worker tokenu, żeby przyjmować połączenia od supervisora. W deploymencie w chmurze generujesz go ręcznie. W self-hostedzie bootstrap mode robi to automatycznie.

Wzorzec to wspólny wolumin Docker między webappem i supervisorem. Webapp zapisuje token do pliku przy starcie, supervisor go odczytuje. Brak ręcznego generowania tokenów, brak problemu z kurą i jajkiem przy pierwszym deploymencie.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
volumes:
  trigger-bootstrap:

services:
  webapp:
    volumes:
      - trigger-bootstrap:/app/bootstrap
    environment:
      TRIGGER_BOOTSTRAP_ENABLED: "true"
      TRIGGER_BOOTSTRAP_DIR: "/app/bootstrap"

  supervisor:
    volumes:
      - trigger-bootstrap:/bootstrap
    environment:
      TRIGGER_WORKER_TOKEN_FILE: "/bootstrap/worker-token"

Lokalny registry

Kod tasków jest budowany w obrazy Docker. Te obrazy muszą gdzieś trafić. Opcje to zdalny registry (Docker Hub, GHCR) albo lokalny. Używam lokalnego registry na porcie 5000.

1
2
3
4
5
6
registry:
  image: registry:2
  ports:
    - "5000:5000"
  volumes:
    - registry-data:/var/lib/registry

Webapp i supervisor wskazują na localhost:5000. Nie ma opóźnień na push/pull i deploymenty nie potrzebują zewnętrznego dostępu do sieci. Rozwiązuje też problemy z cross-arch registry, bo build i runtime to ta sama maszyna.

Deployment tasków

Pipeline deploymentu to workflow GitHub Actions. Self-hosted runner jest na tej samej płytce co wszystko inne, więc buduje i deployuje natywnie na ARM64.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
name: Deploy Trigger.dev tasks
on:
  push:
    branches: [master]
    paths:
      - 'trigger/**'
      - 'trigger.config.ts'

jobs:
  deploy:
    runs-on: [self-hosted, Linux, ARM64]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx [email protected] deploy --env prod
        env:
          TRIGGER_API_URL: ${{ secrets.TRIGGER_API_URL }}
          TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}

trigger.config.ts używa rozszerzenia aptGet dla zależności systemowych i syncEnvVars do pobierania sekretów ze środowiska przy deploymencie. Jak komendy devopsowe wchodzą w interakcję z tym setupem opisałem w poście o komendach devopsowych. Zarówno /deploy, jak i /trigger-status owijają ten workflow.

Co tak naprawdę na tym działa

Aktualnie 16 tasków. Kilka to praktyczna automatyzacja:

  • foxess-pvoutput: synchronizuje dane z paneli słonecznych do PVOutput co 5 minut
  • servicenow-daemon: odpytuje instancję ServiceNow o nowe incydenty
  • check-appointments: monitoruje rządowy serwis rezerwacji o wolne terminy, z rozwiązywaniem CAPTCHA przez Browserless
  • Taski do komunikacji ze szkołą przez Librus (send-excuse, send-teacher-message)

Reszta to automatyzacja bloga. generate-cover wywołuje Gemini API, żeby generować okładki. news-digest agreguje wiadomości o AI w tygodniowe podsumowanie. notify-publish wysyła powiadomienie na kanał Telegram, gdy pojawia się nowy wpis. Post jak ten blog działa sam ma więcej szczegółów o tych taskach.

Co zrobiłbym inaczej

Uruchamianie 11 kontenerów na 7,4 GB obok wszystkiego innego jest wykonalne, ale zostawia mało miejsca na cokolwiek nowego. ClickHouse jest głównym ograniczeniem. Gdybym zaczynał od nowa, sprawdziłbym, czy Trigger.dev obsługuje alternatywny backend do metryk albo czy ClickHouse można przenieść na maszynę z większą ilością RAM.

Strona ARM64 działała bez problemów. Obrazy multi-arch działają, a running runner na tej samej płytce oznacza, że nigdy nie muszę myśleć o cross-kompilacji. To wyszło lepiej, niż się spodziewałem.

Jedna rzecz, której nie rozwiązałem: retencja logów. ClickHouse domyślnie przechowuje historię uruchomień w nieskończoność. Powinienem ustawić politykę retencji zanim NVMe się zapełni. To problem dla mnie z przyszłości.