Jak przyspieszyć pipeline: cache, równoległość i sprytne artefakty w CI

0
6
Rate this post

Nawigacja:

Po co przyspieszać pipeline i gdzie jest sensowna granica

Czas „okrążenia” jako realny koszt dla zespołu

Czas od commita do wyniku pipeline’u CI to jedna z najbardziej niedocenianych metryk produktywności zespołu. Każda minuta zwłoki to dłuższy czas niepewności: czy zmiana przechodzi testy, czy złamała build, czy można ją spokojnie scalać i wdrażać. Jeżeli pipeline trwa 20–30 minut, deweloper ma silną pokusę, by w tym czasie „zrobić coś innego”, co często kończy się rozproszeniem i przerywaniem kontekstu.

Krótki pipeline (rzędu kilku minut) sprzyja częstym, małym commitom i szybkiemu feedbackowi. Długi pipeline prowokuje do kumulowania zmian i „wypychania” dużych paczek kodu, co z kolei zwiększa ryzyko konfliktów, regresji i trudnych do odtworzenia błędów. Zależność jest prosta: im szybciej feedback, tym niższy koszt naprawy błędów, tym łatwiej utrzymać jakość.

Kiedy optymalizacja ma sens, a kiedy jest sztuką dla sztuki

Nie każdy projekt wymaga pipeline’u trwającego 3 minuty. Dla krytycznych systemów finansowych czy medycznych bardziej opłaca się dłuższa, wielowarstwowa walidacja, niż agresywne skracanie czasu kosztem pokrycia testami. Z drugiej strony, w typowych aplikacjach webowych, gdzie deploymentów jest dużo, pipeline dłuższy niż 10–15 minut zaczyna realnie przeszkadzać w pracy.

Sensowna optymalizacja to taka, która zmniejsza czas oczekiwania bez obniżania zaufania do wyniku. Jeśli przyspieszenie wymaga wyłączenia lintera, coverage albo części testów integracyjnych, to jest to raczej kastracja pipeline’u niż jego optymalizacja. Lepsza strategia to podział na:

  • szybki pipeline na pull requesty (fast checks, smoke tests, kluczowe scenariusze),
  • pełny, bardziej kosztowny pipeline na gałąź główną i nightly.

Takie rozdzielenie pozwala skrócić czas „okrążenia” dla dewelopera, nie rezygnując z głębszej walidacji w późniejszym etapie.

Praktyczne progi czasowe: ile to „szybko”

Rzeczywiste granice „szybko” i „wolno” zależą od technologii, rozmiaru projektu i infrastruktury. Można jednak wskazać typowe progi, które pomagają w rozmowie z zespołem:

  • do 5 minut – luksusowy feedback, zwykle możliwy dla mniejszych projektów lub dobrze zoptymalizowanych monorepo z agresywnym cache i równoległością,
  • 5–10 minut – bardzo dobry wynik dla przeciętnej aplikacji z testami jednostkowymi i podstawowymi integracyjnymi,
  • 10–20 minut – akceptowalne dla większych projektów, ale powyżej 15 minut warto zacząć szukać prostych optymalizacji,
  • powyżej 30 minut – sygnał, że pipeline wymaga analizy: albo jest przeładowany, albo infrastruktura nie nadąża.

Zdarzają się wyjątki, np. ciężkie testy e2e z przeglądarką, environmenty QA zewnętrzne, testy wydajnościowe. W takich przypadkach długi czas jest często nieunikniony, ale można je wydzielić do osobnych pipeline’ów wyzwalanych warunkowo, zamiast obciążać nimi każdy commit.

Różnica między przyspieszaniem a upraszczaniem pipeline’u

Skrócenie pipeline’u można osiągnąć na dwa sposoby: przyspieszając istniejące kroki lub je usuwając. Druga opcja jest kusząca, ale to rozwiązanie krótkoterminowe. Pipeline, który nie sprawdza niczego istotnego, jest szybki – i bezużyteczny.

Zamiast usuwać testy, sensowniejsze jest:

  • podzielenie testów na warstwy (unit, integration, e2e) i uruchamianie ich w różnych momentach,
  • wykorzystanie równoległości i podziału na shardy,
  • cache’owanie zależności i buildów, aby przyspieszyć przygotowanie środowiska,
  • przeniesienie rzadko łapiących problemy testów do pipeline’ów okresowych.

Granica optymalizacji pojawia się tam, gdzie kolejne godziny pracy nad cache i równoległością przynoszą już marginalne zyski (np. skrócenie z 6 do 5 minut). Wtedy rozsądniej zainwestować w jakość testów czy monitoring niż w dalsze „tuningowanie” sekund.

Diagnoza – skąd wiadomo, że pipeline jest naprawdę wolny

Analiza czasu trwania jobów i etapów

Zanim zacznie się majstrowanie przy cache, równoległości czy artefaktach, potrzebna jest diagnoza. Nagminny błąd to „optymalizowanie w ciemno” – wprowadzanie równoległości tam, gdzie główny problem leży w instalacji zależności albo w budowaniu obrazów Docker.

Pierwszy krok to przejście przez historię kilku–kilkunastu ostatnich pipeline’ów i zebranie danych:

  • które joby trwają najdłużej,
  • jak długo trwają poszczególne etapy (build, test, deploy),
  • czy są joby, które często się powtarzają (retry) z powodu flaky testów lub niestabilnej infrastruktury,
  • czy długość pipeline’u jest stabilna, czy czasem skacze (np. od 8 do 25 minut).

