Caching i GitHub Workflows

Glenn Bitar

tl;dr

GitHub har et system for å lagre og hente ut cache for workflows. Bruk av caching kan redusere byggetid betydelig for CI-workflows for f.eks. Rust-prosjekter, som i utgangspunktet er notorisk trege. Man bruker enklest GitHub-cachen ved å bruke actionen actions/cache@v4. Da har man filer fra forrige kjøring av tilsvarende jobb tilgjengelig ved neste kjøring, som gjør at man kan utnytte seg av inkrementell kompilering. Jeg så en reduksjon av kjøretid fra ~7 minutter til ~30 sekunder.

Bakgrunn

I faguka bestemte jeg meg for å prøve å lage en frontend til Mittari, som er en intern tjeneste for å vise fram ytelsen til de mest populære artiklene i avisene våre over tid. Nå skal det sies at jeg er ikke en frontender, og mitt øye for design har stort forbedringspotensiale, men motivasjonen min til å prøve meg på dette prosjektet er som følger:

  • Lære meg hvordan man deployer en app eller tjeneste i vår utviklerplattform
  • Lære om WebAssembly, eller WASM

Om du ikke har hørt om WASM før, så er det en av måtene man kan kjøre kode i nettleseren på. Ved siden av HTML og CSS, så er det tradisjonelt kun JavaScript som har fått lov å kjøre i nettleseren. WASM ble støtta i alle de store nettleserne fra år 2017, og fra da var det mulig å skrive klient-kode i alle programmeringsspråk kan kompileres til WASM. Dette inkluderer C, C++, Rust, Go, .NET-språk, med flere.

Jeg har tidligere brukt egui i Rust til å raskt utvikle GUI-er som løser utvikler-retta oppgaver. Dette var native-applikasjoner som kjørte på maskina til utviklerne. Da jeg holdt på med dette var det også tydelig at det var mulig å servere egui i nettleseren. Dette gjøres ved å kompilere til WASM, som er noe jeg fikk muligheten til å prøve i faguka.

Etterhvert som jeg begynte å legge til CI med GitHub workflows, viste det seg at å bygge og teste Rust-prosjekter fra scratch hver gang ville ta altfor lang tid. Dette tok gjerne 7-8 minutter, siden man i CI ikke kan utnytte seg av inkrementell kompilering. Når man utvikler lokalt tas byggefilene vare på i en target/-katalog, som gjør at bygging etter en endring går veldig raskt. Denne blir sletta mellom hver kjøring i CI. Jeg tenker at alt over 2 minutter gjør utviklerens liv totalt uutholdelig, og måtte derfor gjøre noe med dette.

Skjermdump av CI-jobber som tar syv opptil minutter.
Dette er helt krise.

GitHub Cache

Hvert repo i GitHub har mulighet til å lagre cache-filer som kan brukes av workflows. I et repo kan du se en oversikt over cache-data på https://github.com/amedia/<repo>/actions/caches. Dette er plassen for å lagre filer som kan hjelpe til med inkrementell kompilering, samt andre filer, f.eks. dependencies, så man slipper å generere dem eller laste dem ned for hver gang du kjører en CI-jobb. En fint sted å starte for å lese om GitHub workflow caching er her.

Den enkleste måten å bruke caching på er ved hjelp av actions/cache. Denne kan brukes som et steg i en workflow-jobb som laster ned cache dersom den finnes, og lagrer ved endt kjøring. Man spesifiserer en nøkkel og en liste av stier som skal forbindes med denne cachen, og actionen tar seg av resten. F.eks.:

jobs:
  # ...
  build:
    # ...
    steps:
      - uses: actions/checkout@v4
      - name: Cache build files and dependencies
        uses: actions/cache@v4
        with:
          path: |
            ./build-directory
            ./downloaded-dependencies
          key: ${{ runner.os }}-build
      - name: build project
        run: # build command

Denne vil sjekke om det finnes cache med nøkkelen spesifisert i key og laste den ned før jobben kjører videre. Dersom det ikke fantes cache i nøkkelen, lagres det som ligger i stiene spesifisert i path etter avslutta jobb, slik at disse er tilgjengelig ved neste kjøring.

En GitHub cache kan kun skrives til én gang, og cachen er scopet til branchen workflowen kjøres på, en versjon som er bestemt av detaljer i actions/cache-implementasjonen, stiene i path, og selvfølgelig nøkkelen key. I tillegg vil cache-lookup gjøres i default-branchen (main eller master). Dette medfører at cachen bestemmes av første kjøring av jobben, og dermed ikke vil oppdateres med siste versjon av filene. Cache går ut etter syv dager uten bruk, men det kan være ønskelig å oppdatere cache mer jevnlig.

En mulighet er å la f.eks. dependencies være en del av cache-nøkkelen. GitHub Actions gir deg mulighet til å hashe filer, så man kan f.eks. hashe låsefilene som definerer avhengigheter:

          key: ${{ runner.os }}-build-${{ hashFiles('Cargo.lock') }}

Dette gjør at jobben oppdaterer cachen hver gang man endrer avhengigheter, men det gjør også at man må bygge fra scratch om dependencies endrer seg. For å forbedre dette kan man bruke restore-keys, som henter ned cache om man får en delvis match på en nøkkel:

          key: ${{ runner.os }}-build-${{ hashFiles('Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-build-

Her vil actionen først se etter en eksakt match i key, og gå videre til delvis-matcher i restore-keys. Resultatet lagres som en ny cache i key uansett, så ved neste run vil man få en eksakt match på key med fersk cache.

Man kan ta dette lengre ved å sørge for at det lages en ny cache ved hver kjøring av en GitHub workflow:

          key: ${{ runner.os }}-build-${{ github.run_id }}
          restore-keys: |
            ${{ runner.os }}-build-

github.run_id har en ny verdi ved hver kjøring, og dermed vil man aldri få en eksakt match, som fører til at man alltid lagrer en ny cache. Dette er strategien jeg endte opp med for prosjektet mitt, med behagelige resultater:

Skjermdump av CI-jobber hvor den lengste kjøretida er 44 sekunder.
Der ja.

Det finnes flere cache-strategier man kan lese om her.

Også greit å vite: Cacher har en grense på 10 GB per repo, og kastes ut med LRU-først-regelen. I tillegg kan man slette caches ved hjelp av REST-API og gh-CLI-verktøyet. Se hvor og hvordan actions/cache er brukt i organisasjonen vår her.