Jak zoptymalizować pipeline CI/CD, by skrócić czas buildów nawet o połowę

0
41
4/5 - (1 vote)

Nawigacja:

Po co skracać czas buildów i skąd się biorą opóźnienia

Efekty szybszego pipeline: krótszy feedback, mniej kontekstu, niższe koszty

Skrócenie czasu pipeline CI/CD z godziny do 15 minut zmienia codzienną pracę programistów. Feedback po kilku minutach zamiast po godzinie oznacza, że zmiana jest świeża w głowie, łatwiej znaleźć błąd i go naprawić. Znika potrzeba „wracania” mentalnie do zadania sprzed kilku godzin.

Krótszy pipeline obniża też liczbę równoległych zadań na dewelopera. Zamiast skakać między trzema branchami i czterema ticketami, można skupić się na jednym, bo cykl „commit → review → zielony build” mieści się w jednym bloku pracy. To prosta droga do mniejszej liczby błędów i wyższego morale zespołu.

Następny aspekt to koszty. Długi pipeline to długie zużycie CPU, dysku i sieci. W środowiskach chmurowych przekłada się to bezpośrednio na większe rachunki. Nawet w on-premise, każda minuta maszyny zajętej przez nieoptymalne joby to mniej zasobów na inne projekty.

Objawy niewydolnego pipeline: czekanie, omijanie testów, rosnące kolejki

Najbardziej namacalny sygnał to deweloper mówiący: „wrzucam commit, idę na kawę, bo pipeline trwa”. Jeśli w zespole stało się to normą, pipeline jest za wolny. Pojawia się też pokusa omijania pełnych testów, puszczania „tylko części”, co przenosi problemy dalej w proces.

Kolejny objaw to rosnące kolejki jobów. Gdy narzędzie CI pokazuje czekanie w kolejce dłuższe niż sam build, oznacza to, że brakuje runnerów lub konfiguracja priorytetów jest zła. Deweloperów mało obchodzi, czy czekają w kolejce, czy w jobie – realny czas feedbacku i tak rośnie.

Wreszcie, pipeline zaczyna być obchodzony bokiem: ręczne deploye, branchowanie bez CI, „lokalne testy zamiast CI”. Jeśli ludzie przestają ufać CI, to znak, że czas i stabilność pipeline’u są na tyle słabe, że system przestaje spełniać swoją rolę.

Źródła opóźnień: testy, zależności, niepotrzebne kroki, słaba infrastruktura

Najczęściej największy udział w czasie pipeline’u mają testy: unit, integration, e2e, bezpieczeństwa, regresyjne. Gdy wszystkie odpalają się przy każdym commicie, bez priorytetyzacji, szybko robi się z tego kilkanaście–kilkadziesiąt minut. Do tego dochodzą testy niestabilne, które trzeba odpalać ponownie.

Drugie źródło problemów to zależności: każdorazowe pełne npm install, mvn dependency:resolve czy pip install bez cache potrafią trwać dłużej niż same testy. Jeśli do tego dochodzi ściąganie dużych obrazów Dockerowych z publicznego rejestru, pipeline znacząco traci na szybkości.

Trzeci element to niepotrzebne kroki: generowanie raportów, które nikt nie czyta, powtarzane kompilacje, wielokrotne buildy tych samych artefaktów dla różnych środowisk. Czwarty – słaba infrastruktura: shared runner na wolnym HDD, brak równoległości, brak lokalnych cache czy mirrorów zależności.

Czas jednego builda a przepustowość całego systemu CI

Istnieje różnica między optymalizacją czasu pojedynczego pipeline’u a zwiększeniem przepustowości całego systemu CI. Można mieć jeden bardzo szybki build, ale jeśli jest odpalany rzadko i sekwencyjnie, system nadal będzie blokował zespół.

Z drugiej strony, dobrze zrobiona równoległość i sensowne kolejkowanie potrafią obsłużyć dziesiątki commitów na godzinę nawet przy nieco dłuższym pojedynczym pipeline. Przy optymalizacji trzeba więc patrzeć zarówno na czas „commit → wynik”, jak i na to, ile takich cykli system obsłuży w ciągu dnia.

Najlepszy efekt daje połączenie obu perspektyw: skrócenie czasu krytycznych etapów (build, szybkie testy) oraz dołożenie równoległości i autoskalowania runnerów tam, gdzie to ma sens.

Jak zmierzyć obecny stan pipeline i znaleźć wąskie gardła

Metryki z narzędzia CI: czas etapów, kolejka, CPU i IO

Pierwszy krok to zebranie twardych danych. Większość narzędzi CI (GitHub Actions, GitLab CI, Jenkins, CircleCI, Azure Pipelines) prezentuje czas trwania poszczególnych jobów, a często także czas oczekiwania w kolejce. Warto przejrzeć kilka–kilkanaście ostatnich runów z typowego dnia pracy.

Do analizy przydają się przede wszystkim:

  • łączny czas pipeline’u (od pierwszego joba do ostatniego),
  • czas najdłuższego joba w każdym pipeline,
  • czas spędzony w kolejce przed startem jobów,
  • podstawowe metryki hosta: CPU, RAM, IO, sieć (jeśli dostępne).

Jeśli narzędzie CI nie daje gotowych wykresów CPU/IO, dobrym obejściem jest proste logowanie czasu lokalnych operacji (np. instalacji zależności) i porównanie ich między runami. Różnice rzędu kilku minut często wskazują na wąskie gardła IO albo sieci.

Prosty audyt pipeline’u: rozpisanie kroków z czasami

Skuteczną techniką jest ręczne rozpisanie pipeline’u w formie listy kroków wraz z czasami. Dla kilku ostatnich runów można stworzyć tabelę: etap → job → czas → typ zadania (build/test/deploy/inne). To wymusza zrozumienie, co dokładnie się dzieje oraz jak często pewne kroki się powtarzają.

Pomocne jest pogrupowanie jobów według typu:

  • kompilacja i build artefaktów,
  • instalacja zależności,
  • testy jednostkowe, integracyjne, e2e, bezpieczeństwa,
  • operacje infrastrukturalne (tworzenie środowisk, migracje DB),
  • publikacja artefaktów, obrazów Docker, deployment.

Takie zestawienie pozwala szybko odpowiedzieć na pytania: gdzie uciekają minuty, które kroki są powtarzane, które testy dominują czas pipeline’u, a które joby są prawie puste, ale generują narzut uruchomienia.

Identyfikacja wąskich gardeł i powtarzanych kroków

Po zebraniu danych trzeba wskazać wąskie gardła. Najczęściej będą to:

  • pojedynczy, bardzo długi job (np. „test-all” trwający 20–30 minut),
  • instalacja zależności za każdym razem od zera,
  • budowanie obrazu Docker od podstaw przy każdym pipeline,
  • czas oczekiwania w kolejce powyżej kilku minut.

Oprócz tego trzeba wypatrzyć kroki powtarzane w kilku miejscach, np. kompilację wykonywaną zarówno w jobie testowym, jak i w jobie deploymentowym, albo generowanie tego samego bundla frontendu w wielu równoległych jobach.

Dobrym kandydatem do ingerencji są także testy flakujące. Test, który raz przechodzi, raz nie, prowokuje wielokrotne reruny całych etapów. To często kilkanaście minut marnowanego czasu jedynie po to, by „trafiło się” na zielony wynik.

Ustalenie celu: ile czasu i gdzie chcemy zaoszczędzić

Randomowa optymalizacja pipeline’u daje słabe efekty. Potrzebny jest konkretny cel, np.:

  • czas pipeline’u dla PR: maksymalnie 10–15 minut,
  • czas pełnego pipeline’u na main: maksymalnie 30–40 minut,
  • testy jednostkowe: poniżej 5 minut,
  • czas oczekiwania w kolejce: poniżej 1 minuty.

Cele trzeba powiązać z typami pipeline’ów. Nie każdy commit wymaga tych samych testów. Inne oczekiwania można mieć dla prostego PR z drobną poprawką, a inne dla nightly builda czy pipeline’u release’owego.

Dobrym podejściem jest ustalenie „budżetu czasowego” dla etapów krytycznych. Na przykład: od commit do końca unit testów nie więcej niż 7 minut, a wszystkie cięższe testy (integration, e2e) mogą kończyć się już po merge do main lub w nocnych runach.

Przykład: jeden ciężki job „test-all” vs rozbicie na moduły

Częsty scenariusz: monorepo lub większa aplikacja ma jeden job „test-all”, który:

  • instaluje wszystkie zależności,
  • buduje całą aplikację,
  • odpala wszystkie testy jednostkowe i integracyjne,
  • generuje raporty z pokrycia kodu.

Taki job bywa prosty w konfiguracji, ale rośnie do 20–30 minut. Rozbicie go na kilka mniejszych jobów – np. test-unit-backend, test-unit-frontend, test-integration – uruchamianych równolegle na osobnych runnerach sprawia, że najdłuższy z nich trwa np. 8–10 minut, a łączny czas pipeline’u spada o połowę.

Warunek: wspólne kroki (np. build artefaktu) powinny wykorzystywać cache lub artefakty, a nie powtarzać kompilację w każdym jobie. W przeciwnym razie zyska się równoległość kosztem większego obciążenia runnerów i rachunku za CI.

Projekt pipeline’u: sensowna struktura stage’y i zasada „build once, deploy many”

Podział na etapy i minimalny przepływ

Dobry pipeline CI/CD ma niewiele klarownych etapów, np.:

  • validate (lint, format, szybka kompilacja),
  • build (kompilacja, packaging, tworzenie artefaktu),
  • test (unit, integration, e2e smoke),
  • security/quality (SAST, skan zależności),
  • deploy (do środowisk: dev/stage/prod).

Klucz polega na tym, aby nie dublować tych samych czynności między etapami. Artefakt z etapu build powinien być jedyną bazą do dalszych kroków. Jeśli każdy etap kompiluje aplikację na nowo, przepala się czas i zasoby.

Dobrze zaplanowany podział na stage’e umożliwia też szybkie zakończenie pipeline’u przy pierwszej awarii. Jeśli testy jednostkowe padają w ciągu 3 minut, nie trzeba uruchamiać całej drogiej części z testami e2e i skanami bezpieczeństwa.

Eliminacja duplikacji: jeden artefakt, różne środowiska

Zasada „build once, deploy many” chroni przed sytuacją, w której różne środowiska (dev, stage, prod) dostają artefakty zbudowane z innych commitów lub na innych maszynach. Proces jest wtedy niepowtarzalny i trudniejszy do debugowania.

Rozsądny przepływ wygląda tak:

  • build tworzy jeden spójny artefakt (np. JAR, kontener Docker, paczkę frontendu),
  • artefakt jest wersjonowany i publikowany do rejestru (artifact repository, registry),
  • deploy do dev/stage/prod tylko pobiera ten artefakt i używa konfiguracji per środowisko.

Konfiguracja środowisk (adresy usług, klucze, feature flags) powinna być oddzielona od kodu i artefaktu. Można używać zmiennych środowisk, configów w repo z infrastrukturą, systemów typu Vault. Dzięki temu build pozostaje identyczny, a zmienia się tylko otoczenie.

Wczesne odrzucanie zmian: szybkie walidacje przed ciężkimi testami

Drogi etap testów e2e i integracyjnych powinien uruchamiać się tylko dla zmian, które przeszły szybkie walidacje. Typowy zestaw „tanich” kroków na początku pipeline’u to:

  • lint i formatowanie kodu,
  • szybka kompilacja (bez pełnych testów),
  • podstawowe testy jednostkowe (np. tylko na zmienionych modułach).

Jeśli na tym etapie coś się sypie, pipeline kończy się w 2–5 minut. Oszczędza to zasoby przeznaczone na drogie testy i przyspiesza feedback dla dewelopera. Dopiero po „zielonym” wyniku tych wstępnych kroków warto odpalać resztę.

Częstym błędem jest odpalanie pełnych testów e2e od razu po buildzie, bez wcześniejszej filtracji. Gdy w kodzie są proste błędy składni czy brakujące importy, cały ciężki etap jest wykonywany bez sensu.

Porządek w definicji pipeline: czytelny YAML zamiast chaosu

Nawet najlepsze pomysły optymalizacyjne giną, jeśli konfiguracja CI jest nieczytelna. Dobrze jest trzymać się kilku zasad:

  • rozbij długie definicje na pliki wczytywane przez include/extends (GitLab) czy reusable workflows (GitHub Actions),
  • grupuj joby według typu (build, test, deploy) i używaj wspólnych szablonów,
  • unikaj kopiowania tych samych kroków (używaj anchors, templates, composite actions),
  • trzymaj zmienne globalne (np. wersje narzędzi) w jednym miejscu.

Czysta definicja YAML ułatwia analizę, co naprawdę jest potrzebne, a co powstało historycznie i od dawna nie ma sensu. Przy dużych projektach sama refaktoryzacja plików CI potrafi odkryć kilka zbędnych jobów, które tylko dublują pracę.

Osobne pipeline’y dla PR, main i release, ale z wspólnymi komponentami

Uproszczenie logiki często polega na rozdzieleniu pipeline’ów według typu wydarzenia:

  • pipeline dla PR/feature branch – szybki, ograniczony zestaw testów, bez deployu,
  • pipeline dla main – pełniejszy zestaw testów i walidacji, przygotowanie do release,
  • pipeline release/tag – pełne testy, skany, deploy do produkcji.

Mechanizmy warunkowe: które pipeline’y w ogóle powinny się uruchamiać

Nie każdy commit wymaga pełnego, ciężkiego pipeline’u. CI musi rozróżniać typ zmiany i adekwatnie reagować. Służą do tego mechanizmy warunkowe: only/except, rules, if:, ścieżki plików, tagi.

Przykład: zmiana w README czy plikach dokumentacji nie musi odpalać builda i testów. Podobnie modyfikacja w katalogu infra/ może wymagać innych kroków niż zmiana w backendzie.

Dobry układ to połączenie warunków na gałąź (PR/main/release) z warunkami na ścieżki plików. Dzięki temu ciężkie testy integracyjne ruszają tylko po zmianach w krytycznych modułach, a drobne poprawki przechodzą przez skróconą ścieżkę.

Cache i artefakty: jak nie kompilować i nie pobierać tego samego w kółko

Różnica między cache a artefaktami

Cache służy do przyspieszania kolejnych jobów lub runów (np. katalogi zależności, build tool cache). Artefakty to produkty jednego joba, które inne joby konsumują (np. paczka aplikacji, skompilowany frontend).

Cache może być współdzielony między pipeline’ami, ale zazwyczaj nie jest wersjonowany wprost. Artefakty powinny być silnie powiązane z konkretnym commitem lub wersją – to podstawa „build once, deploy many”.

Jeśli mieszamy te pojęcia, łatwo o problemy: joby odtwarzające build z cache, który nie pasuje do aktualnego kodu, albo deployment korzystający z przypadkowo nadpisanego katalogu.

Strategia cache’owania zależności

Najwięcej zysku daje cache’owanie zależności języka i narzędzi:

  • node_modules lub cache pnpm/yarn,
  • katalogi .m2, ~/.gradle,
  • paczki Pythona w ~/.cache/pip,
  • moduły Go, cargo dla Rust itd.

Klucz do cache powinien zależeć od deklaracji zależności (np. package-lock.json, pom.xml, requirements.txt), a nie od losowych danych. Częsty wzorzec: hash pliku z zależnościami + nazwa joba + system operacyjny.

Jeśli cache kluczowany jest wyłącznie po gałęzi, każda zmiana zależności generuje problemy z niespójnością. Zamiast przyspieszenia pojawiają się trudne do reprodukcji błędy.

Cache przyrostowy i fallback

Narzędzia CI zwykle wspierają fallback do „najbliższego” cache. To przydaje się, gdy hash zależności jest nowy, ale istnieje wcześniejszy cache o podobnym kluczu.

Przykład: klucz główny to hash package-lock.json, a klucz zapasowy to branch-name. Gdy lockfile się zmienia, pipeline próbuje użyć cache z tej samej gałęzi jako przybliżenia, a brakujące paczki doinstalowuje.

Dobry kompromis: niewielki zysk czasu przy pierwszym runie po zmianie zależności i pełne korzyści przy kolejnych runach.

Artefakty jako spoiwo między etapami

Artefakty powinny łączyć etapy build → test → deploy. Budujemy aplikację raz, pakujemy wynik do artefaktu (archiwum, paczka, obraz) i przekazujemy go dalej.

W prostych przypadkach wystarczy archiwum z binarką lub bundlami frontendu. Bardziej złożone projekty korzystają z rejestrów: Docker registry, Maven repository, npm registry. Pipeline buduje artefakt, publikuje go do rejestru, a kolejne joby tylko go pobierają.

Przekazywanie kodu źródłowego zamiast gotowego produktu prowadzi do dublowania kompilacji. Przy kilku testowych jobach czas potrafi wzrosnąć o kilkadziesiąt procent.

Praktyczne pułapki przy użyciu cache i artefaktów

Najczęstsze problemy:

  • zbyt duże artefakty (gigabajtowe archiwa),
  • cache współdzielony między różnymi gałęziami bez izolacji,
  • cache nadpisywany przez joby o różnych wersjach narzędzi,
  • brak limitu retencji artefaktów i cache – rejestry zapychają się do granic.

Rozwiązanie jest proste: ograniczenie rozmiaru (archiwizowanie tylko tego, co potrzebne), wyraźne nazewnictwo, oddzielenie cache per system/wersja toolchainu oraz sensowna polityka czyszczenia.

Cache kompilacji i warunkowe buildy

Przy monorepo i dużych projektach można iść krok dalej i cache’ować same wyniki kompilacji. Narzędzia typu remote cache (np. w ekosystemie Bazel czy Gradle) trzymają skompilowane moduły, które nie zmieniły się między commitami.

Łącząc to z analizą zależności (co faktycznie zostało ruszone w danym commicie), da się kompilować jedynie podzbiór pakietów. Zamiast 15 minut pełnego builda, kilka minut na zmienione moduły.

Działa to dobrze, jeśli strukturę projektu zaprojektowano z myślą o częściowej kompilacji. Przy wielkim, spłaszczonym projekcie bez jasno zdefiniowanych modułów zyski będą mniejsze.

Stalowe rury z manometrami w przemysłowej instalacji
Źródło: Pexels | Autor: Pixabay

Równoległość i rozbijanie zadań: jak wykorzystać wielu workerów

Rozsądne dzielenie na joby

Podział na niezależne joby umożliwia równoległe wykonanie pracy na wielu runnerach. Nie chodzi jednak o maksymalne rozdrobnienie, ale o podział według naturalnych granic: moduły, komponenty, typy testów.

Przykładowy podział:

  • backend – build + testy jednostkowe,
  • frontend – build + testy jednostkowe,
  • testy integracyjne – osobny job na gotowym obrazie,
  • skany bezpieczeństwa – niezależne od testów funkcjonalnych.

Jeśli każdy job trwa 5–10 minut i ruszają równolegle, łączny czas pipeline’u wyznacza najdłuższy z nich, nie suma. To duża przewaga nad pojedynczym, monolitycznym jobem typu „all-in-one”.

Parametryzacja jobów i matrix builds

Wielu dostawców CI umożliwia matrix builds: ta sama definicja joba uruchamiana z różnymi parametrami (np. wersje JDK, Node, systemy operacyjne). To naturalne miejsce na równoległość.

Matrix jest szczególnie przydatny przy bibliotekach i narzędziach, które trzeba przetestować na kilku platformach. Zamiast ręcznie tworzyć 5 jobów, definiuje się jedną macierz, a CI uruchamia je równolegle.

Trzeba jednak pilnować limitów: zbyt duża macierz potrafi zalać infrastrukturę i spowolnić inne pipeline’y. Lepiej utrzymywać rozsądny zestaw krytycznych kombinacji niż „testować wszystko ze wszystkim”.

Równoległość wewnątrz testów

Nie wszystkie optymalizacje muszą dotyczyć samego CI. Czasem prościej jest usprawnić narzędzia testowe. Większość frameworków obsługuje równoległe uruchamianie testów na wielu wątkach lub workerach.

Przykład: zamiast jednego joba uruchamiającego testy sekwencyjnie, można odpalić je w tym samym jobie, ale z --max-workers ustawionym adekwatnie do liczby CPU. Różnica między jednym wątkiem a kilkoma bywa ogromna.

