CodeGym /Kursy /Docker for Spring /Warstwy obrazu i cache Dockera

Warstwy obrazu i cache Dockera

Docker for Spring
Poziom 5, Lekcja 0
Dostępny

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".

1
Zadanie
Docker for Spring, poziom 5, lekcja 0
Niedostępne
Ponowne budowanie bez zmian
Ponowne budowanie bez zmian
1
Zadanie
Docker for Spring, poziom 5, lekcja 0
Niedostępne
Cache miss po zmianie pliku jar
Cache miss po zmianie pliku jar
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION