Skip to main content

Fallback runnerów CI (GitHub-hosted → self-hosted)

Dokument opisuje, dlaczego pipeline CI potrafi sam przełączyć się na własny serwer, jak z tego korzystać na co dzień i jak jest to zbudowane.

Po co to jest​

8 września 2026 potwierdziliśmy pomiarem, że runnery GitHub-hosted w tym repozytorium są zablokowane od 18 sierpnia z komunikatem:

The job was not started because recent account payments have failed or your spending limit needs to be increased.

Joby kończyły się po dwóch sekundach z pustą listą kroków, więc każdy PR miał czerwone checki niezależnie od jakości zmiany. W tym samym runie job na runnerze self-hosted (contabo-vmi3510396) wykonał się normalnie i został rozliczony na 0 ms.

Blokada rozliczeń dotyczy wyłącznie runnerów GitHub-hosted. Runnery self-hosted działają dalej i nie zużywają minut. Na tym opiera się ten mechanizm.

Jak to działa z perspektywy użytkownika​

Nie musisz robić nic. Przy każdym PR-ze pipeline sam wybiera, gdzie się wykonać:

  • GitHub działa → wszystko leci na ubuntu-latest, tak jak wcześniej,
  • GitHub jest zablokowany → wszystko automatycznie ląduje na naszym serwerze,
  • GitHub wraca do działania → pipeline wraca na ubuntu-latest przy następnym runie.

Przełączenie jest bezstanowe i działa w obie strony bez ingerencji człowieka.

Na czym poszedł konkretny job, sprawdzisz w logu joba w sekcji „Set up job" (Runner name:) albo poleceniem:

gh run view <RUN_ID> --json jobs --jq '.jobs[] | "\(.name) → \(.runner_name)"'

Wymuszenie trybu​

Zmienna repozytorium CI_RUNNER_MODE nadpisuje automatykę:

WartośćZachowanie
brak zmiennej (domyślnie)automat: najpierw GitHub, w razie blokady serwer
self-hostedpomija sondę i od razu kieruje wszystko na serwer

Ustawienie na self-hosted oszczędza jedną minutę GitHuba na każdy run i przydaje się, gdy limit minut jest na wyczerpaniu, a nie całkiem zablokowany.

gh variable set CI_RUNNER_MODE --body self-hosted
gh variable delete CI_RUNNER_MODE # powrót do automatu

Jak to jest zbudowane​

Mechanizm opiera się na tym, że runs-on przyjmuje konteksty github, needs, strategy, matrix, vars, inputs, więc etykieta runnera może być wyrażeniem, oraz na tym, że zablokowany job nie wykonuje ani jednego kroku — czyli nie ustawia żadnego outputu.

Sonda to jeden job kosztujący kilka sekund:

runner_probe:
if: vars.CI_RUNNER_MODE != 'self-hosted'
runs-on: ubuntu-latest
continue-on-error: true
outputs:
label: ${{ steps.probe.outputs.label }}
steps:
- id: probe
run: echo "label=ubuntu-latest" >> "$GITHUB_OUTPUT"

Każdy job roboczy czyta jej wynik, a pusty output oznacza brak dostępu do GitHub-hosted:

needs: runner_probe
if: ${{ !cancelled() }}
runs-on: ${{ needs.runner_probe.outputs.label || 'self-hosted' }}

continue-on-error: true sprawia, że nieudana sonda nie maluje całego runu na czerwono, a !cancelled() pozwala jobom ruszyć również wtedy, gdy sonda padła lub została pominięta.

Objęte workflowy: ci-pipeline.yml, test-pipeline.yml, check-migrations.yml — czyli te, które bramkują PR-y. Poza zakresem zostają workflowy deployujące (preview-deployment.yml, deploy-docs.yml, deploy-android-pipeline.yml, release-manager.yml, nightly-merge*.yml): wszystkie chodzą na ubuntu-latest, niosą sekrety produkcyjne i przy zablokowanych rozliczeniach i tak się nie wykonują. Przenoszenie ich na runner stojący obok produkcyjnej bazy to osobna decyzja. Wydania iOS nie dotyczy to w ogóle — TestFlight został zastąpiony lokalnym skryptem (yarn ios:release), w repo nie ma workflowu iOS.

Zabezpieczenie zasobów serwera​

Runner na współdzielonym serwerze musi mieć twardy sufit, inaczej next build potrafi zająć całą pamięć i wywrócić resztę maszyny. Odpowiada za to slice systemd instalowany skryptem:

sudo scripts/ci/install-runner-limits.sh

Skrypt wykrywa wszystkie usługi actions.runner.*, wkłada je do slice'a github-runner.slice i dobiera limity na podstawie profilu hosta:

Profil hostaWykrywany poMemoryMaxCPUQuota
współdzielony z produkcjądziałające kontenery40% RAM75% rdzeni
dedykowany runnerbrak działających kontenerów85% RAMwszystkie rdzenie

Docelowym hostem runnerów jest Contabo (vmi3510396, 6 rdzeni, 12 GB) — maszyna dedykowana wyłącznie CI. Skrypt wykrywa na niej profil dedykowany i ustawia MemoryMax=10166M oraz CPUQuota=600%, czyli wszystkie rdzenie.

Runnery świadomie nie stoją na VPS produkcyjnym, mimo że tamten sprzęt jest 2,54× szybszy na rdzeń. Powód jest w pomiarach niżej: buildy CI i buildy obrazów w Coolify biją w ten sam dysk, a na tamtym hoście nie da się tego zarbitrażować (brak BFQ). Cena tej decyzji to wolniejsze pojedyncze buildy; zysk to produkcja, której CI nie dotyka.

Wartości można nadpisać: --memory-max, --cpu-quota, --cpu-weight, --io-weight, --tasks-max. --dry-run wypisuje jednostki bez instalowania.

Poza limitami skrypt ustawia trzy rzeczy, które w praktyce decydują o tym, czy produkcja odczuje build:

  • CPUWeight=20 — przy rywalizacji o CPU produkcja wygrywa arbitraż, a gdy serwer jest bezczynny runner i tak bierze wszystko, więc działa to lepiej niż sam CPUQuota. IOWeight=20 jest ustawiane razem z nim, ale na hoście bez BFQ lub io.cost nie robi nic — io.weight nie ma wtedy czym egzekwować podziału. Sprawdzone na VPS Hostinger: scheduler none, brak modułu BFQ, io.cost.qos puste. Jeśli kiedyś runner ma dzielić maszynę z produkcją, arbitraż dysku wymaga doinstalowania BFQ (linux-modules-extra-$(uname -r)) albo twardych limitów IOWriteBandwidthMax.
  • MemorySwapMax=2G — pik ponad MemoryHigh trafia do swapu zamiast zabijać proces.
  • github-runner-gc.timer — codzienne czyszczenie _work/_temp i _diag starszych niż 7 dni.

Weryfikacja po instalacji:

systemctl show github-runner.slice -p MemoryMax -p CPUQuota -p CPUWeight
systemd-cgtop /github-runner.slice

Zabezpieczenia po stronie workflow​

Same limity nie wystarczą, bo można zapchać runner kolejką jobów:

  • concurrency z cancel-in-progress: true w każdym z trzech workflowów — nowy push anuluje poprzedni run tej samej gałęzi, więc seria commitów nie ustawia kolejki buildów.
  • timeout-minutes na każdym jobie (5–45) — zawieszony job zwalnia runner zamiast trzymać go przez domyślne 6 godzin.
  • Krok „Check self-hosted host capacity" w jobach build i test — przerywa z czytelnym błędem, jeśli na serwerze zostało mniej niż 15 GB wolnego miejsca, zamiast wywracać build na ENOSPC w połowie.
  • NODE_OPTIONS z mniejszym heapem na self-hosted (5120 MB zamiast 8192 MB), dopasowanym do sufitu slice'a.

Ograniczenia​

  • Sonda kosztuje jedną rozliczaną minutę GitHuba na run (minuty są zaokrąglane w górę do pełnej minuty na job). Przy wyczerpanym limicie minut ustaw CI_RUNNER_MODE.
  • Jeśli GitHub jest zablokowany i żaden runner self-hosted nie jest online, joby będą czekać w kolejce (GitHub anuluje je po 24 godzinach).
  • Jeden runner wykonuje jeden job naraz. Przy pojedynczym runnerze joby szeregują się i zegar PR-a rośnie — patrz pomiar niżej. Dwie instancje runnera na maszynie z ≥16 GB RAM przywracają równoległość.

Rejestracja runnerów na hoście​

Robi to scripts/ci/setup-runner-host.sh. Token rejestracyjny jest ważny godzinę i mintujesz go u siebie, więc na serwer nie trafia żaden trwały sekret:

RUNNER_TOKEN=$(gh api -X POST repos/DMT-Softwares/Acepark_Rezerwacje/actions/runners/registration-token --jq .token)
scp scripts/ci/setup-runner-host.sh scripts/ci/install-runner-limits.sh root@169.58.181.247:/tmp/
ssh root@169.58.181.247 "RUNNER_TOKEN='$RUNNER_TOKEN' bash /tmp/setup-runner-host.sh --instances 2 --labels contabo"