W połączeniu z równoległymi jobami (np. podział testów na moduły) daje to efekt kaskadowy. Duży projekt potrafi zejść z godzinnych testów do kilkunastu minut.

Bilans między równoległością a kosztami

Więcej jobów to większe obciążenie runnerów i potencjalnie wyższy koszt (szczególnie w płatnych planach CI). Trzeba ustalić limit równoległych wykonów per projekt lub per organizację.

Jeśli pipeline jest często uruchamiany, zbyt agresywna równoległość może zablokować zasoby dla innych zespołów. Dobrym kompromisem jest przydział „twardego” limitu dla projektów i osobne, wyższe limity dla krytycznych gałęzi (np. main, release).

Oszczędności można szukać także w reużywaniu runnerów (np. maszyny on-prem zamiast wyłącznie cloudowych minut), ale trzeba zachować dyscyplinę w czyszczeniu środowisk między jobami.

Rozbijanie zadań długich i flakujących

Długi job testowy warto rozbić na kilka mniejszych, nawet jeśli przez chwilę wymaga to ręcznej pracy. Typowy ruch: podział zestawu testów na grupy tematyczne lub według folderów.

Jeśli część testów jest niestabilna (flaky), warto przenieść je do osobnego joba, który nie blokuje całego pipeline’u. W skrajnym przypadku można go oznaczyć jako „non-blocking” i monitorować osobno, zamiast powtarzać cały pipeline.

Po takim rozbiciu łatwiej też mierzyć, które grupy testów są najbardziej problematyczne i wymagają refaktoryzacji albo izolacji środowiska.

Optymalizacja testów: co naprawdę musi się uruchamiać przy każdym commicie

Klasyfikacja testów według kosztu i wartości

Testy można klasyfikować nie tylko według typu (unit, integration, e2e), ale też według kosztu i wartości dla feedbacku:

  • szybkie, tanie i krytyczne (unit, lint, podstawowe scenariusze),
  • średnio kosztowne, ale istotne (integration na kluczowych ścieżkach),
  • drogie, długie i często niestabilne (pełne e2e, performance).

Szybkie i krytyczne testy powinny biec zawsze dla każdego commitu. Droższe można uruchamiać rzadziej: tylko na main, tylko nocą, tylko dla zmian w kluczowych modułach.

To nie oznacza rezygnacji z jakości. Chodzi o inteligentne rozmieszczenie testów w czasie, tak by feedback dla developera przychodził w ciągu minut, a nie godzin.

Testy przyrostowe i selekcja na podstawie zmian

Przy większych repozytoriach ogromny zysk daje selekcja testów na podstawie tego, co faktycznie się zmieniło. Znając zależności między modułami, można wyznaczyć minimalny zestaw testów dotkniętych zmianą.

Prosty wariant: odpalenie testów tylko w katalogach, w których zmieniono pliki, plus ich bezpośrednie zależności. Bardziej zaawansowany – użycie narzędzi budujących graf zależności (np. w monorepo frontowym) i wyliczanie „dotkniętych” pakietów.

Takie podejście bywa łączone z pełnym cyklem testów odpalanym cyklicznie (np. nocą) lub przy release. Na PR deweloper dostaje szybki feedback z ograniczonego zakresu, a całość jest weryfikowana później.

Smoke tests zamiast pełnych e2e

Testy e2e są najdroższe: wymagają środowiska, danych, często kilku usług. Zamiast uruchamiać pełny zestaw przy każdym commicie, można stosować krótkie smoke tests.

Smoke tests to pojedyncze scenariusze sprawdzające, czy aplikacja w ogóle działa: można się zalogować, główna funkcja działa, nie ma krytycznych błędów. Taki zestaw da się odpalić w kilka minut.

Pełne e2e warto zostawić dla merge do main, release lub cyklu nocnego. Pipeline PR może na tym sporo zyskać, zwłaszcza gdy e2e wykorzystują zewnętrzne usługi lub skomplikowaną infrastrukturę.

Stabilizacja i higiena testów

Flakujące testy niszczą efektywność pipeline’u. Każdy flaky generuje powtórne uruchomienia, niepewność wyniku i rosnącą frustrację. Często prostsze jest zainwestowanie kilku godzin w naprawę lub izolację środowiska niż w kolejne „retry”.

Nawet, jeśli tymczasowo trzeba oznaczyć test jako „skipped” lub przenieść go do osobnego, nieblokującego joba, pipeline na tym zyskuje. Później taki test można przywrócić, gdy problem zostanie zdiagnozowany.

Dobre praktyki: deterministyczne dane testowe, brak zależności od czasu rzeczywistego, czyszczenie środowiska między scenariuszami oraz unikanie nadmiernego współdzielenia stanu.

Równoległe uruchamianie testów z podziałem na shard’y

Przy bardzo dużej liczbie testów można je dzielić na shard’y – podzbiory, które biegną w osobnych jobach. Każdy job dostaje inny fragment listy testów.

Kluczowe jest równomierne rozłożenie: shard’y o podobnym czasie trwania, a nie losowy podział, w którym jeden job ma 80% ciężkich testów. Można to robić na podstawie historycznych czasów lub prostego algorytmu dzielącego zestawy według katalogów.

