1. Cache Dockera i szybkość docker build
Działający Dockerfile już mamy: kontener startuje, port przekierowany, wszystko wygląda przyzwoicie. Ale mały projekt jest zwodniczy: na nim prawie każdy Dockerfile wydaje się „normalny". docker build zadziałał, kontener się uruchomił — zwycięstwo. Prawdziwy ból zaczyna się następnego dnia, gdy zmieniasz jedną linię kodu, znowu uruchamiasz budowanie… a ono znowu ciągnie się jak serial na 12 sezonów. W Javie odczuwa się to szczególnie mocno: zależności dużo, budowanie ciężkie, a cierpliwość człowieka — infinite resource.
Cache Dockera nie jest po to, żeby „zawsze budować błyskawicznie". Jest po to, żeby Docker mógł uczciwie powiedzieć: „Oto te kroki na pewno się nie zmieniły, już je robiłem, nie będę powtarzać". I im lepiej rozumiemy, jak Docker podejmuje tę decyzję, tym łatwiej nam zaprojektować Dockerfile tak, żeby codzienne zmiany w src/ nie zamieniały się w „przebudowujemy cały świat od nowa".
2. Kroki Dockerfile i warstwy obrazu
Dockerfile często postrzegany jest jako „po prostu lista poleceń". Dla Dockera to raczej przepis budowania: idzie przez niego krok po kroku, od góry do dołu. Każdy krok daje pośredni wynik, który Docker może zapamiętać i potem ponownie wykorzystać. Właśnie ten „zapamiętany wynik kroku" w naszym uproszczonym modelu dydaktycznym będziemy nazywać warstwą (layer).
Wyobraź sobie obraz jak warstwowe ciasto: na dole baza (np. Linux + JDK), powyżej — Twoje dodatki (katalog roboczy, jar, zmienne środowiskowe, entrypoint). Docker „piecze" każdą warstwę osobno. I jeśli dolne warstwy się nie zmieniły, nie musi ich ponownie „wypiekać".
Dla przejrzystości można wyobrazić sobie budowanie tak (uproszczone):
flowchart TD
%% Uproszczony model: jeden krok Dockerfile = jedna warstwa
A["Dockerfile: krok 1"] --> B[Layer 1]
B --> C["Dockerfile: krok 2"]
C --> D[Layer 2]
D --> E["Dockerfile: krok 3"]
E --> F[Layer 3]
F --> G["Wynikowy image = warstwy 1+2+3"]
W rzeczywistości jest trochę więcej niuansów: część instrukcji wpływa na system plików, część — na metadane. Ale dla zrozumienia cache wystarczy jedna idea: Docker buduje obraz jako łańcuch kroków, a cache działa po krokach.
3. Layer, image, container
Bardzo typowe wczesne zamieszanie: człowiek słyszy słowo „warstwa", przypomina sobie „warstwę kontenera", „warstwę systemu plików" — i wszystko skleja się w jedną bryłę. Odlejmy to ostrożnie.
Image — to wynik budowania. Jest przechowywany na dysku jako zestaw warstw (plus konfiguracja). Można go uruchamiać wiele razy, ale sam w sobie nie „działa" i nie słucha na porcie.
Container — to uruchomiony egzemplarz image. Kontener żyje, dopóki żyje główny proces (dla nas to zazwyczaj java -jar ...). U kontenera pojawia się writable layer (zapisywalna warstwa ponad obrazem), do której proces może pisać pliki podczas działania.
A warstwy obrazu — to właśnie te „kawałki" zbudowanego image, które Docker próbuje ponownie wykorzystać przy następnym docker build.
Zestawmy to w krótkiej tabeli, żeby mózg nie trzymał wszystkiego jako mgłę:
| Termin | Prostymi słowami | Kiedy pojawia się | Dlaczego ważne dla nas dziś |
|---|---|---|---|
| Layer | Wynik kroku budowania Dockerfile, który można ponownie wykorzystać | Podczas docker build | Od warstw zależy szybkość przebudowania |
| Image | Gotowy „szablon" aplikacji (warstwy + konfiguracja) | Po docker build | To uruchamiamy i przekazujemy innym |
| Container | Uruchomiony proces z image | Po docker run | Żyje, dopóki żyje proces Java |
Jeśli uprościć do jednego zdania, warstwa — to o budowaniu, a kontener — o uruchamianiu. Dziś celujemy w szybkość budowania, więc wszystko kręci się wokół warstw.
4. Cache Dockera: hit, miss, inwalidacja
Teraz najsmaczniejsza część: czym jest cache Dockera nie jako „magia", lecz jako zrozumiały mechanizm. Podczas docker build Docker idzie przez Dockerfile od góry do dołu i na każdym kroku pyta siebie: „Czy mam już wynik dokładnie takiego samego kroku przy dokładnie takich samych danych wejściowych?" Jeśli tak — następuje cache hit, krok nie wykonuje się ponownie. Jeśli nie — cache miss, krok się wykonuje, a Docker zapisuje nowy wynik do cache.
W wyjściu budowania zazwyczaj widać to jako znaczniki CACHED (w wyjściu BuildKit) lub Using cache (w starszych formatach). Sens jest jeden: Docker nie robi pracy po raz drugi.
Weźmy całkowicie minimalny Dockerfile dla naszego serwisu (na razie bez „dorosłej" optymalizacji — ważny jest princip). Zakładamy, że w build/libs leży już jeden gotowy bootJar, a wewnątrz obrazu kładziemy go pod stabilną nazwą app.jar:
# Obraz bazowy z JDK — zmienia się rzadko, więc to dobra "dolna warstwa" dla cache
FROM eclipse-temurin:25-jdk
# Katalog roboczy wewnątrz obrazu (metadane + tworzenie katalogu jeśli potrzeba)
WORKDIR /app
# Kopiujemy gotowy bootJar pod stałą nazwę wewnątrz obrazu
COPY build/libs/*.jar app.jar
# Punkt wejścia kontenera: właśnie ten proces będzie "głównym" (PID 1)
ENTRYPOINT ["java", "-jar", "app.jar"]
Przy pierwszym budowaniu Docker wykona wszystko uczciwie. Przy drugim (jeśli jar się nie zmienił) powie mniej więcej tak:
[1/4] FROM eclipse-temurin:25-jdk CACHED [2/4] WORKDIR /app CACHED [3/4] COPY build/libs/*.jar app.jar CACHED [4/4] ENTRYPOINT ["java", "-jar", "app.jar"]
Ważne jest zrozumienie jednej prostej rzeczy: cache nie „przyspiesza wszystkiego po kolei". Przyspiesza tylko to, co się nie zmieniło.
I tu pojawia się jeszcze jeden przydatny termin — inwalidacja cache. To sytuacja, gdy Docker nie może użyć cache dla kroku, bo zmieniło się coś ważnego: sama instrukcja Dockerfile lub wejściowe pliki, których ta instrukcja używa. Na przykład jeśli zmienił się jar, to warstwa z COPY ... app.jar musi być przebudowana, bo faktycznie kopiujemy inny plik. I to normalne: chcemy przecież dostać obraz z nową wersją aplikacji, a nie „ładny cache".
5. Kaskadowa inwalidacja cache
To właśnie ten moment, gdzie wiele osób po raz pierwszy łapie „aha!". Dockerfile czyta się od góry do dołu, a każdy krok buduje się na wierzchu wyniku poprzedniego. Dlatego jeśli zmienił się krok nr 2, to krok nr 3 już nie może być „dokładnie taki sam": jego warstwa bazowa jest teraz inna, nawet jeśli polecenie w kroku nr 3 formalnie się nie zmieniło.
To właśnie kaskadowa inwalidacja: zmiana wczesnego kroku często zmusza do przebudowania wszystkiego poniżej, jak domino.
Spójrzmy na prosty łańcuch:
# Obraz bazowy: jeśli go zmienisz (tag/wersję), zepsuje się cache wszystkich kroków poniżej FROM eclipse-temurin:25-jdk # Metadane/przygotowanie katalogu: zazwyczaj cache'uje się stabilnie WORKDIR /app # Ta warstwa zmienia się, gdy zmienia się jar (a więc Twój kod) COPY build/libs/*.jar app.jar # Każdy krok poniżej COPY zostanie przeliczony, jeśli COPY stał się cache miss (kaskada) ENTRYPOINT ["java", "-jar", "app.jar"]
Jeśli zmienia się tylko app.jar, Docker przebudowuje warstwę COPY, a wszystko poniżej też liczy się od nowa (w naszym przykładzie to tylko ENTRYPOINT). A FROM i WORKDIR pozostają zcache'owane.
A teraz wyobraź sobie inny obraz: „nie chce mi się myśleć, kopiuję wszystko":
FROM eclipse-temurin:25-jdk WORKDIR /app # Szerokie polecenie: wejściem kroku staje się prawie całe repozytorium COPY . . # Każda zmiana w projekcie (łącznie z "drobiazgami") zmusi teraz do przebudowania RUN ./gradlew bootJar # Ważne: w exec-form wildcard nie jest rozwijany przez powłokę, więc `*.jar` sam się nie podstawi ENTRYPOINT ["java", "-jar", "build/libs/*.jar"]
Tu jeden „niewinny" krok COPY . . zależy prawie od każdej zmiany w projekcie: poprawiłeś plik .md, dodałeś linię w src/, IDE stworzyło jakiś plik pomocniczy (jeśli zapomniałeś o .dockerignore) — i już, warstwa się zmieniła. A znaczy to, że kolejny krok RUN ./gradlew bootJar wykona się ponownie. I właśnie tu programista Java zaczyna się smucić, bo bootJar — to ciężki krok.
Główna myśl dnia: cache — to nie „włączona galka". Cache — to inżynierska nagroda za to, że ustrukturyzowałeś kroki tak, żeby rzadko się inwalidowały.
6. Java/Gradle i problemy z cache
Projekty Java mają nieprzyjemną cechę: nawet mała aplikacja ciągnie solidny zestaw zależności, a budowanie często obejmuje kompilację, testy (niekiedy), pakowanie jar i jeszcze kilka radości życia. Plus Gradle ma swoją logikę pobierania zależności, własny cache i własne pierwsze „rozgrzewki". W efekcie docker build staje się albo przyjemnym codziennym narzędziem, albo „czasowym podatkiem" za każdą linię kodu.
Żeby poczuć, dlaczego kolejność kroków jest tak ważna, trzeba zobaczyć prostą rzecz: w projekcie Gradle różne pliki zmieniają się z różną częstotliwością. Kod w src/ dotykasz cały czas. build.gradle.kts i settings.gradle.kts — zauważalnie rzadziej. A sama lista zależności (szczególnie w projekcie dydaktycznym) może nie zmieniać się tygodniami. Właśnie tu cache staje się sojusznikiem: jeśli zbudujesz Dockerfile tak, żeby rzadko zmieniające się części trafiały do wczesnych kroków i były cache'owane, to codzienne zmiany w kodzie będą dotykać tylko późnych kroków.
Teraz jeszcze nie refaktoryzujemy Dockerfile. Ale diagnoza jest już ważna: dla Java/Gradle zła struktura Dockerfile zazwyczaj oznacza nie „minus 5 sekund", lecz „wszystko przebudowuje się przez 3–10 minut, i zaczynasz podejrzewać, że Docker Cię nie lubi". Docker Cię lubi. Po prostu nie jest telepatą.
7. Gdzie zobaczyć cache
Teoria o cache brzmi pięknie, dopóki nie zobaczysz jej na własne oczy. Dobra wiadomość: Docker całkiem uczciwie pokazuje w build output, co wziął z cache, a co przebudował. I widać to nawet bez specjalnych poleceń analizy — wystarczy uważnie patrzeć na budowanie.
Wyobraźmy sobie, że pracujemy w korzeniu repozytorium docker-java-catalog-service/ i używamy naszego prostego Dockerfile, który kopiuje już zbudowany jar. Kolejność działań wygląda tak:
# Budujemy jar lokalnie (to osobny cache — gradle cache, nie docker cache) ./gradlew bootJar # Pierwsze budowanie obrazu: warstw w cache jeszcze prawie nie ma, kroki wykonają się realnie docker build -t docker-java-catalog-service:day5 . # Drugie budowanie bez zmian: oczekujemy zobaczyć dużo CACHED docker build -t docker-java-catalog-service:day5 .
Pierwsze budowanie niemal na pewno wykona kroki realnie. Drugie powinno pokazać dużo CACHED. I to jest logiczne: nic nie zmieniałeś, Docker nie musi cierpieć po raz drugi.
Teraz kluczowy moment: jeśli zmieniasz kod, to jar się zmienia, i krok COPY build/libs/*.jar app.jar będzie cache miss. W wyjściu zobaczysz, że właśnie ten krok wykonuje się od nowa, a poprzednie pozostają CACHED. To właśnie to rozumne zachowanie: przebudowujemy tylko to, co realne stało się inne.
I odwrotnie, jeśli przypadkowo zmieniasz coś wczesnego (np. FROM lub WORKDIR, albo robisz szeroki COPY . .), to uruchamiasz kaskadę: Docker nie będzie mógł używać cache niżej w łańcuchu, bo warstwa bazowa się zmieniła. Wygląda to jak „dlaczego w ogóle wszystko się przebudowało, przecież prawie nic nie zrobiłem". W rzeczywistości zrobiłeś — zmieniłeś to, od czego zależy wszystko pozostałe.
8. Typowe błędy przy pracy z warstwami
Błędy wokół cache najczęściej zdarzają się nie z powodu „skomplikowanego Dockera", lecz z powodu naszych oczekiwań. Czekamy, że system zgadnie: „no przecież to drobiazg", a system jest uparty i uczciwy — porównuje kroki i dane wejściowe. Żadnych uczuć, żadnej empatii, tylko warstwy. Nawet kod Java czasem wykazuje więcej współczucia, rzucając zrozumiały wyjątek.
Błąd nr 1: mylenie warstwy obrazu z kontenerem, a potem próba „wyczyszczenia cache" przez usunięcie kontenera.
Kontener — to uruchomienie, warstwy — to budowanie. Usunąwszy kontener (docker rm), nie usuwasz automatycznie warstw obrazu i build cache. I odwrotnie: można mieć idealny cache budowania i przy tym uruchamiać nowe kontenery ile razy chcesz. To różne części świata.
Błąd nr 2: uznawanie cache hit za „przyspieszenie w ogóle" i nie zauważanie, że wejściowe pliki kroku cały czas się zmieniają.
Docker cache'uje krok tylko wtedy, gdy pokrywają się instrukcja i jej wejścia. Jeśli kopiujesz cały projekt jednym poleceniem COPY . ., to wejściem staje się „prawie wszystko", i każde kichnięcie inwaliduje cache. Potem wydaje się, że „cache nie działa", choć w rzeczywistości działa… po prostu ma prawie nic do ponownego wykorzystania.
Błąd nr 3: oczekiwanie, że jeśli zmienił się jeden plik, to przebuduje się tylko jeden krok.
Zmiana często wywołuje kaskadę: zmieniła się wczesna warstwa — przebudowały się wszystkie dolne. To normalne i logiczne, jeśli pamiętać, że Docker buduje obraz jako łańcuch „na wierzchu wyniku poprzedniego". I właśnie dlatego kolejność kroków w Dockerfile — to nie kosmetyka, lecz narzędzie zarządzania szybkością.
Błąd nr 4: zapomnienie, że cache żyje lokalnie, i zdziwienie, że na innej maszynie budowanie jest „nagle wolne".
Cache — to nie magia chmury (domyślnie). Jeśli zbudowałeś obraz na swoim laptopie, a potem kolega buduje go na czystej maszynie, nie ma Twoich zcache'owanych warstw. U niego będzie „pierwsze budowanie" ze wszystkimi ciężkimi krokami. To nie awaria — to uczciwa rzeczywistość.
Błąd nr 5: celowe lub przypadkowe wyłączanie cache, a potem narzekanie na Docker za powolność.
Jeśli uruchamiasz budowanie z flagą --no-cache, Docker dosłownie wykonuje Twoją prośbę: „nie używaj cache". Niekiedy jest to przydatne do sprawdzenia, ale jeśli robisz tak cały czas, dobrowolnie zamieniasz każde budowanie w pierwsze. To jak za każdym razem reinstalować IDE, żeby „na pewno było czysto".
GO TO FULL VERSION