Na tej podstawie da się zwykle szybko określić, gdzie jest główne wąskie gardło: testy jednostkowe, testy e2e, docker build, pobieranie zależności czy deployment.

Oddzielenie wolnego pipeline’u od problemów infrastruktury

Wiele osób wrzuca do jednego worka „wolny pipeline” i „wolną infrastrukturę CI”. To nie to samo. Pipeline może być dobrze zaprojektowany, ale:

  • runnery są przeciążone i joby czekają w kolejce,
  • storage (np. NFS, S3, dyski sieciowe) ma duże opóźnienia,
  • sieć do rejestru obrazów (Docker registry) jest wąskim gardłem,
  • serwer baz danych testowych jest współdzielony przez kilka zespołów.

Objawy infrastrukturalne to m.in.:

  • duże różnice w czasie dla tego samego joba między pipeline’ami,
  • częste time-outy przy pobieraniu artefaktów lub cache,
  • długie czasy „pending” jobów przed startem.

W takim scenariuszu kombinowanie przy cache w konfiguracji CI niewiele da, dopóki nie zostanie poprawiona sama infrastruktura (więcej runnerów, lepszy storage, mirror rejestru, lokalne proxy cache pakietów).

Wykrywanie wąskich gardeł: od dependency install po e2e

Typowe kategorie problemów:

  • Dependency install – npm install, pip install, mvn dependency:go-offline; bez cache’u potrafią zjadać większość czasu pipeline’u.
  • Kompilacja i build – webpack, Gradle, Maven, kompilacja C/C++; tutaj często pomaga incremental build i cache wyników.
  • Testy jednostkowe i integracyjne – jeśli są monolityczne, bez podziału na moduły, potrafią się rozrastać do kilkunastu minut.
  • Testy e2e – Selenium, Cypress, Playwright; wolne z natury, wrażliwe na niestabilne środowiska.
  • Docker build – szczególnie w połączeniu z dużymi obrazami bazowymi i słabym cache layerów.

W każdym obszarze inne techniki przynoszą największy zysk. Zanim zacznie się wprowadzać równoległość na oślep, dobrze jest jasno nazwać, które z powyższych sekcji pipeline’u są faktycznym problemem.

Prosty audyt istniejącego pipeline’u krok po kroku

Minimalny audyt można przeprowadzić w kilku krokach, bez specjalistycznych narzędzi APM:

  1. Wybrać 5–10 ostatnich pipeline’ów na głównej gałęzi, które przeszły sukcesem.
  2. Spisać dla każdego:
    • całkowity czas pipeline’u,
    • czasy poszczególnych jobów,
    • czas oczekiwania (pending) przed startem jobów.
  3. Policzyć średni i maksymalny czas dla najdłuższych jobów.
  4. Oznaczyć joby, które:
    • są powtarzalnie najdłuższe,
    • mają największą wariancję czasu (czasem 3 minuty, czasem 12).
  5. Na tej podstawie określić 2–3 główne cele optymalizacji (np. „dependency install dla Node”, „testy e2e”, „docker build”).

Taki audyt zajmuje zwykle mniej niż godzinę, a pozwala uniknąć sytuacji, w której zespół wprowadza rozbudowane cache’y testów, podczas gdy 60% czasu pipeline’u pożera budowanie obrazu Docker bez wykorzystania layer cache.

Metryki przed optymalizacją i po niej

Bez twardych liczb łatwo wpaść w pułapkę optymalizacji „na wyczucie”. Warto przed zmianami zapisać kilka metryk, np.:

  • średni czas pipeline’u z ostatnich 20 przebiegów,
  • średni czas najdłuższych trzech jobów,
  • liczba jobów pending dłużej niż X sekund,
  • procent pipeline’ów zakończonych niepowodzeniem z powodu timeoutów.

Po wdrożeniu cache, równoległości czy zmiany strategii artefaktów te same metryki pokazują, czy zysk jest realny, czy jedynie przeniesiono problem w inne miejsce (np. cache przyspieszył build, ale kolejki runnerów wydłużyły czas oczekiwania).

Podstawy cache w CI – co można, a czego lepiej nie cache’ować

Cache, artefakty i obrazy bazowe – trzy różne mechanizmy

W praktyce DevOps często miesza się trzy pojęcia: cache, artefakty i obrazy bazowe (np. Docker). Pełnią różne funkcje:

  • Cache – służy do przyspieszania powtarzalnych działań w pipeline’ie. Przykład: katalog z zależnościami npm, cache kompilatora, katalog build cache Gradle.
  • Artefakty – to rezultat pracy joba, przeznaczony dla ludzi lub dla kolejnych jobów (np. paczka .jar, zbudowane statyczne pliki, raport testowy).
  • Obraz bazowy – prekonfigurowane środowisko (np. Docker image) z zainstalowanymi narzędziami i zależnościami, z którego startuje job CI.

Mylenie tych pojęć prowadzi do chaosu: trzymanie wszystkiego jako artefaktu, używanie cache jako kanału wymiany plików między jobami czy pakowanie całego builda do obrazu bazowego. Każdy z mechanizmów ma swoje miejsce; sensowna optymalizacja pipeline’u wymaga świadomego wykorzystania wszystkich trzech.

Typowe obszary do cache’owania

