1. Dockerfile: „przepis" na obraz, a nie magiczny przycisk
Teraz robimy pierwszy dorosły krok: zaczynamy kontrolować to, co trafia do wnętrza obrazu. Jeśli do tej pory Docker był dla Ciebie czymś w stylu „uruchom kontener i niech jakoś samo", to Dockerfile właśnie łamie tę magię. To nie config „jak uruchomić kontener" i nie skrypt „jak naprawić życie". To raczej przepis lub instrukcja IKEA: co wziąć, gdzie położyć i co w efekcie ma startować.
Ważne jest od razu złapać właściwą logikę. Dockerfile opisuje, jak zbudować obraz. A z obrazu potem uruchamiany jest kontener. Dlatego teraz nie myślimy „jak otworzyć port", „jak podłączyć bazę", „jak zrobić ładnie i szybko", lecz jak najbardziej przyziemnie: „Jak zapakować nasz gotowy jar do obrazu, żeby mógł startować".
Żeby nie rozłazić się po całym wszechświecie instrukcji Dockerfile, dziś trzymamy focus na czterech podstawowych liniach. To dokładnie to minimum, które daje działający wynik i nie zamienia pliku w arkusz magii.
Poniżej — mała tabela, żebyś miał orientację „co robi każda linia" jeszcze przed czytaniem szczegółów:
| Instrukcja | Co rozwiązuje prostymi słowami | Co to znaczy dla naszego serwisu Boot |
|---|---|---|
| FROM | „Na jakiej bazie budujemy obraz" | Potrzebujemy obrazu z Javą, żeby java -jar w ogóle istniało |
| WORKDIR | „Gdzie żyją pliki aplikacji wewnątrz kontenera" | Chcemy zrozumiały folder jak /app, żeby nie szukać jar po całym obrazie |
| COPY | „Jakie pliki z build context wkładamy do wnętrza obrazu" | Przenosimy build/libs/...jar do wnętrza obrazu, np. jako app.jar |
| ENTRYPOINT | „Co uruchamia się przy starcie kontenera" | Polecenie java -jar app.jar |
Dalej omówimy te linie w tej samej kolejności, w jakiej pojawiają się w Dockerfile, bo Dockerfile czyta się od góry do dołu, jak scenariusz.
2. FROM: bazowy obraz z Javą
Gdy po raz pierwszy widzisz FROM, zazwyczaj pojawiają się dwa pytania. Pierwsze: „Dlaczego mam zaczynać nie od mojego kodu?" Drugie: „Dlaczego w ogóle wybieram jakiś obcy obraz?" Odpowiedź jest bardzo inżynierska: Docker image to nie pusty folder, lecz warstwowe ciasto, a najniższa warstwa — to obraz bazowy. Jeśli potrzebujesz Javy, bierzesz bazę, gdzie Java już jest.
Dla naszego Spring Boot jar jest to krytyczne. Wewnątrz kontenera nie ma Twojej lokalnej Javy, nie ma IntelliJ IDEA i w ogóle nie ma Twojego przyzwyczajonego środowiska. Kontener — to osobny system plików i osobny proces. Jeśli w tym świecie nie istnieje polecenie java, to ENTRYPOINT ["java", ...] nie uruchomi się, a kontener skończy się szybciej, niż zdążysz powiedzieć: „ale u mnie na laptopie działało!"
W ramach kursu baseline zafixowany jest na 25, a więc obraz bazowy powinien zawierać Java 25 lub kompatybilną wersję. Dla pierwszego minimalnego Dockerfile logiczne jest wzięcie prostego i zrozumiałego obrazu z JDK. Nie jest najkompaktniejszy, za to jest maksymalnie przewidywalny dla początkującego: Java w środku na pewno jest, i uruchomienie będzie się czytać bez niespodzianek.
Przykładowa linia bazowa:
# Obraz bazowy, w którym jest już JDK potrzebnej wersji
FROM eclipse-temurin:25-jdk
Kilka ważnych momentów — bez wchodzenia w przyszłe tematy:
Po pierwsze, obraz bazowy — to to, co Docker przy budowaniu będzie prawdopodobnie pobierał, jeśli nie ma go jeszcze na Twojej maszynie. To normalne: nie jesteś zobowiązany trzymać w repozytorium całego systemu operacyjnego i JDK.
Po drugie, wersję Javy w obrazie lepiej trzymać w głowie jako warunek kompatybilności. Jeśli zbudowałeś jar, który wymaga Java 25, a w kontenerze jest Java 21, będzie ból. Nie filozoficzny, lecz całkiem konkretny: aplikacja może po prostu nie startować.
Po trzecie, teraz nie spieramy się, który obraz bazowy jest „lepszy", i nie organizujemy konkursu „kto zbuduje najmniejszy obraz". Dziś cel — nie minimalizm, lecz zrozumiałość i pierwszy działający wynik. Utrwalamy mechanikę, a nie polerujemy jej do błysku.
3. WORKDIR: katalog roboczy
W Dockerfile bardzo łatwo napisać polecenia, które „niby działają", ale zostawiają wewnątrz obrazu chaos plików. A potem otwierasz kontener, widzisz app.jar gdzieś w korzeniu obok czegoś innego i czujesz się archeologiem, który przypadkowo wykopał starożytny artefakt i teraz próbuje zrozumieć, dlaczego leży w kuchni.
WORKDIR — to prosta dyscyplina: mówimy Dockerowi, że dalej wszystkie względne ścieżki wewnątrz kontenera liczone są od konkretnego folderu. Dla człowieka znaczy to: „Aplikacja mieszka tu i tu". Dla zespołu — „Zawsze wiemy, gdzie szukać plików aplikacji".
Minimalny przykład:
# Dalej wszystkie względne ścieżki będą liczone od /app
WORKDIR /app
Po tej linii można myśleć tak: „Kontener wszedł do /app i dalej pracuje stamtąd". Jeśli kopiujesz plik jako app.jar, trafi on do /app/app.jar. Jeśli uruchamiasz java -jar app.jar, faktycznie oznacza to java -jar /app/app.jar.
I tu warto nieco zmienić optykę: WORKDIR nie ma związku z Twoim lokalnym folderem projektu. Nie tworzy na dysku nowego katalogu obok src i build. To folder wewnątrz przyszłego obrazu. Można go nazwać /app, /opt/service lub /workspace, ale wybieramy /app, bo jest proste i rozpoznawalne.
Na razie nie omawiamy uprawnień, użytkowników, „czy można pisać do tego katalogu" i innych dorosłych spraw. Teraz potrzebujemy czystego minimum: zrozumiałe miejsce, gdzie leży nasz jar.
4. COPY: przenosimy jar do obrazu
W tym momencie mamy już dwa fakty: wykonywalny jar leży w build/libs, a COPY bierze pliki tylko z build context. Tu ważne jest, żeby nie wyjaśniać całej mechaniki od nowa, lecz spokojnie uziemić ją do jednej linii Dockerfile.
# Bierzemy jar z build context i wkładamy go do obrazu jako /app/app.jar (dzięki WORKDIR)
COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar
Lewa część — ścieżka do pliku w kontekście budowania. Jeśli budujesz image z korzenia projektu i jar naprawdę leży w build/libs, Docker go znajdzie. Prawa część — nazwa pliku już wewnątrz obrazu. Ponieważ wcześniej ustawiliśmy WORKDIR /app, app.jar trafi pod ścieżkę /app/app.jar.
Tu warto wyraźnie oddzielić maszynę programisty od obrazu. Na Twojej maszynie plik może mieć długą i niezbyt przyjazną nazwę, z wersją i SNAPSHOT. Wewnątrz kontenera ważniejsza jest stabilność, dlatego od razu nadajemy mu krótką nazwę app.jar.
Dlaczego nie kopiujemy całego projektu? Bo dla pierwszego działającego obrazu potrzebujemy tylko już zbudowanego artefaktu. Im mniej zbędnych plików trafia do obrazu, tym wyraźniejsze jest, co potem jest uruchamiane.
Jeśli potrzebny jar nie trafił do build context — np. zapomniałeś bootJar albo zbyt agresywnie przyciąłeś build/ przez .dockerignore — COPY padnie uczciwie i dość szybko. I to dobry objaw: problem nie w tym, jak startuje Java, lecz po prostu Docker nie widzi wejściowego pliku.
5. ENTRYPOINT: polecenie startu
W Dockerze jest jedna bardzo praktyczna myśl: kontener żyje, dopóki żyje główny proces. Dla naszego serwisu tym procesem będzie uruchomienie java -jar app.jar. Dlatego Dockerfile musi jawnie powiedzieć: „Gdy mnie uruchomią, zrób to i to".
Do tego właśnie służy ENTRYPOINT. Nie omawiamy tu dziesiątek wariantów, nie spieramy się o CMD i nie wchodzimy w subtelności nadpisywania. Potrzebujemy jednego zrozumiałego faktu: kontener przy starcie powinien wykonać polecenie uruchomienia aplikacji.
Najbardziej czytelny wariant dla początkującego — forma exec, czyli tablica JSON. Wygląda tak:
# Przy starcie kontenera uruchamiamy aplikację
ENTRYPOINT ["java", "-jar", "app.jar"]
Ten zapis czyta się dosłownie: „Uruchom program java z argumentami -jar app.jar". I dobrze nakłada się na już znane sprawdzenie z wykładu 1: java -jar build/libs/...jar. Tylko teraz jar leży już wewnątrz obrazu pod nazwą app.jar, a uruchomienie idzie w środowisku kontenerowym.
Dlaczego nie piszemy tak?
# Forma shellowa: krótsza, ale zależy od shell'a wewnątrz obrazu
ENTRYPOINT java -jar app.jar
Bo teraz ważniejsza jest dla nas przewidywalność niż podobieństwo do polecenia w terminalu. Forma exec jest mniej zależna od cech shella i zazwyczaj zachowuje się bardziej bezpośrednio. Ale tu świadomie hamujemy i nie nadymamy tematu: szczegółowy rozbiór form uruchomienia — to osobna rozmowa. Dziś wystarczy zapamiętać jedno: ENTRYPOINT utrwala polecenie startu.
I jeszcze mały psychologiczny trik. ENTRYPOINT — to nie „jak uruchomić image". Obraz sam się nie uruchamia. Uruchamia się kontener, a ENTRYPOINT mówi mu, co robić przy starcie. Jeśli trzymać to w głowie, wiele objawów w Dockerze czyta się znacznie łatwiej.
6. Minimalny Dockerfile w praktyce
Składamy minimalny Dockerfile
Teraz złożymy wszystko w jeden krótki Dockerfile, który można położyć w korzeniu repozytorium docker-java-catalog-service/. Nie jest „idealny", nie jest „najmniejszy" ani „najszybszy". Jest po prostu uczciwy: bierze obraz bazowy z Javą, tworzy katalog roboczy, kopiuje jar i uruchamia go.
Oto minimalny wariant, który dobrze czyta się nawet po pół roku — a to, nawiasem mówiąc, rzadkość w świecie plików infrastrukturalnych, gdzie nawet autor nierzadko straszy się własnym tworem:
FROM eclipse-temurin:25-jdk WORKDIR /app COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]
Jeśli chcesz nieco więcej „ludzkości", możesz dodać komentarze. Komentarze w Dockerfile zaczynają się od #, i to jeden z najlepszych sposobów na pomoc przyszłemu sobie i kolegom, żeby nie zamieniać się w szamana, który „po prostu pamięta właściwe zaklęcie":
# Obraz bazowy z JDK 25 (kompatybilny z naszym jar) FROM eclipse-temurin:25-jdk # Folder, w którym będzie leżeć aplikacja wewnątrz obrazu WORKDIR /app # Kopiujemy już zbudowany Spring Boot jar do wnętrza obrazu COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar # Przy starcie kontenera uruchamiamy aplikację ENTRYPOINT ["java", "-jar", "app.jar"]
Zwróć uwagę na jedną ideę, która często umyka: zmieniamy nazwę pliku wewnątrz obrazu na app.jar. To bardzo wygodne. Na hoście może się nazywać choćby service-0.0.1-SNAPSHOT-final-final2.jar — wszyscy widzieliśmy takie w naturze — ale wewnątrz kontenera nazwa pozostaje stabilna.
Jeszcze raz powiedzmy to wyraźnie. Ten Dockerfile zakłada, że wykonałeś już ./gradlew bootJar i plik naprawdę istnieje. Jeśli zapomniałeś zbudować jar, Dockerfile nie „zbuduje go magicznie za Ciebie". Uczciwie powie „plik nie znaleziony", i to dobry sygnał: znaczy, że pomieszyłeś dwa różne zadania w jedno.
Czytanie Dockerfile i zawartość obrazu
Dobry Dockerfile różni się od złego nie tylko tym, że „działa". Zły też czasem działa — szczególnie na komputerze autora i szczególnie w pełnię księżyca. Dobry Dockerfile można czytać jak mini-program i z góry rozumieć wynik: jakie pliki znajdą się w obrazie i co się stanie przy starcie kontenera.
Spróbujmy myślowo przejść przez nasz plik krok po kroku, bez terminala i bez zgadywania.
Po FROM eclipse-temurin:25-jdk mamy już system plików obrazu bazowego, w którym jest Java. Nie musisz wiedzieć, gdzie dokładnie leży binarny plik java — wystarczy rozumieć, że istnieje i jest dostępny w PATH.
Po WORKDIR /app wewnątrz obrazu pojawia się, lub zostaje wybrany, katalog /app. I wszystkie dalsze względne ścieżki wewnątrz kontenera będą czytane właśnie stamtąd.
Po COPY ... app.jar wewnątrz obrazu pojawia się plik /app/app.jar. Trafił tam z build context, czyli z Twojego projektu, który przekazałeś do docker build. To właśnie nasz wykonywalny Spring Boot jar.
Po ENTRYPOINT ["java","-jar","app.jar"] masz regułę uruchomienia: gdy kontener startuje, wykona polecenie java -jar /app/app.jar.
Jeśli chcesz utrwalić to jeszcze bardziej poglądowo, możesz wyobrazić sobie stan plików jako małą „karteczkę" po buildzie:
| Gdzie | Co powinno być |
|---|---|
| Na hoście (przed buildem) | build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar |
| W build context | ten sam plik, jeśli nie jest wycinany przez .dockerignore |
| Wewnątrz obrazu | /app/app.jar |
| Polecenie startu kontenera | java -jar app.jar w katalogu /app |
Zauważasz przyjemną rzecz? Przestaliśmy zależeć od tego, jak dokładnie plik nazywa się na hoście. Wewnątrz obrazu zawsze jest app.jar. To drobiazg, ale właśnie z takich drobiazgów składa się konteneryzacja bez chaosu.
7. Typowe błędy Dockerfile
Błędy na tym etapie są prawie zawsze takie same, i to dobra wiadomość: jeśli nauczysz się je szybko rozpoznawać, przestaniesz tracić czas na mistykę. Poniżej — najczęstsze pułapki właśnie dla minimalnego Dockerfile z czterech instrukcji.
Błąd nr 1: nieprawidłowa ścieżka w COPY, bo nie sprawdziłeś build/libs.
Często Dockerfile pisze się „na oko": COPY build/libs/app.jar app.jar, a potem okazuje się, że plik nazywa się inaczej. W efekcie build pada z komunikatem, że plik nie znaleziony. Leczy się nudno: najpierw ./gradlew bootJar, potem sprawdzamy rzeczywistą nazwę pliku w build/libs, i dopiero potem piszemy COPY.
Błąd nr 2: jar nie trafił do build context przez .dockerignore.
Czasem studenci z przyzwyczajenia dodają do .dockerignore linię build/, bo „build to śmietnik". Lokalnie to śmietnik, a dla dzisiejszego budowania — akurat jedyna przydatna część, bo tam leży jar. Objaw będzie podobny do błędu ścieżki, ale przyczyna inna: Docker po prostu nie widział tego pliku. Zasada prosta: jeśli plik jest potrzebny dla COPY, nie powinien być odfiltrowany.
Błąd nr 3: docker build uruchamiany nie z korzenia projektu, i kontekst staje się „nie tym".
Możesz doskonale napisać COPY build/libs/..., ale jeśli uruchamiasz build z innego katalogu lub podajesz inny kontekst, Docker po prostu nie znajdzie potrzebnych ścieżek. Tu ważne jest pamiętanie poprzedniego wykładu: ścieżki w COPY liczone są względem build context. Jeśli kontekstem nie jest korzeń projektu, to build/libs dla Dockera może po prostu nie istnieć.
Błąd nr 4: obraz bazowy z inną wersją Javy.
Jeśli jar jest zbudowany pod Java 25, a w FROM wziąłeś Java 21, aplikacja może nie startować. Czasem widać to od razu, czasem objawia się dziwnymi błędami przy starcie. Na razie nie omawiamy strategii wyboru obrazów — to osobny duży temat — ale podstawowa dyscyplina jest już potrzebna: wersja Javy w obrazie musi być kompatybilna z wersją, pod którą zbudowany jest jar.
Błąd nr 5: pomylenie „pliku na hoście" z „plikiem wewnątrz obrazu".
Początkujący czasem piszą ENTRYPOINT ["java", "-jar", "build/libs/docker-java-catalog...jar"], bo właśnie tak plik nazywał się na hoście. Ale wewnątrz obrazu już skopiowałeś go jako app.jar do /app. Wewnątrz kontenera nie ma Twojej struktury projektu, jeśli jej specjalnie tam nie kopiowałeś. Dlatego ENTRYPOINT musi odwoływać się do ścieżki wewnątrz obrazu, a nie do ścieżki na hoście.
GO TO FULL VERSION