Jak skróciliśmy nasz czas w CI z 30 minut do 5 minut
Wolne CI to jedna z tych rzeczy, które doprowadzają zespół inżynierski do szału. Inżynierowie dłużej czekają na feedback, rośnie przełączanie kontekstu, a pokusa zbierania zmian w paczki staje się większa, co tylko pogarsza sprawę. Spędziliśmy kilka tygodni na optymalizacji naszych workflowów GitHub Actions i skróciliśmy czas naszych sprawdzeń PR z 30 minut do 5 minut. To 83% mniej czekania, osiągnięte bez poświęcania pokrycia testów ani bramek jakości. Nie było jednego magicznego rozwiązania. Przyjrzeliśmy się temu, gdzie naprawdę uciekał czas, i zastosowaliśmy trzy zasady: mądre zależności zadań, agresywne cachowanie i shardowanie testów. Oto, czego się nauczyliśmy.

Fundament: migracja do Blacksmith
Tak naprawdę próbowaliśmy wcześniej migrować do Blacksmith, ale trafiliśmy na problemy. Przy tak aktywnej gałęzi main jak nasza nie chcieliśmy walczyć z niestabilnością CI. Tym razem migracja była prosta (#26247): zamieniliśmy runner'y buildjet-*vcpu-ubuntu-2204 na blacksmith-*vcpu-ubuntu-2404 w 28 plikach workflowów, podmieniliśmy akcje cache i setup-node oraz dostosowaliśmy przydział vCPU. Jedyny realny problem, na jaki trafiliśmy, to uszkodzony cache z plikami .next (więcej o tym w sekcji o cachowaniu).
Po aktualizacji zauważyliśmy natychmiastowe 2x przyspieszenie pobierania cache. Ponieważ yarn install waży 1.2GB, oszczędza to około 22 sekundy przy każdym zadaniu, które musi wykonać yarn install, a takich zadań jest większość. Korzystamy też z container caching Blacksmitha dla usług używanych w wielu zadaniach (Postgres, Redis, Mailhog). Zadanie Initialize containers jest teraz krótsze o ponad 50%, z 20 sekund do 9 sekund.
jobs:
build:
name: Build Web App
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
Co musieliśmy naprawić
Runner'y Blacksmitha pokazują liczbę CPU maszyny hosta zamiast przydzielonych vCPU (zwykle 4). Playwright, chcąc pomóc, uruchamiał worker'y zgodnie z tą liczbą, przez co pojawiały się konflikty o zasoby i niestabilne błędy testów. Wystarczyło jawnie ograniczyć worker'y przez --workers=4, żeby dopasować się do naszego rzeczywistego przydziału.
Odkryliśmy też, że większa równoległość ujawniła warunki wyścigu w naszej logice sprzątania po testach. Testy integracyjne, które działały dobrze sekwencyjnie, zderzały się przy równoległym uruchamianiu, powodując błędy idempotencyKey. Rozwiązanie wymagało przemyślenia, jak testy czyszczą po sobie: zamiast śledzić pojedyncze ID rezerwacji, zaczęliśmy wyszukiwać rezerwacje po eventTypeId, co wcześniej pomijało rezerwacje tworzone pośrednio przez testowany kod (#26269, #26278, #26283).
Nawet nasze testy zrzutów ekranu wymagały poprawki (#26247). Różne runner'y mają subtelne różnice w renderowaniu fontów, więc dodaliśmy deviceScaleFactor: 1, żeby rendering był spójny, i lekko podnieśliśmy próg diffów z 0.05 do 0.07, aby uwzględnić różnice w wygładzaniu bez maskowania prawdziwych regresji.
Szybsze tempo często ujawnia ukryte sprzężenia w systemie. To sprzężenie zawsze tam było; po prostu nie było go widać przy niższej prędkości. Naprawienie tych problemów właśnie umożliwia trwałe przyspieszenie.
Zasada 1: Skróć ścieżkę krytyczną przez projektowanie zależności
U nas dużym problemem było to, że lint blokował rzeczy, których nie powinien blokować. Gdy twoje testy E2E trwają 7-10 minut, blokowanie ich przez 2-3 minuty lintu i sprawdzania typów tylko wydłuża cały workflow. Lint skończy się dużo wcześniej niż E2E, więc czemu nie puścić ich równolegle?
Rozdziel niezależne zestawy testów
Nasze testy integracyjne i główny zestaw E2E nie potrzebują buildu API v2, więc usunęliśmy tę zależność (#26170). Z drugiej strony testy E2E API v2 nie potrzebują głównego buildu web. Każdy zestaw testów zależy teraz tylko od tego, czego naprawdę potrzebuje, co daje maksymalną równoległość.
Połącz zadania preflight
Mieliśmy osobne zadania do wykrywania zmian w plikach (changes) i sprawdzania etykiety E2E (check-label). Każde zadanie ma narzut startowy: uruchomienie runnera, checkout kodu, odtworzenie kontekstu. Łącząc je w jedno zadanie prepare, wyeliminowaliśmy zbędny narzut (#26101). Poszliśmy dalej i przenieśliśmy kroki instalacji zależności również do zadania prepare, oszczędzając kolejne 20 sekund na workflow dzięki usunięciu jeszcze jednej granicy zadania (#26320).
Zrób z raportowania coś, co nie blokuje
Raporty z testów E2E są cenne, ale nie powinny stać na ścieżce krytycznej. Przenieśliśmy zadania merge-reports, publish-report i cleanup-report do osobnego workflow uruchamianego przez workflow_run (#26157). Teraz inżynierowie mogą od razu ponownie uruchomić nieudane zadania, bez czekania na wygenerowanie raportu.
name: E2E Report
on:
workflow_run:
workflows: ["PR Update"]
types:
- completed
Zasada jest prosta: dajmy luz i pozwólmy zadaniom E2E startować wcześniej, a nie później. Nie blokuj długich zadań krótkimi zależnościami, chyba że istnieje prawdziwa zależność. Czas wall-clock twojego CI zależy od ścieżki krytycznej, a każda zbędna zależność ją wydłuża.
Zasada 2: Cache jest królem (ale tylko jeśli trafia)
Skuteczne cachowanie wymaga kluczy, które unieważniają się wtedy, kiedy powinny, pozostają stabilne wtedy, kiedy powinny, oraz magazynu cache, który z czasem pozostaje zdrowy.
Korzystaj z lookup-only
Nasze najbardziej spektakularne przyspieszenie cache wynikało z prostego spostrzeżenia: pobieraliśmy około 1.2GB danych z cache, nawet gdy nie musieliśmy niczego instalować. Cache istniał, trafienie było udane, ale i tak płaciliśmy koszt pobrania, zanim odkryliśmy, że możemy pominąć krok instalacji.
Naprawą było użycie sprawdzeń cache lookup-only (#26314). Mamy osobne kroki uruchamiane przed wszystkimi zadaniami, które pilnują, że zależności są zainstalowane, więc gdy uruchamiają się poszczególne zadania, wiemy już, że cache istnieje. Zanim spróbujemy odtworzyć pełny cache, najpierw sprawdzamy, czy istnieje, bez pobierania. Jeśli wszystkie cache'e trafiają, pomijamy cały przepływ odtworzenia i instalacji. Bez pobierania, bez instalacji, od razu przechodzimy do właściwej pracy. Samo to oszczędziło sporo czasu w każdym scenariuszu trafienia cache, a przy dobrze zaprojektowanych kluczach cache powinno to być większość uruchomień.
- name: Check yarn cache (lookup-only)
if: ${{ inputs.skip-install-if-cache-hit == 'true' }}
uses: actions/cache/restore@v4
id: yarn-download-cache-check
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
lookup-only: true
Zastosowaliśmy tę samą logikę do instalacji przeglądarek Playwrighta (#26060). Ponieważ mamy dedykowany krok, który zapewnia, że przeglądarki Playwrighta są zcache'owane przed uruchomieniem zadań testowych, możemy użyć tego samego podejścia lookup-only. Jeśli cache Playwrighta trafia, pomijamy instalację całkowicie. Teraz najpierw sprawdzamy, a dopiero potem decydujemy, czy w ogóle coś pobierać.
Rozszerzyliśmy ten wzorzec także na nasz cache bazy danych (#26344). Dzięki sprawdzeniom lookup-only możemy pominąć przebudowę cache DB, jeśli już istnieje. Uprościliśmy też klucz cache, usuwając numer PR i SHA, dzięki czemu można go znacznie częściej ponownie wykorzystać między uruchomieniami.
Poprawność cache
Poprawność cache ma takie samo znaczenie jak szybkość cache. Odkryliśmy, że nasz wzorzec glob **/node_modules/ łapał apps/web/.next/node_modules/, czyli output buildu Next.js zawierający symlinki i wygenerowane pliki, które zmieniają się podczas buildów. To prowadziło do uszkodzonych archiwów cache z błędami przy rozpakowywaniu tar (#26268). Naprawa była prostą regułą wykluczenia, ale znalezienie jej wymagało dokładnego zrozumienia, co dokładnie jest cachowane i dlaczego.
Wprowadziliśmy też automatyczne czyszczenie cache (#26312). Gdy PR zostaje scalony albo zamknięty, usuwamy teraz powiązane z nim cache'e buildu. Dzięki temu nasz magazyn cache pozostaje lekki i nie gromadzi starych wpisów, które nigdy więcej nie będą użyte. Przy okazji uprościliśmy format klucza cache, usuwając zbędne elementy, takie jak runner.os i node_version, które dodawały złożoność bez poprawy trafień.
delete-cache-build-on-pr-close:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: blacksmith-2vcpu-ubuntu-2404
env:
CACHE_NAME: prod-build
steps:
- name: Delete cache-build cache
uses: useblacksmith/cache-delete@v1
with:
key: ${{ env.CACHE_NAME }}-${{ github.event.pull_request.head.ref }}
prefix: "true"
Turbo Remote Caching
Zaktualizowaliśmy Next.js do 16 (#26093), co skróciło nasze buildy z 6+ minut do około 1 minuty (ukłony dla zespołu Next.js za ogromne usprawnienia). Po włączeniu Turbo Remote Caching kolejne buildy zajmują tylko 7 sekund, żeby pobrać wynik z cache, zamiast około 1 minuty dla pełnego buildu.
Włączyliśmy też Turbo Remote Caching dla naszych testów E2E API v2 (#26331). Wcześniej każdy shard E2E przebudowywał pakiety platformy od zera. Teraz każdy shard korzysta ze zdalnego cache Turbo, a my przyspieszyliśmy kompilację TypeScript przez Jest, włączając isolatedModules i wyłączając diagnostykę w CI, co znacząco skraca czas startu testów.
Zasada 3: Skaluj testy przez shardowanie
Gdy masz ponad 1,100 przypadków testowych w 82 plikach testów E2E, uruchamianie ich sekwencyjnie zostawia wydajność na stole. Shardowanie testów, czyli dzielenie zestawu testów na kilka równoległych runnerów, to najprostszy sposób na skrócenie czasu wall-clock dla dużych zestawów testów. Mieliśmy już shardowanie w głównym zestawie E2E, ale zwiększyliśmy je z 4 do 8 shardów (#26342), co skróciło całkowity czas zestawu E2E o kolejną minutę. Nasze testy E2E API v2 nadal działały jako jedno zadanie.
Podzieliliśmy nasze testy E2E API v2 na 4 równoległe zadania, korzystając z wbudowanej opcji --shard w Jest (#26183). Każdy shard działa niezależnie z własnymi usługami Postgres i Redis, a artefakty są nazwane unikalnie dla każdego shardu. To, co wcześniej zajmowało ponad 10 minut jako jedno zadanie, teraz kończy się znacznie szybciej dzięki rozłożeniu pracy na cztery runner'y.
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}
EXIT_CODE=$?
echo "yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }} command exit code: $EXIT_CODE"
exit $EXIT_CODE
Shardowanie na większą skalę ujawnia też problemy infrastrukturalne, których możesz nie zauważyć przy sekwencyjnym uruchamianiu. Gdy kilka zadań próbuje jednocześnie uzupełnić ten sam cache bazy danych, pojawiają się warunki wyścigu. Rozwiązaliśmy to, tworząc dedykowane zadanie setup-db, które uruchamia się przed wszystkimi zadaniami testów E2E i integracyjnych (#26171). To pojedyncze zadanie raz uzupełnia cache bazy danych, a wszystkie zadania zależne odtwarzają go potem z tego źródła. Bez wyścigów, bez podwójnej pracy.
Stworzyliśmy też dedykowany workflow dla testów jednostkowych API v2, oddzielając je od głównego zestawu testów (#26189). Dzięki temu mogą działać równolegle z innymi sprawdzeniami, zamiast rywalizować o zasoby w jednym monolitycznym zadaniu testowym.
Efekt kumulacyjny
Żadna z tych zmian osobno nie skróciłaby naszego czasu CI o 83%. Zysk bierze się z ich połączenia.
Mądre zależności pozwalają zadaniom startować wcześniej. Skuteczne cachowanie oznacza, że te zadania spędzają mniej czasu na przygotowaniu. Shardowanie sprawia, że właściwe wykonywanie testów dzieje się równolegle. Razem ściskają ścieżkę krytyczną z każdej strony.
Inwestycja kumuluje się z czasem. Każdy PR dostaje teraz feedback szybciej. Inżynierowie dłużej pozostają w flow. Pokusa zbierania zmian w paczki maleje, co oznacza mniejsze, łatwiejsze do zreviewowania diffy. Jakość kodu rośnie, bo pętla feedbacku jest ciaśniejsza.
Będziemy dalej inwestować w wydajność CI, bo zwrot jest realny i mierzalny. Celem nie jest szybkość sama w sobie; chodzi o to, by inżynierowie mogli szybciej dostarczać wysokiej jakości oprogramowanie. Gdy CI jest szybkie, działanie sprawnie i utrzymywanie wysokich standardów pomagają sobie nawzajem, zamiast się wykluczać.
Wspomniane pull requesty
Fundament: migracja z Buildjet do Blacksmith
- #26247 - Migracja workflowów GitHub z Buildjet do Blacksmith
Stabilność testów
-
#26269 - Napraw niestabilne testy e2e z izolowanymi sesjami użytkownika
-
#26278 - Ustabilizuj testy e2e dzięki zakresowanym locatorom i deterministycznym harmonogramom
-
#26283 - Popraw izolację testów dla testów e2e managed scheduling link
Turbo Remote Caching
-
#26093 - Aktualizacja do Next 16 (buildy skrócone z 6+ minut do około 1 minuty)
-
#26331 - Włącz Turbo Remote Caching (kolejne buildy zajmują 7 s)
Zależności zadań i optymalizacja workflowów
-
#26101 - Połącz zadania changes i check-label w prepare
-
#26170 - Rozdziel testy inne niż API v2 od buildu API v2
-
#26157 - Spraw, by zadania raportów E2E nie blokowały, przenosząc je do osobnego workflow
-
#26320 - Przenieś zadanie deps do kroku zadania prepare, żeby oszczędzić około 20 s na workflow
Korzystaj z lookup-only
-
#26314 - Użyj sprawdzenia cache lookup-only, aby pominąć pobieranie w zadaniu deps
-
#26060 - Pomijaj instalację Playwrighta przy trafieniu cache
-
#26344 - Użyj lookup-only dla cache DB i uprość klucz cache
Poprawność cache
-
#26268 - Wyklucz .next/node_modules z cache yarn, aby zapobiec uszkodzeniom
-
#26312 - Usuń wpisy cache-build po zamknięciu PR
Shardowanie testów i infrastruktura