Najczęściej cache przynosi realny zysk w trzech kategoriach:

  • Zależności języka:
    • Node.js – katalog node_modules lub cache npm/yarn/pnpm (np. ~/.npm, ~/.cache/yarn),
    • Java – lokalne repozytorium Maven (~/.m2/repository), cache Gradle (~/.gradle),
    • Python – wirtualne środowiska i cache pip (~/.cache/pip),
    • Rust, Go, Ruby, PHP – odpowiednie katalogi modułów/bibliotek.
  • Wyniki kompilacji / build outputs – katalogi typu target, build, dist, o ile narzędzie builda potrafi je wykorzystywać przy kolejnym uruchomieniu.
  • Cache specyficzny dla narzędzi – npm cache, pnpm store, cache kompilatora TypeScript, cache bundlera (np. webpack, esbuild) o ile istnieją mechanizmy incremental.

W większości projektów cache zależności daje największy jednostkowy zysk. Instalacja bibliotek z internetu jest wolniejsza niż lokalne kopiowanie plików, nawet jeśli storage cache CI nie jest idealnie szybki.

Czego lepiej nie cache’ować

Nie każdy katalog, który da się wrzucić do cache, powinien się tam znaleźć. Kilka typowych antywzorców:

  • Wyniki testów – raporty, coverage itp. Zwykle i tak są artefaktami, a ich cache może powodować mylące odczyty (np. stary raport z poprzedniego commita).
  • Pliki generowane z losowością – snapshoty, logi z timestampami, tymczasowe katalogi; ich obecność w cache tylko zwiększa ryzyko błędów.
  • Środowiska lokalne – np. cache w stylu „cały katalog projektu”; może to prowadzić do zaciągania śmieci z poprzednich uruchomień.
  • Duże, rzadko wykorzystywane zbiory danych, których przywracanie trwa dłużej niż ich lokalne wygenerowanie.

Ogólna zasada: cache’ować warto to, co jest deterministyczne, powtarzalne i kosztowne do odtworzenia. Wszystko, co jest niestabilne, zależy od aktualnego czasu, losowości lub zewnętrznych API, lepiej pozostawić poza cache’em.

Cache a deterministyczne buildy

Samo dorzucenie cache do pipeline’u nie gwarantuje stabilności. Jeżeli build nie jest deterministyczny, cache często tylko „pudruje” problemy. Klasyczne symptomy:

  • commit A przechodzi, commit B z minimalną zmianą już nie, bo trafił na inny stan cache’u,
  • lokalnie wszystko działa, a w CI od czasu do czasu coś „magicznie” znika lub się psuje,
  • te same testy potrafią przyspieszyć o kilka minut albo zwolnić bez modyfikacji kodu.

Najczęstsze źródła niedeterministyczności, które w połączeniu z cache’em generują trudne do odtworzenia błędy:

  • brak blokady wersji zależności – brak plików typu package-lock.json, yarn.lock, poetry.lock, Gemfile.lock,
  • build zależny od zegara – timestampy w nazwach plików, generowanie wersji na podstawie czasu systemowego,
  • współdzielony cache między gałęziami bez odpowiednich kluczy – jeden branch „nadpisuje” wyniki drugiego,
  • skrypty buildowe z efektami ubocznymi – usuwanie lub modyfikowanie plików poza katalogami roboczymi.

Zanim cache zostanie rozbudowany, opłaca się:

  • upewnić się, że wersje zależności są zablokowane,
  • odseparować katalogi builda od źródeł (np. build/ poza src/),
  • przemyśleć, czy cache jest współdzielony między branchami i czy to ma sens.

Dopiero na takim fundamencie cache przynosi realne przyspieszenie bez wprowadzania losowości w rezultatach pipeline’u.

Czas życia cache’u i jego „świeżość”

Cache, który nigdy nie jest czyszczony, zamienia się w wysypisko. Zbyt agresywne czyszczenie z kolei redukuje zysk do zera. Trzeba znaleźć pragmatyczny środek:

  • krótkie TTL (np. kilka dni) – dobre dla katalogów zależności, które często się zmieniają; minimalizuje ryzyko ciągnięcia bardzo starego stanu,
  • dłuższe TTL (tygodnie) – przy cache’u narzędzi i runtime’ów, które rzadko się aktualizują,
  • rotacja po wielkości – część systemów CI potrafi usuwać najstarsze wpisy, gdy cache przekracza limit rozmiaru.

Jeśli platforma CI na to pozwala, sensowna praktyka to dodawanie prostego mechanizmu „force refresh”:

  • specjalny tag w commicie (np. [nocache]) powoduje zignorowanie cache,
  • zmienna środowiskowa w pipeline (np. FORCE_CLEAN_BUILD=true) wyłącza użycie cache w wybranych jobach.

Dzięki temu, gdy pojawiają się dziwne błędy, można szybko sprawdzić, czy problem leży w cache, czy w kodzie.

Projektowanie kluczy cache – jak uniknąć kolizji i „zastanego” stanu

Najczęściej bagatelizowany element konfiguracji cache to klucz. Przypadkowy wybór (np. stały string) prowadzi do mieszania stanów buildów, co prędzej czy później kończy się nieodtwarzalnymi błędami.

Przy projektowaniu kluczy dobrze jest odpowiedzieć na kilka pytań:

  • czy cache ma być współdzielony między branchami, czy odseparowany,
  • czy zmiana zależności powinna unieważniać cały cache, czy tylko jego część,
  • czy różne joby mogą korzystać z tego samego cache bez efektów ubocznych.

Elementy składowe dobrego klucza

