Schalen van Golang CI door actions/setup-go te vervangen
We hebben een nieuwe manier gevonden om parallelle Golang continuous integration (CI) workflows te versnellen door optimaal gebruik te maken van de Golang build-cache. Door de officiële actions/setup-go actie van GitHub te vervangen door een gelijkwaardig alternatief, hebben we de looptijd van onze testjobs met 69% kunnen verminderen. We stellen cloudx-io/setup-go open-source beschikbaar, zodat anderen hetzelfde kunnen doen.
De officiële actions/setup-go stap van GitHub zorgt ervoor dat parallelle jobs elkaars prestaties beïnvloeden en continu verouderde cache-waarden laden. Backtesting in onze monorepo — waar we de gebruikelijke situatie hebben van enkele parallelle Golang testjobs (één voor lints, één voor tests, één voor builds) — suggereert dat 86% van het werk dat de standaard actie uitvoert, volledig overbodig is.
Wie een matig complex Go-project beheert, kan soortgelijke prestatieverbeteringen verwachten.
Waarom snelheid van CI belangrijk is
We brengen veel nieuwe producten en functies uit, en het tempo waarin we dat doen neemt na verloop van tijd zelfs toe. Dit is geen toeval; we investeren zwaar in de tools en processen die dit mogelijk maken. In het hart van elke "softwarefabriek" staan de testsuites en Continuous Integration (CI) workflows die garanderen dat code-wijzigingen in productie niet zullen falen. Als onze tests betrouwbaar en snel draaien bij elke wijziging, kunnen we met een fantastische snelheid bouwen zonder ons zorgen te maken over storingen voor onze klanten.
Dit is belangrijk voor ons, dus we meten en investeren in de snelheid van onze CI-jobs. Ons doel is dat wanneer er code naar een CloudX-repository wordt gepusht, de ontwikkelaar binnen 90 seconden een duidelijk antwoord krijgt over de acceptatie: of het bouwt, of de tests slagen en of het voldoet aan onze linter-regels.
Snelheid kan op verschillende manieren worden bereikt, maar uiteindelijk zijn algoritmische verbeteringen noodzakelijk. We maken al gebruik van Warp Build om onze CI-jobs uit te voeren op snelle, kostenefficiënte machines. Naarmate onze testsuite meegroeide met ons productoppervlak, merkten we dat actions/setup-go ons niet optimaal ondersteunde.
Hoe actions/setup-go faalt bij parallelle jobs
GitHub's actions/setup-go is de door GitHub aanbevolen manier om Go te installeren en uit te voeren in GitHub Actions. Het maakt intern gebruik van actions/cache om de lokale Go module-cache en build-cache directories op te slaan en te herstellen. In principe zou dit ervoor moeten zorgen dat gedownloade module-broncode en build/test-artefacten van één job-run beschikbaar zijn voor alle volgende job-runs in je repo.
Dit is de standaard constructie van de actions/setup-go cache-sleutel:
setup-go-${os}-${arch}-go-${goVersion}-${hashFiles('**/go.mod')}
Deze cache-sleutel is ontoereikend: in een typisch product in actieve ontwikkeling wijzigt slechts een klein deel van de code-wijzigingen het doelbesturingssysteem, de architectuur, de Go-versie of de go.mod-bestanden.
De eerste keer dat een job deze hash-sleutel berekent, wordt de uiteindelijke cache-status opgeslagen in de GitHub cache-service. Tot de volgende wijziging die een van die sleutelelementen aanpast, zal elke CI-run die eerste waarde laden. Naarmate je de applicatie wijzigt, worden de herstelde build-module-archieven van die eerste run minder relevant — elke volgende build moet meer werk vanaf nul doen. Ook de herstelde go test outputs worden verouderd, waardoor elke volgende job meer tests opnieuw moet uitvoeren. De CI degradeert totdat je go.mod bijwerkt.
Bovendien strijden meerdere parallelle jobs die actions/setup-go uitvoeren om verschillende lokale cache-statussen naar de GitHub cache-service te schrijven. Dit komt omdat de uiteindelijke Go-cache-status op een runner afhangt van zowel de broncode als de uitgevoerde commando's. Stel dat je aparte lint- en testjobs parallel uitvoert:
# Sterk vereenvoudigde .github/workflows/go.yaml
jobs:
test:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- run: go test ./...
lint:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- uses: golangci/golangci-lint-action@v9
Beide jobs resolveren dezelfde standaard cache-sleutel en strijden om de waarde ervan te schrijven (een race-conditie). Stel dat de lint-job als eerste klaar is: deze slaat een waarde op zonder een bijgewerkte test-cache-status. Latere testjobs blijven die verouderde waarde gebruiken totdat de cache-sleutel verandert, en voeren daarom onnodig tests opnieuw uit.
Caches van de Go-toolchain
Het linten, bouwen en testen van een codebase zijn ideale kandidaten voor memoisatie: hun outputs (respectievelijk linter-berichten, gebouwde binaries en testresultaten) zouden pure functies van de broncode moeten zijn. Je kunt outputs opslaan en hergebruiken in plaats van ze opnieuw te berekenen, zolang de inputs niet zijn veranderd.
Verschillende delen van de standaard Go-toolchain slaan hun outputs op het bestandssysteem op en controleren of ze een bestaande output kunnen hergebruiken:
| Cache | Omgevingsvariabele voor controle | Standaard Linux-locatie |
|---|---|---|
| Module cache | GOMODCACHE | $GOPATH/pkg/mod |
| Build cache | GOCACHE | ~/.cache/go-build |
| Test cache | GOCACHE | ~/.cache/go-build |
De module-cache van Go bespaart tijd bij het downloaden van broncode voor module-afhankelijkheden. Dit kan expliciet via go mod download, maar ook impliciet via go build.
De build-cache en test-cache bevinden zich beide in de GOCACHE directory en delen een algemene structuur. Beide processen hashen hun volledige inputs voor gebruik als cache-sleutel. Deze hashes zijn georganiseerd in subdirectories op basis van prefix en dienen als bestandsnamen voor de herbruikbare proces-outputs:
$ tree "$(go env GOCACHE)" | head -6
~/.cache/go-build
├── 00
│ ├── 000131ed61b57fbbb4ad26f5862b2aae4b648b33be167a7212fc680e7af79a93-a
│ ├── 000147cede28c3ff98a24adc10ac43dfbe11d00b4707945236c461031c0a4a5c-a
│ ├── 000427faba12a1cf22e9396ebc98ada8dbf714d648c1746176d26779c47bf05a-d
│ ├── 0008027ee2ff469b29ad5f95fca2a6d74fba5caa5371dd80e2f5f3ba7f117bc8-d
Bestanden met het suffix -d zijn datapakketten (data payloads), en bestanden met het suffix -a dienen als indexen. Build- en testprocessen leveren verschillende payloads op:
go buildslaat package-archieven op; tussenstappen die worden gelinkt in een final binary.go testslaat stdout, stderr en de uiteindelijke exit-code van de testuitvoering op.
De Go test runner detecteert automatisch welke bestanden worden gelezen en neemt hun inhoud mee in de cache-sleutel.
Dit werkt briljant op een enkel persistent bestandssysteem, maar CI-runners hebben dat niet. In GitHub Actions worden deze toolchain-caches van de ene kortstondige (ephemeral) runner naar de volgende gesmokkeld via een andere cache, die zeer andere ontwerpprioriteiten heeft.
De GitHub Actions-cache
De basis actions/cache van GitHub kent alleen sleutels en bestandspaden. Je geeft de cache-service een sleutel naar eigen ontwerp. Als de service de sleutel herkent, laadt hij de bijbehorende bestanden in je runner; anders wordt er niets geladen. Alleen als deze primaire-sleutel lookup mislukt, slaat actions/cache de bestanden op na voltooiing van de CI-job.
actions/cache schrijft alleen een nieuw blob naar de service als de job slaagt en er initieel geen exacte match voor die sleutel was. Eenmaal geschreven, zijn sleutel-waarde paren in de Actions-cache onveranderlijk (immutable). Elke volgende poging om een andere waarde voor diezelfde sleutel op te slaan, wordt geweigerd.
Het lastige gedeelte is het kiezen van goede sleutels.
Verbetering van setup-go
cloudx-io/setup-go is in feite een drop-in vervanging voor actions/setup-go. We geven hier de voorkeur aan voor onze web-monorepo om de volgende redenen:
- We zijn bereid te betalen om engineers en coding agents ongehinderd te laten werken. Dat betekent dat paralleliseerbaar werk parallel moet draaien, en we betalen graag extra voor extra cache-ruimte.
- Onze build-, test- en lint-workloads zijn veel sneller wanneer ze prior cached-waarden kunnen hergebruiken.
Ons belangrijkste inzicht is dat de Go-toolchain zeer goed is; een goede CI-caching strategie moet de belangrijkste eigenschappen van die toolchain behouden over vele ephemeral runner-instanties, terwijl het binnen de beperkingen van GitHub werkt.
Optimalisatie van hit rates
GitHub's standaard setup-go sleutels zijn onvolledig omdat ze niet vastleggen wat een specifieke job daadwerkelijk doet. Daarom strijden test- en lint-jobs om één enkele, partiële cache-entry. cloudx-io/setup-go lost dit op door de job-identiteit (of een willekeurige cache-key-prefix input) onderdeel te maken van de Actions cache-sleutel. Hierdoor slaan lint- en test-jobs aparte caches op zonder conflicten.
Garanderen van persistentie bij elke miss
De standaard setup-go slaat alleen een nieuwe cache-entry op wanneer go.mod wijzigt, ondanks dat er bij elke build of test van een nieuwe versie van de broncode nieuwe data naar de lokale cache-directories wordt geschreven.
In plaats van deze incrementele inspanning weg te gooien, schrijft cloudx-io/setup-go elke keer een cache-entry: het laatste element in de sleutel is de GitHub Actions run ID. De volledige cache-sleutel ziet er als volgt uit:
go-cache-${os}-${inputs.cache-key-prefix}-${goVersion}-${refname}-${hashFiles('**/go.sum')}-${runid}
Door exacte sleutel-matches onmogelijk te maken, zorgt cloudx-io/setup-go ervoor dat elke job eindigt met een vers geschreven blob in de GitHub Actions cache-service.
Prestaties meten
Eind vorig jaar, toen we nog de standaard actie gebruikten, stuitten we op de eerder besproken race-conditie: onze parallelle lint-job bewaarde een Go-cache zonder testresultaten, wat onze test-jobs vertraagde van een mediane looptijd van 76 seconden naar een onacceptabele 180 seconden. Deze vertraging voegde nul waarde toe; de jobs voerden tests opnieuw uit voor logica die sinds de vorige run ongewijzigd was.
Door de race-conditie te elimineren en caches voor verschillende jobs te scheiden via cloudx-io/setup-go, herstelden de test-jobs het laden van de juiste caches. De mediane looptijd daalde naar 41 seconden, een verbetering van 69%.
Zelfs als we linten en testen sequentieel zouden doen, zou cloudx-io/setup-go beter presteren omdat het na elke run een bijgewerkte cache-status opslaat. Bij de GitHub-standaard wordt de geladen cache progressief verouderd tussen sleutelwijzigingen (go.mod wijzigingen). Met de nieuwe strategie is de geladen cache altijd vers van de vorige run.
In totaal hoeven we nu voor 86% minder testpakketten te wachten. Om dit te vergelijken met echte data, namen we een reeks van 4.000 echte commits en modelleerden we de cache-hit rates onder de oude en nieuwe sleutelconstructies.
Nadelen
We hebben twee nadelen gemerkt door het opslaan van veel meer cache-objecten:
- Cache-omvang: Aanvankelijk groeiden onze cache-blobs lineair per run, waardoor de laadtijden een belangrijke factor werden in de totale CI-tijd. Go's cache ruimt zichzelf namelijk niet op. We hebben dit opgelost met automatische pruning (snoeien).
- Opslagcapaciteit: Je hebt mogelijk een uitgebreidere GitHub Actions cache-capaciteit nodig. Dit wordt echter gecompenseerd door het feit dat jobs sneller zijn (runners worden per minuut gefactureerd).
cloudx-io/setup-go is sinds november vorig jaar intern stabiel. We hopen dat het jullie team tijd bespaart!
Groetjes,