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-latestprzy 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-hosted | pomija 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 hosta | Wykrywany po | MemoryMax | CPUQuota |
|---|---|---|---|
| współdzielony z produkcją | działające kontenery | 40% RAM | 75% rdzeni |
| dedykowany runner | brak działających kontenerów | 85% RAM | wszystkie 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ż samCPUQuota.IOWeight=20jest ustawiane razem z nim, ale na hoście bez BFQ lubio.costnie robi nic —io.weightnie ma wtedy czym egzekwować podziału. Sprawdzone na VPS Hostinger: schedulernone, brak modułu BFQ,io.cost.qospuste. Jeśli kiedyś runner ma dzielić maszynę z produkcją, arbitraż dysku wymaga doinstalowania BFQ (linux-modules-extra-$(uname -r)) albo twardych limitówIOWriteBandwidthMax.MemorySwapMax=2G— pik ponadMemoryHightrafia do swapu zamiast zabijać proces.github-runner-gc.timer— codzienne czyszczenie_work/_tempi_diagstarszych 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:
concurrencyzcancel-in-progress: truew 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-minutesna 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
builditest— przerywa z czytelnym błędem, jeśli na serwerze zostało mniej niż 15 GB wolnego miejsca, zamiast wywracać build naENOSPCw połowie. NODE_OPTIONSz 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 wnode_modulesw miejscu), bezpieczniejszy wariant tohardlinks-local.cache: 'yarn'wactions/setup-nodewyłączony — pakował i rozpakowywał archiwum przez sieć, podczas gdy globalny cache yarna i tak przeżywagit cleanzactions/checkout(kasowane jest tylkonode_modules, bo leży w workspace).
Efekt:
| Krok | przed | po | GitHub |
|---|---|---|---|
yarn install | 2:30 – 6:33 | 23 – 39 s | 63 s |
Set up Node.js | 42 – 52 s | 0 – 1 s | ~5 s |
next build client | 2:56 | 2:17 – 2:51 | 3: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.