W praktyce sensowny klucz cache składa się z kilku komponentów:

  • Identyfikator projektu – nazwa repo lub inny stabilny identyfikator,
  • Rodzaj cache’u – np. deps-node, build-gradle,
  • Zakres – branch, gałąź główna, pull request; np. main, feature-*,
  • Hash pliku lock lub innego pliku definiującego zależności.

Przykładowy klucz dla zależności Node.js:

cache:key: "$CI_PROJECT_NAME:deps-node:$CI_COMMIT_REF_SLUG:$(sha256sum package-lock.json | cut -d' ' -f1)"

W wielu systemach CI nie można wykonywać złożonych poleceń bezpośrednio w polu key:. Zamiast tego stosuje się predefiniowane zmienne (np. hash wszystkich plików package-lock.json), albo prostsze podejście: hash całego katalogu z definicją zależności.

Strategie dzielenia cache według gałęzi

Dylemat: czy cache ma być wspólny między gałęziami, czy rozdzielony? Oba warianty mają plusy i minusy.

  • Cache współdzielony (np. po pliku lock):
    • zalety: szybkie buildy na feature branchach zaraz po zmianie na main,
    • wady: ryzyko, że eksperymentalna gałąź „zanieczyści” cache zależności, jeśli lock nie jest używany konsekwentnie.
  • Cache per branch:
    • zalety: izolacja, mniejsze ryzyko kolizji, łatwiejsza diagnostyka problemów,
    • wady: więcej miejsca na storage, dłuższy czas pierwszego pipeline’u na nowej gałęzi.

Praktyczny kompromis to:

  • wspólny cache dla gałęzi długowiecznych (np. main, develop),
  • cache per branch dla dużych, eksperymentalnych feature’ów,
  • okresowe czyszczenie cache’u dla gałęzi, które zostały już zmergowane lub skasowane.

Fallback klucza cache – korzyści i pułapki

Część systemów CI oferuje mechanizm „fallback key”: jeśli nie znaleziono dokładnego klucza, używany jest klucz bardziej ogólny. Przykład:

  • projekt:deps-node:feature-xyz:HASH_LOCK – dokładny klucz,
  • fallback: projekt:deps-node:main:HASH_LOCK.

Taki mechanizm przyspiesza pierwsze buildy na nowych gałęziach, ale wprowadza pewne ryzyko:

  • jeśli zależności na main są już stare, nowy feature może zacząć od nieoptymalnego zestawu paczek,
  • zmiana locka na gałęzi nie od razu przełoży się na nowy cache, jeżeli fallback nadpisuje lokalne wpisy.

Do fallbacków lepiej podchodzić z rezerwą, a nie jako domyślne rozwiązanie. Sprawdzają się głównie tam, gdzie zależności są aktualizowane rzadko, a locki są silnie kontrolowane.

Zardzewiałe rury i zawory przemysłowe w starej hali fabrycznej
Źródło: Pexels | Autor: Pixabay

Równoległość w pipeline – od podziału testów po złożone zależności

Granica sensownej równoległości

Teoretycznie można każdy krok pipeline’u rozbić na kilkanaście jobów. W praktyce każda dodatkowa jednostka równoległości ma swój koszt:

  • czas startu nowego joba (provisioning, pull obrazu),
  • koszt synchronizacji – artefakty, cache, zmienne środowiskowe,
  • więcej miejsc, w których pipeline może się „rozsypać” przez flaki lub problemy sieciowe.

Sensownie zaczynać od podziału tylko tych kroków, które są:

  • najdłuższe i powtarzalnie dominują czas pipeline’u,
  • naturalnie dzielone (np. zestawy testów, moduły monorepo),
  • niezależne od siebie w kontekście danych wejściowych.

Dopiero później, jeśli zysk jest zadowalający, można rozważać bardziej zaawansowane DAG-i.

Prosty fan-out – dzielenie testów poziomo

Najbardziej oczywisty kandydat na równoległość to testy. Zamiast jednego joba z całą paczką, sensowne jest:

  • podział po katalogach (np. tests/unit vs tests/integration),
  • podział po modułach (np. mikroserwisy, pakiety w monorepo),
  • shardowanie – kilka jobów uruchamia ten sam runner testów z innym zakresem (np. --shard=1/4, --shard=2/4).

Korzyści są oczywiste: jeśli testy trwają 20 minut i uda się je równomiernie podzielić na 4 joby po 5–6 minut, moment feedbacku skraca się kilkukrotnie. Problem zaczyna się, gdy:

  • runnerów jest za mało i joby spędzają większość czasu w stanie pending,
  • każdy job osobno instaluje zależności i przygotowuje środowisko – zysk z równoległości jest zjadany przez powielanie tych samych kroków.

Rozwiązaniem bywa:

  • wydzielenie „setup joba”, który przygotowuje artefakty (np. zbudowane binaria) i współdzielony cache,
  • minimalizowanie pracy powtarzanej w każdym jobie (np. korzystanie ze wspólnego obrazu bazowego).

Równoległość a współdzielone zasoby

Wiele projektów zakłada istnienie pojedynczej instancji bazy testowej, kolejki lub zewnętrznego serwisu. Równoległe joby mogą się o nią „bić”, co daje złudne przyspieszenie:

  • testy kończą się szybciej na papierze, ale rośnie liczba retry i flaky testów,
  • czasami pipeline i tak czeka, bo ktoś trzyma lock na bazie.

Bezpieczniejsze wzorce:

  • oddzielne instancje środowisk dla każdego joba (np. osobne schematy bazy, osobne namespace’y w K8s),
  • wyłączenie równoległości tam, gdzie wymagałoby to nieproporcjonalnie dużej przebudowy infrastruktury.

