Produkty Poradenství O nás Blog Kontakt English
arrow_back Zpět na blog

Když runs-on neumí failover: hybridní strategie CI runnerů

Když runs-on neumí failover: hybridní strategie CI runnerů

Naše backendová testovací sada má kolem 12 000 testů a spousta z nich startuje skutečnou infrastrukturu — databáze, cache, event store — v jednorázových kontejnerech. To je ten typ sady, který vám o vašem kódu řekne pravdu. A zároveň ten typ sady, který se nevejde na malý sdílený CI runner.

Zjistili jsme to tak, jak to zjistí každý: build začal umírat. Ne padat — umírat. Uprostřed běhu testovací JVM zmizela, zabitá za to, že spotřebovala víc paměti, než kolik měl runner k dispozici. Oprava zní samozřejmě — „použij větší runner" — ale cesta k ní se ukázala zajímavější než pouhý výběr stroje. Provedla nás ostrou hranou v tom, jak GitHub Actions plánuje úlohy, a na druhém konci vyústila v malý routovací vzor, který stojí za sdílení.

Tohle je mapa terénu, kterým jsme prošli. Nic z toho není specifické pro náš produkt ani pro nás — pokud na GitHub Actions provozujete těžkou testovací sadu, ten terén poznáte.

Zeď, do které jsme narazili

Test, který nabootuje databázový kontejner, potřebuje rezervu: kontejner, testovaná JVM, vlastní forky testovacího frameworku — všechno rezidentní najednou. Spusťte jich dost paralelně a paměťový strop malého sdíleného runneru se stane tvrdou zdí. Symptom je, že operační systém potichu zabije největší proces, aby se zachránil, a váš build hlásí proces, který „neočekávaně skončil", bez stack trace, který by se dal sledovat.

Poctivé čtení toho symptomu není „flaky test". Je to „tahle práce se sem nevejde". Potřebovali jsme víc paměti a víc jader. Otázka byla, kde je vzít, aniž bychom CI udělali pomalejší, dražší nebo křehčí, než už bylo.

Past v runs-on

Instinkt velí sáhnout po větším stroji, který už vlastníte — self-hosted runneru — a v případě, že je obsazený, spadnout na cloudový runner. GitHub Actions to vypadá jednoduše. Napíšete:

YAML
runs-on: [self-hosted, big]

a předpokládáte, že když je self-hosted stroj zaneprázdněný, úloha půjde jinam.

Nepůjde. runs-on je shoda labelů, ne plánovač s failoverem. Pokud je runner, který odpovídá vašim labelům, obsazený, úloha se zařadí do fronty — a čeká, jakkoli dlouho to trvá. Žádné „…a pokud není volný, použij cloud" tam zabudované není. Uvedete label a jste s tím labelem oddáni. Tohle je zdokumentované chování GitHubu, ale je to ten typ věci, který obvykle objevíte v 9 ráno, když dlouhá úloha sedí ve frontě za jinou dlouhou úlohou.

Skutečný problém tedy nebyl „který runner". Byl: jak vyberete runner v okamžiku, kdy úloha startuje, na základě toho, co je právě teď reálně k dispozici?

Mapa: tři úrovně

Než jsme to vyřešili, pomohlo pojmenovat terén. Měli jsme k dispozici tři druhy runnerů, každý dobrý v něčem jiném:

  • Self-hosted runner — fakticky zdarma a teplý: jeho cache a image kontejnerů už jsou na lokálním disku, takže build, který sem dopadne, startuje rychle. Háček je v kapacitě. Jeden stroj, jedna úloha v jednu chvíli.
  • Cloudový runner — rychlý, štědře dimenzovaný a vždy dostupný. Stojí za minutu, ale nikdy vás nenechá čekat ve frontě. My tady používáme Blacksmith; je to jeden z několika dodavatelů v tomhle prostoru a vzoru je jedno, který si vyberete.
  • Standardní GitHubem hostovaný runner — univerzální záchranná síť. Menší, ale vždy tam, i kdyby se každý účet, kredit nebo integrace nad ním vypařily.

Tvar, který jsme chtěli: upřednostnit volný teplý stroj, když zahálí, sáhnout po rychlém cloudovém runneru ve chvíli, kdy nezahálí, a standardní runner si nechat jako studenou poslední záchranu, která nás nikdy nenechá na holičkách. Jasné pořadí preferencí — a záruka, že vždy někde přistaneme.

Cesta skrz: vyber label za běhu

Protože runs-on za nás nevybere, vybereme za něj. Klíčové uvědomění je, že runs-on umí přečíst svou hodnotu z výstupu jiné úlohy — takže přidáme drobnou úlohu, která běží první, rozhodne, který runner použít, a odpověď předá dál.