W praktyce kilka shardów zasilanych tym samym artefaktem builda potrafi skrócić czas dużego zestawu testów nawet kilkukrotnie, przy odpowiednim dobraniu liczby runnerów.

Obrazy bazowe, kontenery i środowisko buildowe

Stabilne, zoptymalizowane obrazy bazowe

Każdy pipeline zużywa czas na przygotowanie środowiska: instalacja narzędzi, zależności systemowych, paczek. Znaczna część tego może zostać przeniesiona do obrazów bazowych.

Dobry obraz bazowy dla CI zawiera:

  • konkretną wersję języka (JDK, Node, Python itd.),
  • najczęściej używane narzędzia (build system, CLI usług chmurowych),
  • konfigurację minimalną, bez zbędnych pakietów.

Budując taki obraz raz na jakiś czas i używając go w jobach, usuwa się z pipeline’u powtarzające się instalacje. Różnica w czasie przygotowania środowiska często sięga kilku minut na job.

Wielostopniowe obrazy Docker i kompozycja

Wielostopniowe buildy i lekkie obrazy wynikowe

Rozbudowane aplikacje często kończą jako ciężkie obrazy Docker z toolchainem, cache’ami i zbędnymi plikami. To spowalnia zarówno build, jak i deployment.

Lepszym podejściem jest wielostopniowy Dockerfile: w pierwszym etapie pełne środowisko buildowe, w drugim – tylko artefakt i minimalny runtime. Build dzieje się w „ciężkim” etapie, a do uruchomienia trafia lekki obraz.

Przykład dla aplikacji Javy lub Node: etap builder z Mavenem/Node, etap runtime z samym JDK/Node i skopiowanym jar lub katalogiem dist. Takie podejście przyspiesza też skany bezpieczeństwa i rollouty.

Pre-builty obrazów dla częstych ścieżek

Jeśli kilka projektów używa podobnego stacka, można przygotować wspólne obrazy z preinstalowanymi zależnościami (np. globalne paczki npm, narzędzia testowe, CLI do chmury). Obraz jest budowany rzadko, a pipeline’y tylko go wykorzystują.

Przy zmianie wersji narzędzi buduje się nowy wariant obrazu i stopniowo przełącza pipeline’y. Znika potrzeba instalacji tego samego w każdym jobie.

Trzeba tylko pilnować, by obraz nie puchł z kolejnymi narzędziami „na wszelki wypadek”. Lepiej mieć kilka wyspecjalizowanych obrazów niż jednego „kombajna”.

Standaryzacja środowiska buildowego między zespołami

Różne zespoły często konfigurują własne obrazy, skrypty i wersje narzędzi. To przynosi chaos i niewidoczne koszty: debugowanie różnic, trudniejsze cache’owanie, brak wspólnych optymalizacji.

Lepiej mieć centralnie utrzymywaną paczkę obrazów i szablonów pipeline’u, którą projekty dziedziczą (np. przez extends lub wspólne workflow’y). Uproszcza to również migracje wersji JDK/Node i eliminację starych narzędzi.

Do tego dochodzi prosty changelog dla obrazów CI: co się zmieniło, które projekty mogą na tym skorzystać, jakie są wymagania migracji.

Hermetyzacja zależności i deterministyczne buildy

Im mniej zależności od hosta, tym stabilniejszy i szybszy pipeline. Build powinien w pełni mieścić się w kontenerze: z konkretną wersją kompilatora, narzędzi i paczek.

Użycie lockfile’ów (np. package-lock.json, poetry.lock, pom.xml z pinowanymi wersjami) zmniejsza niespodzianki przy cache’owaniu i reużywaniu artefaktów. Build jest powtarzalny, więc można śmiało stosować agresywniejsze cache.

Eliminacja „magii” z hosta (dodatkowe binarki, nieudokumentowane zmienne środowiskowe) ogranicza różnice między lokalnym środowiskiem a CI.

Optymalizacja warstw Docker pod cache

Docker łapie cache per warstwa. Jeśli kolejność instrukcji w Dockerfile jest losowa, cache praktycznie nie działa przy drobnych zmianach w kodzie.

Najpierw powinny iść rzadko zmieniające się warstwy (instalacja systemowych pakietów, narzędzi, dependency’ów), dopiero na końcu kopiowanie kodu aplikacji. Dzięki temu cięcie czasu builda obrazu przy drobnej zmianie w kodzie staje się realne.

Przy projektach z dependency managerem warto oddzielić kopiowanie plików definiujących zależności od reszty kodu, tak by same zależności mogły być cache’owane na dłużej.

Konfiguracja runnerów, agentów i infrastruktury CI

Dobór typu maszyn do rodzaju obciążenia

Nie każdy pipeline korzysta tak samo z CPU, RAM i dysku. Buildy kompilacyjne (C++, Java) zwykle potrzebują mocnego CPU, testy integracyjne – RAM i I/O, e2e – sieci i stabilnych usług towarzyszących.

Jeśli wszędzie używa się tych samych, ogólnych maszyn, pojawia się marnowanie zasobów lub wąskie gardła. Lepszy jest podział: dedykowane klasy runnerów dla ciężkich buildów, inne dla lekkich zadań typu lint lub deploy.

W wielu systemach CI (GitLab, GitHub, Jenkins) można wybierać maszyny na podstawie tagów/labeli. Pipeline określa, gdzie powinien biec dany job, co ułatwia profilowanie.

Autoskalowanie runnerów i kolejki

Statyczna pula runnerów, dobrana „na oko”, często kończy się długimi kolejkami w godzinach szczytu i niewykorzystanymi zasobami poza nimi. Rozwiązaniem jest autoskalowanie.

Runner może powstawać na żądanie (np. maszyna w chmurze, pod w Kubernetesie), działać tylko podczas joba i być niszczony po zakończeniu. Pozwala to agresywnie zwiększyć równoległość w godzinach szczytu bez stałych kosztów.

Ważne jest ustawienie limitów maksymalnej liczby równoległych instancji, tak by nie przeciążyć API dostawców chmurowych czy własnych usług zaplecza.

Warm-up runnerów i preinstalowane narzędzia

Tworzenie „nagiej” maszyny do każdego joba i instalacja wszystkiego od zera to strata czasu. Można użyć dwóch poziomów optymalizacji.

Po pierwsze, obrazy maszyn lub szablony (np. AMI, image w GCE) z preinstalowanymi narzędziami CI, dockerem i podstawowym toolsetem. Maszyna startuje szybciej i od razu nadaje się do pracy.

Po drugie, mechanizmy warm-up: okresowe utrzymywanie małej liczby gotowych runnerów w stanie „idle”, które natychmiast przejmują joby. Reszta skalowana jest na żądanie.

Lokalne cache na runnerach

Cache zależności i artefaktów w zewnętrznym storage (S3, GCS) bywa wolny przy tysiącach małych plików. Przy powtarzalnych zadaniach na tych samych runnerach można wykorzystać lokalny dysk.

Typowe zastosowanie: mirror repozytoriów pakietów (npm, Maven, PyPI), cache kompilacji (Gradle, Bazel, ccache). Runner, który obsługuje kilka kolejnych jobów tego samego projektu, korzysta z tych samych cache’y bez ciągłego pobierania z sieci.

Trzeba znaleźć balans między ephemeralnymi runnerami (bezpieczne, czyste środowisko) a runners „sticky” dla danego projektu, które zyskują na lokalnym cache’u.

Separacja runnerów dla różnych klas pipeline’ów

Pipeline’y PR i pipeline’y release mają inne wymagania. Te drugie nie mogą stać w kolejce, gdy deweloperzy zalewają system małymi commitami.

Rozwiązanie: osobne pule runnerów dla krytycznych gałęzi (main, release) i dla gałęzi deweloperskich. Dzięki temu ważne buildy zawsze znajdą wolne miejsce.

Można też dodać priorytety jobów – np. deploy do produkcji ma wyższy priorytet niż pełne testy feature branchy.

Monitoring metryk CI i profilowanie infrastruktury

Bez metryk nie widać, czy kolejne optymalizacje mają sens. System CI powinien wysyłać dane do centralnego monitoringu: czasy jobów, kolejki, zużycie CPU/RAM/dysku, błędy runnerów.

Na tej podstawie łatwo wychwycić np. to, że maszyny mają za mało RAM i często swapują, że storage artefaktów jest wąskim gardłem albo że autoskalowanie reaguje zbyt wolno.

Dobrym nawykiem jest okresowy przegląd: top N najdłuższych jobów, top N pipeline’ów z najdłuższą kolejką, największe artefakty. To naturalne cele do dalszej optymalizacji.

Bezpieczeństwo a wydajność runnerów

Security ma wpływ na czas buildów. Zbyt restrykcyjne, ale źle dobrane polityki (np. częste skany pełnego systemu plików w każdym jobie) potrafią zwiększyć czas wykonania o kilkanaście minut.

Lepszym podejściem jest selektywne skanowanie: obrazy kontenerowe skanowane asynchronicznie po zbudowaniu, skany SAST/DAST odpalane zgodnie z harmonogramem, a nie przy każdym drobnym commicie.

Konfigurując agenty, trzeba też zadbać o bezpieczne przechowywanie sekretów (KMS, Secret Manager, sealed secrets), ale tak, by dostęp do nich nie wymagał wielu wolnych round-tripów do zewnętrznych usług.

Konfiguracja storage na artefakty i logi

Artefakty i logi potrafią zdominować czas uploadu/downloadu, szczególnie przy dużych raportach testowych czy paczkach frontendu. Domyślne ustawienia często są mało efektywne.

Warto ustalić klarowną politykę retencji: krótszy czas trzymania artefaktów buildów PR, dłuższy dla release’ów. Tam, gdzie to możliwe, stosować kompresję i dzielenie artefaktów na mniejsze, faktycznie potrzebne fragmenty.

Przy intensywnym ruchu dobrym ruchem jest przeniesienie storage’u bliżej runnerów (ta sama strefa, region), by skrócić opóźnienia sieciowe.

Izolacja środowisk testowych i stubowanie usług

Wiele pipeline’ów traci czas na czekanie na zewnętrzne systemy: bazy współdzielone przez zespoły, płatności sandbox, REST API innych aplikacji. Odpowiedź bywa losowo wolna albo niestabilna.