Tu dobrze zadać sobie pytanie: przyspieszenie o 2–3 minuty jest warte migracji bazy na inny silnik albo budowy dedykowanej infrastruktury testowej? Czasem odpowiedź jest uczciwie negatywna.

DAG zamiast liniowego łańcucha

Wiele pipeline’ów jest zbudowanych jak prosta linia: build → testy → docker build → deploy. W praktyce część kroków nie zależy od siebie wprost:

  • statyczna analiza (lint, SAST) może iść równolegle z testami jednostkowymi,
  • build frontendu i backendu można uruchomić równolegle,
  • testy integracyjne mogą startować po zbudowaniu artefaktów, jeszcze zanim zakończy się np. analiza jakościowa.

Model DAG (Directed Acyclic Graph) pozwala zdefiniować zależności między jobami explicite zamiast bazować tylko na „stage’ach”. Korzystając z DAG-ów:

  • skraca się czas oczekiwania na wolne joby – część pracy rusza wcześniej,
  • łatwiej opisać nietypowe sekwencje, np. testy e2e tylko po udanym deployu na środowisko tymczasowe.

Pułapka: nadmiernie skomplikowany DAG szybko staje się nieczytelny. Jeśli nikt poza jego autorem nie wie, dlaczego dany job odpalił się dopiero po trzech innych, utrzymanie takiego pipeline’u będzie problemem.

Ograniczenia równoległości po stronie CI

Równoległość nie istnieje w próżni. Trzeba brać pod uwagę:

  • limity równoległych jobów na projekt / namespace,
  • limity runnerów (szczególnie na self-hosted),
  • limity kosztów, jeśli CI jest płatne per-minuta lub per-maszyna.

Jeśli projekt ma do dyspozycji 4 równoległe joby, podział testów na 10 części i tak nie przyspieszy najbardziej „krytycznej” ścieżki. Realna równoległość kończy się na liczbie dostępnych slotów, wszystko ponad to jest jedynie podziałem pracy, nie faktycznym przyspieszeniem.

Artefakty – co, jak i na jak długo przechowywać

Artefakty jako kontrakt między jobami

Artefakt jest w praktyce formą kontraktu: „job X produkuje pakiet o określonej strukturze, job Y zakłada, że taki pakiet będzie dostępny”. Problem zaczyna się, gdy:

  • artefakty zawierają przypadkowe pliki, bo kopiowany jest cały katalog projektu,
  • format artefaktu zmienia się bez aktualizacji jobów konsumenckich,
  • artefakty są wykorzystywane do rzeczy, do których lepiej użyć cache (np. share’owanie zależności języka).

Bardziej przewidywalne jest podejście:

  • artefakty zawierają tylko to, co jest potrzebne kolejnemu etapowi (np. gotowy plik .jar i manifest),
  • katalog artefaktów ma w miarę stabilną strukturę,
  • w konfiguracji CI jasno wskazuje się, które ścieżki są archiwizowane.
  • Czas życia artefaktów a realne potrzeby

    Systemy CI kuszą prostą opcją typu expire_in: 30 days i temat jest „załatwiony”. Problem w tym, że przechowywanie wszystkiego „na wszelki wypadek” bardzo szybko winduje koszty lub uderza w limity storage’u. Rozsądniej jest podzielić artefakty według ich roli:

  • artefakty operacyjne – potrzebne tylko w ramach jednego pipeline’u (np. build → testy integracyjne),
  • artefakty release’owe – wykorzystywane przy deployu na środowiska wyżej (test, stage, prod),
  • artefakty diagnostyczne – logi, raporty testów, snapshoty środowiska.

Dla każdej grupy sensowny jest inny czas życia:

  • operacyjne – często wystarczy 1–3 dni, bo po tym czasie i tak pipeline jest przestarzały wobec main,
  • release’owe – ich TTL zwykle jest powiązany z cyklem życia danej wersji (np. do czasu kolejnego minor/major albo określony czas po deployu),
  • diagnostyczne – skrócony TTL (np. 7–14 dni), z wyjątkiem krytycznych incydentów, które i tak lądują w osobnym archiwum.

Skutkiem ubocznym zbyt agresywnego skracania TTL jest to, że cofnięcie się do starego pipeline’u w poszukiwaniu artefaktów kończy się komunikatem „not found”. Dla niektórych zespołów to akceptowalne, inne wolą strategię: krótszy TTL w CI, a istotne buildy (np. tagi release’owe) kopiowane do zewnętrznego repozytorium artefaktów (Nexus, Artifactory, S3).

Minimalny zestaw artefaktów w praktyce

Rozsądny punkt wyjścia to zadanie kilku pytań przy definiowaniu artefaktów:

  • który kolejny job realnie używa tego pliku lub katalogu,
  • czy ten sam efekt da się osiągnąć przez cache (np. node_modules),
  • czy artefakt można odtworzyć w rozsądnym czasie z kodu i zależności.

Typowa nadmierna konfiguracja to artefakt zawierający cały katalog repozytorium razem z buildem, logami narzędzi i pośrednimi plikami. Zwykle wystarczy:

  • sam wynik kompilacji (np. build/ lub dist/),
  • plik/opis wersji (VERSION, package.json z numerem wersji, manifest Helm),
  • raporty testów w formacie, który potrafi odczytać UI CI (JUnit XML, HTML coverage).