YAML
jobs:
  route:
    runs-on: ubuntu-latest
    outputs:
      runner: ${{ steps.pick.outputs.runner }}
    steps:
      - id: pick
        run: |
          # Výchozí volba: vždy dostupný cloudový runner.
          runner='"fast-cloud-runner"'
          # Pokud je self-hosted stroj online A právě teď volný, dej mu přednost.
          if self_hosted_is_free; then
            runner='["self-hosted","big"]'
          fi
          echo "runner=$runner" >> "$GITHUB_OUTPUT"

  build:
    needs: route
    runs-on: ${{ fromJSON(needs.route.outputs.runner) }}
    steps:
      - run: ./run-the-heavy-suite

Fungovat to nechávají dva detaily a oba se snadno přehlédnou:

Živá dostupnost, ne konfigurace. self_hosted_is_free není statický příznak — je to otázka kladená za běhu proti živému seznamu runnerů: je stroj online a není právě teď zaneprázdněný? Routování podle zastaralé konfigurační hodnoty by jen znovu vytvořilo problém s frontou; routování podle živého stavu je to, co nám ho dovolí obejít.

fromJSON umožní, aby jeden výstup byl buď řetězec, nebo seznam. Cloudový runner je pojmenovaný jediným řetězcem labelu ("fast-cloud-runner"); self-hosted runner se matchuje polem labelů (["self-hosted","big"]). runs-on přijímá obě formy — ale výstup úlohy je vždy řetězec. Tak emitujeme JSON text — někdy řetězec v uvozovkách, někdy pole v hranatých závorkách — a necháme fromJSON(...), ať ho rozparsuje zpátky do tvaru, který runs-on zrovna potřebuje. Jeden kanál, dva typy.

Poslední princip je ten, který se nejvíc vyplatí: fail open. Když se v routovacím kroku cokoli pokazí — API volání selže, kontrola je neprůkazná, chybí credential — výchozí hodnota je už nastavená na vždy dostupný cloudový runner dřív, než jakákoli kontrola proběhne. Routovací vrstva by nikdy neměla mít možnost nechat build na holičkách. Nejhorší případ je „zaplatili jsme za cloudový runner, který jsme striktně vzato nepotřebovali", což je náklad, ne výpadek.

Odbočka ke cache, která stojí za to

Přesun těžké úlohy na cloudový runner vynesl na povrch druhou otázku: cache. Velká cache závislostí a buildu se draho přesouvá. Výchozí model CI cachování — zkomprimovat ji, nahrát do vzdáleného úložiště, při dalším běhu stáhnout a rozbalit — utrácí reálný čas na multigigabajtové cache při každém jednotlivém běhu, na obou koncích.

Spousta dodavatelů cloudových runnerů nabízí lepší primitivum: perzistentní disk, který se napříč běhy znovu připojí. Místo posílání cache sem a tam prostě zůstane na místě. Build ho připojí a cache už tam je. U velké cache poráží opětovné připojení stahování-rozbalení-nahrání o velký kus.

Jeden footgun, na který upozorňuji, protože jsme do něj šlápli: pokud nasadíte cachování na perzistentním disku, ujistěte se, že váš jazyk nebo build nástroje zároveň neposílají tentýž adresář zpátky do staré cache přes vzdálený tarball. Nechte zapnuté obojí a každý běh platí pomalou cenu navrch té rychlé — přidali jste cache, ne ji nahradili. Vyberte jeden mechanismus na adresář.

Kde jsme přistáli

OOM-killy jsou pryč, protože těžká sada teď běží tam, kde má místo. Zaseknuté fronty jsou pryč, protože obsazený self-hosted stroj už nic neblokuje — další úloha routuje rovnou do cloudu. A účet zůstává skromný, protože volný teplý runner si práci pořád bere, kdykoli zahálí, což je většinu času. Tři úrovně, jasné pořadí preferencí a žádný jediný bod, který by mohl nechat build čekat nebo na holičkách.

Žádný z těch dílků není exotický. Pointa je prostě v tom, že runs-on je shoda, ne plánovač — takže pokud chcete failover, label si spočítáte sami, z živého stavu, a uděláte fail open. Ty tři úrovně náhodou patří nám; ty vaše můžou být dvě, nebo čtyři, nebo úplně jiný cloud. Vzor je přenositelný, a o to právě jde: je postavený ze standardních primitiv GitHub Actions a z dodavatele, kterého můžete vyměnit, takže vás tu nic neváže k mapě, kterou jsme zrovna nakreslili.

Pokud vám CI stojí ve frontě za obsazeným strojem nebo umírá na runneru, který je moc malý, tohle je cesta, kterou jsme našli. Jděte si nakreslit tu svou.


Titulní foto od pavel ondera na Unsplash.

Další z blogu