Lepszym wzorcem jest izolacja środowisk (np. ephemeralne namespace’y w Kubernetesie dla każdego pipeline’u) i agresywne stubowanie lub mockowanie drogich usług zewnętrznych w testach.

Zdejmuje to presję z infrastruktury wspólnej i sprawia, że pipeline’y są bardziej przewidywalne czasowo, co ułatwia dalszą optymalizację.

Najczęściej zadawane pytania (FAQ)

Jak realnie skrócić czas pipeline CI/CD z godziny do 15 minut?

Najpierw zmierz obecny stan: czas całego pipeline’u, czas najdłuższego joba, czas czekania w kolejce, obciążenie CPU/IO. Bez tego strzelasz na ślepo.

Następnie rozbij długie joby (np. jeden „test-all”) na mniejsze, uruchamiane równolegle. Dołóż cache instalacji zależności (npm, Maven, pip), przyspiesz budowę obrazów Docker i usuń niepotrzebne kroki (zbędne raporty, powtórne buildy). Na końcu dostosuj infrastrukturę: więcej runnerów, szybszy dysk, sensowne autoskalowanie.

Skąd biorą się największe opóźnienia w pipeline CI/CD?

Najczęściej z testów odpalanych „hurtowo” przy każdym commicie: unit, integracyjnych, e2e, bezpieczeństwa, regresyjnych. Gdy wszystko leci zawsze i wszędzie, pojedynczy pipeline szybko robi się wielominutowy.

Drugi duży winowajca to zależności instalowane od zera oraz ciężkie obrazy Docker ściągane za każdym razem z zewnętrznych rejestrów. Dochodzą do tego niepotrzebne kroki (raporty, duplikaty buildów) i zbyt słaba infrastruktura: wolne dyski, brak cache, brak równoległości.

Jak zmierzyć i zdiagnozować wąskie gardła w pipeline CI/CD?

Użyj metryk z narzędzia CI: czas całego pipeline’u, czasy poszczególnych jobów, czas oczekiwania w kolejce. Jeśli masz dostęp do metryk hosta, sprawdź CPU, RAM, IO i sieć w trakcie typowego dnia pracy.

Dobrym krokiem jest ręczne rozpisanie pipeline’u jako listy kroków z czasami i typem zadania (build, testy, deploy). Szybko widać wtedy pojedyncze, bardzo długie joby, powtarzane kroki oraz „puste” joby, które generują tylko narzut uruchomienia.

Jakie testy uruchamiać przy każdym commicie, a jakie tylko okresowo?

Dla zwykłych PR-ów skup się na szybkim feedbacku: build, testy jednostkowe i wybrane lekkie testy integracyjne, tak by zmieścić się w 10–15 minut. Cięższe testy (pełne integration, e2e, regresyjne, bezpieczeństwa) można przenieść na merge do main, nightly albo pipeline release’owy.

Praktyczny schemat: „commit → szybkie testy (do kilku minut) → review → merge → pełne testy na main”. Dzięki temu deweloper nie czeka godzinę na wynik, a jakość nadal jest pilnowana przez pełne zestawy testów w późniejszych etapach.

Jak poradzić sobie z rosnącą kolejką jobów w CI?

Najpierw rozdziel problem: czy bottleneckiem jest sama długość jobów, czy brak zasobów. Jeśli joby startują po długim czekaniu, potrzebujesz więcej runnerów, autoskalowania lub lepszej konfiguracji priorytetów (np. pipeline’y dla PR wyżej niż pipeline’y pomocnicze).

Jeśli joby startują od razu, ale trwają bardzo długo, trzeba je skrócić: równoległość, cache, rozbicie „kombajnów” na mniejsze etapy. Z perspektywy dewelopera nie ma znaczenia, czy stoi w kolejce, czy w jobie – ważny jest łączny czas „commit → wynik”.

Jakie korzyści daje skrócenie czasu buildów dla zespołu deweloperskiego?

Najważniejszy efekt to szybszy feedback: zmiana jest świeża w głowie, łatwiej znaleźć i poprawić błąd, nie trzeba mentalnie wracać do zadania po godzinie. Zmniejsza się liczba równoległych kontekstów na osobę, co przekłada się na mniej pomyłek.

Dodatkowo spadają koszty infrastruktury (krótsze użycie CPU, dysku, sieci) i maleje pokusa omijania CI (lokalne testy zamiast pipeline’u, ręczne deploye). Stabilny, szybki pipeline buduje zaufanie do procesu i zwiększa przewidywalność pracy.

Jakie typowe błędy sprawiają, że zespół zaczyna omijać pipeline CI/CD?

Najczęściej: zbyt długi czas oczekiwania na wynik, niestabilne testy flakujące oraz przepakowane pipeline’y, które uruchamiają pełny zestaw ciężkich testów przy każdej drobnej zmianie. W takiej sytuacji ludzie zaczynają robić skróty: lokalne testy, branchowanie bez CI, ręczne deploye.

Rozwiązanie to skrócenie krytycznego odcinka (commit → szybkie testy), uporządkowanie flakujących testów oraz dopasowanie pipeline’ów do typów zmian (inny dla małego PR, inny dla release). Dzięki temu CI przestaje przeszkadzać i znowu staje się naturalną częścią pracy.