W jednym z projektów realne cięcie rozmiaru artefaktów nastąpiło dopiero po przejrzeniu zawartości losowej paczki: okazało się, że każdy job przenosił pełne repo (w tym .git) i tymczasowe pliki Dockera. Samo ograniczenie ścieżek artefaktów obniżyło rozmiar o rząd wielkości, bez utraty funkcjonalności.

Artefakty a odtwarzalność buildów

Przyspieszanie pipeline’u często wchodzi w konflikt z wymogami odtwarzalności. Z jednej strony dobrze, gdy z tagu v1.2.3 da się zrekonstruować dokładnie ten sam obraz. Z drugiej, trzymanie wszystkich pośrednich artefaktów miesięcy wstecz rzadko ma sens.

Typowy kompromis:

  • w CI trzymane są artefakty tylko dla main / release/* i tylko przez ograniczony czas,
  • pipeline publikacji releasu przesyła finalny artefakt (np. obraz Dockera, paczkę .zip/.tar.gz) do zewnętrznego rejestru,
  • sam proces wytwarzania artefaktu jest deterministyczny (locki zależności, przypięte wersje narzędzi).

W takiej konfiguracji pipeline może kasować starsze artefakty bez utraty zdolności do odtworzenia releasu: wszystko, co potrzebne, znajduje się w kodzie, lockach i rejestrze artefaktów, a nie w historii CI.

Granice między cache a artefaktami

Cache i artefakty rozwiązują różne problemy, choć na pierwszy rzut oka oba „przenoszą pliki między jobami”. Sensowna granica:

  • cache – przyspiesza powtarzalne kroki (instalacja zależności, kompilacja bibliotek), zwykle jest współdzielony, może się „psuć” i odtwarzać,
  • artefakt – jest częścią procesu biznesowego (build kandydujący do deployu, raport testów), jego zmiana powinna być świadoma.

Wrzuce­nie zależności języka do artefaktów wprowadza pozorny porządek („wiemy dokładnie, na czym działały testy”), ale w dłuższej perspektywie komplikuje proces:

  • utrudnia użycie tych samych zależności w innych pipeline’ach (brak kluczy cache),
  • powoduje niepotrzebne kopiowanie dużych paczek między jobami zamiast lokalnego odświeżania cache’u.

Ze względu na koszty przechowywania i transferu bardziej opłaca się trzymać zależności w cache’u lub w dedykowanym rejestrze (np. prywatny npm/pypi), a artefaktami robić tylko to, co musi być „zamrożone” dla konkretnego pipeline’u lub releasu.

Strategie wersjonowania artefaktów

Sposób wersjonowania artefaktów wpływa zarówno na odtwarzalność, jak i na strukturę cache’u. Najpopularniejsze strategie to:

  • wersje semantyczne powiązane z tagami (v1.4.2),
  • identyfikacja po commit SHA (app-1.4.2+githash),
  • złożone etykiety zawierające gałąź, datę, numer pipeline’u.

Praktyczny model to hybryda: artefakt releasu ma wersję semantyczną, ale w środku lub w metadanych przechowuje też commit SHA, z którego został zbudowany. W codziennym CI można generować artefakty tymczasowe z nazwą opartą o commit lub numer pipeline’u, ale tylko te związane z tagami lub z konkretnymi branchami release’owymi trafiają do „długoterminowego” repozytorium.

Strategie przyspieszania buildów – incremental, pre-build, obrazy bazowe

Incremental builds – kiedy naprawdę coś dają

Budowanie przyrostowe brzmi jak panaceum: „zmieniliśmy tylko jeden moduł, więc przebudujmy tylko ten moduł”. W praktyce efektywność zależy od kilku warunków:

  • system buildów musi poprawnie śledzić graf zależności (np. Bazel, Pants, dobrze skonfigurowany Gradle/Maven),
  • środowisko CI musi mieć stabilny cache (dyskowy lub zewnętrzny),
  • liczba zmian między kolejnymi buildami nie jest na tyle duża, żeby przebudowywać i tak większość projektu.

Dla małych repo incremental często nie ma sensu – narzut na konfigurację i utrzymanie przewyższa zysk. Nabiera znaczenia przy:

  • monorepo z dziesiątkami pakietów/mikroserwisów,
  • ciężkich buildach (np. wielkie projekty Javy, C++, Android/iOS),
  • częstych commitach, gdzie jedna zmiana dotyka tylko niewielkiej części grafu.

Zdarza się też efekt odwrotny: błędnie ustawiony incremental maskuje realne błędy zależności (np. brak zadeklarowanej zależności między modułami). Lokalnie działa, bo wszystko jest „przypadkiem” zbudowane, na czystym CI – wybucha. Wtedy przyspieszanie pipeline’u kończy się podejrzeniem w stronę CI, zamiast w stronę konfiguracji systemu buildów.

Pre-build kroków ciężkich obliczeniowo

Druga popularna strategia to przeniesienie najbardziej kosztownych kroków do wcześniejszej fazy lub osobnego pipeline’u:

  • pre-build obrazów bazowych Dockera z gotowym środowiskiem,
  • prekompilacja rzadko zmieniających się komponentów (np. wspólnych bibliotek),
  • generowanie kodu (API clients, protobufy) w pipeline’ach uruchamianych rzadziej.

Zamiast za każdym razem budować pełny obraz aplikacji od FROM ubuntu:latest i instalować kilkadziesiąt paczek systemowych oraz narzędzi, można:

  1. mieć pipeline aktualizujący raz na jakiś czas obraz bazowy z narzędziami,
  2. w pipeline’ach developerskich używać tego obrazu i dodawać tylko kawałek aplikacyjny.

Zwykle znacząco skraca to sekcję „docker build”, ale też przenosi odpowiedzialność: jeśli obraz bazowy jest przestarzały lub źle zbudowany, wszystkie pipeline’y cierpią jednocześnie. Trzeba więc:

  • zapewnić testy obrazu bazowego (np. smoke test narzędzi w osobnym pipeline’ie),
  • wiązać wersję obrazu bazowego z kodem (tag w Dockerfile, a nie :latest).

Obrazy bazowe dla różnych klas zadań

Kwaśny wariant to jeden „uniwersalny” obraz bazowy zawierający:

  • JDK, Node, Python, Go, Docker-in-Docker,
  • kompilatory C/C++, pakiety systemowe pod wszystko,
  • narzędzia developerskie, których używa tylko jedna podgrupa jobów.

Taki obraz jest ogromny, długo się pobiera, długo buduje i zwykle jest rzadko aktualizowany, bo sam proces jego odbudowy zaczyna być bolesny. Rozsądniejszym wariantem są osobne obrazy:

  • „build-backend” – tylko narzędzia potrzebne do kompilacji backendu,
  • „build-frontend” – stack frontowy z odpowiednim Node, Yarn/npm, narzędziami do bundlingu,
  • „test-e2e” – minimalne środowisko z przeglądarką/driverem, narzędziami testowymi.

Rozbicie na kilka mniejszych obrazów komplikuje konfigurację CI, ale jednocześnie:

  • zmniejsza czas pullowania (frontend nie ściąga narzędzi backendu i odwrotnie),
  • ogranicza blast radius – błąd w jednym obrazie nie blokuje całego pipeline’u,
  • ułatwia aktualizację (można cyklicznie wymieniać tylko część stacku).

Buildy warstwowe i wieloetapowe

W projektach dockerowych naturalne jest wykorzystanie wieloetapowych buildów:

  • etap builder – instalacja zależności buildowych, kompilacja, testy,
  • etap runtime – minimalny obraz z gotową binarką/aplikacją.

Taki podział:

  • pozwala cache’ować warstwy zależności (RUN npm ci, RUN mvn dependency:go-offline) oddzielnie od kodu aplikacji,
  • redukuje rozmiar finalnego obrazu, co przekłada się na szybkość deployu i testów e2e,
  • ułatwia analizę, gdzie dokładnie tracony jest czas (osobno „czas Dockera” i „czas kompilacji w kontenerze”).

Pułapka to zbyt agresywne łączenie kroków w jednym RUN „dla oszczędzenia warstw”. Krótsze warstwy z przemyślanymi granicami (np. osobno instalacja zależności, osobno kopiowanie kodu) dają lepszy cache kosztem kilku dodatkowych MB w rejestrze. Dla szybkości pipeline’u to zwykle opłacalny kompromis.

Podział buildów na ścieżki krytyczne i niekrytyczne

Nie wszystkie buildy są równie ważne z perspektywy czasu feedbacku. Zamiast próbować przyspieszać „wszystko po trochu”, można:

  • zidentyfikować ścieżkę krytyczną – minimalny zestaw kroków, który musi przejść, żeby uznać commit za „nadający się do integracji”,
  • resztę przenieść do pipeline’ów asynchronicznych (np. nocnych, scheduled),
  • część ciężkich analiz (SAST, pełne testy e2e) uruchamiać tylko dla main/PR-ów o określonym priorytecie.

Dla developera kluczowy jest zwykle wynik szybkiego pipeline’u: lint, testy jednostkowe, podstawowe buildy. Pełne testy e2e i analizy bezpieczeństwa mogą dojść później, byle w rozsądnym czasie przed deployem. Próba upchnięcia wszystkiego w jednym „super pipeline’ie” kończy się często tym, że nikt nie patrzy na wyniki dalszych etapów, bo na feedback czeka się zbyt długo.

Wielopoziomowy cache buildów

Łączenie kilku warstw cache może przynieść dodatkowy efekt, ale też wprowadza sporo złożoności. Przykładowy model:

  • lokalny cache systemu buildów (np. Gradle cache, Bazel cache) na dysku runnnera,
  • cache na poziomie CI (np. cache: w GitLab CI) – współdzielony między runnerami,
  • cache w zewnętrznym repo (np. pre-built biblioteki w S3/Nexusie).

Każda warstwa ma inną charakterystykę:

  • lokalny cache jest szybki, ale znika przy rebuildzie runnnera lub w środowiskach ephemeral,
  • cache CI jest wolniejszy, dochodzi transfer sieciowy, ale trwały w skali projektu,
  • Najczęściej zadawane pytania (FAQ)

    Po co w ogóle skracać czas działania pipeline’u CI?

    Krótszy pipeline zmniejsza czas niepewności po commicie. Deweloper szybciej dostaje informację, czy zmiana przeszła testy, czy złamała build i czy można ją bezpiecznie zmergować. Im dłużej to trwa, tym większa pokusa „zajęcia się czymś innym”, co zwykle kończy się utratą koncentracji i częstym przeskakiwaniem kontekstu.

    Krótki czas „okrążenia” sprzyja małym, częstym commitom i szybkiej korekcie błędów. Długi pipeline prowokuje do kumulowania zmian w duże paczki, co zwiększa ryzyko konfliktów, regresji i trudnych do odtworzenia błędów. W praktyce szybki feedback bezpośrednio obniża koszt naprawy problemów i pomaga utrzymać jakość.

    Ile powinien trwać szybki pipeline CI w typowym projekcie?

    Nie ma jednej magicznej liczby, ale da się wskazać sensowne przedziały, które pomagają w rozmowie z zespołem. Dla większości aplikacji webowych i serwisów backendowych:

  • do 5 minut – bardzo szybki, zwykle osiągalny w mniejszych projektach lub dobrze zoptymalizowanych monorepo,
  • 5–10 minut – bardzo dobry wynik dla przeciętnej aplikacji z testami jednostkowymi i podstawowymi integracyjnymi,
  • 10–20 minut – akceptowalny dla większych systemów; powyżej 15 minut zwykle opłaca się poszukać prostych usprawnień,
  • powyżej 30 minut – sygnał do audytu; albo pipeline jest przeładowany, albo infrastruktura nie wyrabia.

Wyjątkiem są ciężkie testy e2e, środowiska QA czy testy wydajnościowe. Tam dłuższy czas często jest nieunikniony, ale takie kroki lepiej wydzielać do osobnych, warunkowo uruchamianych pipeline’ów zamiast obciążać nimi każdy commit.

Jak rozpoznać, czy mój pipeline jest „za wolny”, czy problem leży w infrastrukturze CI?

Wolny pipeline i wolna infrastruktura to dwa różne problemy, choć objaw – długie oczekiwanie na wynik – jest podobny. Sam pipeline może być rozsądnie ułożony, ale runnery przeciążone, storage opóźniony, a sieć do rejestru obrazów zbyt wolna.

Do typowych objawów problemów infrastrukturalnych należą: duże wahania czasu dla tego samego joba w kolejnych uruchomieniach, długie czasy „pending” zanim job w ogóle wystartuje, częste time‑outy przy pobieraniu cache’u czy artefaktów. Jeżeli natomiast czasy jobów są stabilnie długie, główne wąskie gardła najczęściej kryją się w samym pipeline’ie: dependency install, build, testy, docker build.

Jak krok po kroku zdiagnozować, co najbardziej spowalnia pipeline?

Minimalny audyt da się zrobić bez specjalistycznych narzędzi. Wystarczy przejrzeć kilka ostatnich, zakończonych sukcesem pipeline’ów na głównej gałęzi i zebrać podstawowe liczby: całkowity czas, czas poszczególnych jobów oraz czas oczekiwania na ich start.

Następnie warto:

  • zidentyfikować joby powtarzalnie najdłuższe,
  • sprawdzić, które mają największą wariancję czasu (raz 3 minuty, raz 12),
  • określić 2–3 główne cele optymalizacji, np. „npm install”, „testy e2e”, „docker build”.

Taki prosty przegląd często pokazuje, że zamiast wprowadzać skomplikowaną równoległość testów, większy zysk da poprawa cache’u zależności czy wykorzystanie layer cache w obrazach Docker.

Czy przyspieszanie pipeline’u przez wyłączenie części testów ma sens?

Usunięcie testów zawsze „przyspieszy” pipeline, ale to raczej kastracja niż optymalizacja. Taki ruch zwykle zmniejsza zaufanie do wyniku i przerzuca koszt z fazy CI na późniejsze etapy (QA, produkcję). Z technicznego punktu widzenia jest to skrócenie kosztem pokrycia, nie usprawnienie procesu.

Bezpieczniejszym podejściem jest podział na warstwy: szybki zestaw kluczowych testów na pull requesty (lint, unit, smoke) oraz pełniejsza, droższa walidacja na głównej gałęzi i w pipeline’ach okresowych (nightly, weekly). Wybrane testy można też przenieść do osobnych, ręcznie lub warunkowo wyzwalanych pipeline’ów, zamiast całkowicie je usuwać.

Kiedy inwestowanie w cache i równoległość przestaje mieć sens?

Granica opłacalności pojawia się wtedy, gdy kolejne godziny pracy nad tuningiem cache’u i równoległością dają coraz mniejsze zyski czasowe. Skrócenie pipeline’u z 30 do 15 minut ma zwykle wyraźny wpływ na pracę zespołu, ale optymalizacja z 6 do 5 minut często już nie zmienia zachowań deweloperów.

W takim momencie rozsądniej przenieść wysiłek na inne obszary: poprawę jakości testów (mniej flaky), lepszy monitoring i logowanie czy automatyzację czynności okołowdrożeniowych. Pipeline ma być wystarczająco szybki, by nie blokować pracy – niekoniecznie wyśrubowany do absolutnego minimum za cenę dużej złożoności konfiguracji.

Jak zorganizować osobne szybkie i pełne pipeline’y CI?

Sprawdzony wzorzec to dwa poziomy walidacji. Na pull requesty uruchamiany jest szybki pipeline z najważniejszymi, tanimi krokami: lint, testy jednostkowe i krótkie smoke testy krytycznych ścieżek. Dzięki temu deweloper dostaje feedback w kilka minut i może bezpiecznie scalać małe zmiany.

Na gałąź główną oraz w pipeline’ach okresowych (np. nightly) odpalane są pełniejsze zestawy: rozbudowane testy integracyjne, e2e, potencjalnie także testy wydajnościowe. Taki podział pozwala skrócić czas oczekiwania „na co dzień”, nie rezygnując z głębszej walidacji tam, gdzie jest naprawdę potrzebna.