Skrypt zakłada nieuprzywilejowanego użytkownika ghrunner (bez sudo i poza grupą docker), dokłada systemowy Node z corepackiem (bez niego actions/setup-node z cache: yarn pada, bo nie ma czym ustalić katalogu cache'u), weryfikuje sumę kontrolną paczki runnera, rejestruje instancje, nakłada hardening systemd (NoNewPrivileges, PrivateTmp, ProtectSystem=full, ReadWritePaths ograniczone do katalogu runnera) i na koniec uruchamia install-runner-limits.sh. Jest idempotentny — przy ponownym uruchomieniu pomija zarejestrowane instancje i odświeża same jednostki.

Przy zmianie hosta kolejność jest istotna: najpierw zarejestruj i zweryfikuj runnery na nowej maszynie, dopiero potem wyrejestruj starą. Odwrotna kolejność zostawia CI bez runnera, a joby czekają w kolejce do anulowania przez GitHuba po 24 godzinach.

Kierowaniem steruje etykieta w wyrażeniu runs-on. Runner pasuje do joba tylko wtedy, gdy ma wszystkie żądane etykiety, więc sama contabo wystarczy, by wykluczyć pozostałe maszyny. Przełączenie na inny host to zmiana tej jednej wartości w trzech workflowach.

Zmierzone zachowanie​

Wszystkie liczby z jednego repozytorium i tej samej aplikacji, wrzesień 2026.

Co naprawdę kosztuje​

Pierwsze uruchomienia na self-hosted wyglądały źle: pełny cykl PR-a 32 minuty na jednym runnerze, 23:51 na dwóch. Rozbicie kroków pokazało, że nie chodziło o CPU — next build był od początku szybszy niż na GitHubie. Czas zjadał yarn install: node_modules ma 2,0 GB w ponad 150 tys. plików, a domyślny nmMode: classic kopiuje je fizycznie przy każdym jobie. Dwie instalacje naraz na jednym dysku kosztowały każda dwa razy więcej niż jedna.

Dwie zmiany, obie działające wyłącznie na self-hosted, usunęły ten koszt:

  • YARN_NM_MODE: hardlinks-global — yarn twardolinkuje pliki z globalnego cache'u (~/.yarn/berry/cache) zamiast je kopiować. Ustawione jako zmienna środowiskowa joba, nie w .yarnrc.yml, więc nie zmienia zachowania u nikogo lokalnie i cofa się jedną linią. Gdyby twarde linkowanie z cache'em okazało się problemem (narzędzie modyfikujące plik w node_modules w miejscu), bezpieczniejszy wariant to hardlinks-local.
  • cache: 'yarn' w actions/setup-node wyłączony — pakował i rozpakowywał archiwum przez sieć, podczas gdy globalny cache yarna i tak przeżywa git clean z actions/checkout (kasowane jest tylko node_modules, bo leży w workspace).

Efekt:

KrokprzedpoGitHub
yarn install2:30 – 6:3323 – 39 s63 s
Set up Node.js42 – 52 s0 – 1 s~5 s
next build client2:562:17 – 2:513:18

Instalacja jest teraz szybsza niż na GitHub-hosted. To dlatego nie budujemy gotowych obrazów kontenera z zapieczonym node_modules — zostałoby do zaoszczędzenia ok. 30 sekund, a kosztem byłby rejestr obrazów, przebudowa przy każdej zmianie yarn.lock i dostęp do Dockera dla użytkownika runnera.

Dlaczego dedykowana maszyna, a nie szybszy VPS produkcyjny​

Sprzęt Hostingera jest 2,54× szybszy na rdzeń (zmierzone benchmarkiem Node) i buildy szły tam realnie szybciej. Mimo to runnery stoją na Contabo, bo na hoście produkcyjnym CI i buildy obrazów Coolify biją w ten sam dysk. Widać to w PSI: przy równoległym buildzie io full sięgało 30% (wszystkie niebezczynne zadania stały na I/O), przy zerowym nacisku na CPU i pamięć. Load average pokazywał wtedy 54 przy 8 rdzeniach, co jest mylące — to były zadania w stanie D, nie obciążenie procesorów.

Zwykły deploy acepark-dev trwa ~12 minut. Przy zbiegu trzech merge''ów do develop w 44 sekundy kolejka Coolify (concurrent_builds = 1) rozciągnęła je do 27 i 30 minut, a CI dokładało do tego samego dysku. Na dedykowanej maszynie ten konflikt nie istnieje.

Cena decyzji: pojedynczy build na Contabo jest ok. dwa razy wolniejszy niż byłby na Hostingerze. Dwie instancje runnera na 6 rdzeniach odrabiają to równoległością.

Budżet pamięci​

Szczyt hosta przy jednym buildzie wyniósł 5480 MB przy heapie 5120 MB. Na 12 GB przy dwóch równoległych buildach jest to zbyt ciasne, dlatego heap na self-hosted zszedł do 4096 MB, a slice ma MemoryMax=10166M. Zostaje zapas na system i 8 GB swapu jako bufor na piki.