1. Co to jest czytelny Dockerfile
„Czytelny Dockerfile" brzmi jak zdanie ze świata dorosłych ludzi, którzy stawiają przecinki w README i nie nazywają zmiennych x2final_final2. Ale sens jest tu bardzo praktyczny: Dockerfile powinien być taki, żebyś za miesiąc otworzył go i zrozumiał, co się dzieje, bez wzywania szamana od kontenerów i bez ponownego czytania całego internetu.
Czytelność tu — nie piękno dla piękna. To sposób na utrzymanie kontroli nad trzema rzeczami: gdzie bierzemy środowisko Java, gdzie kładziemy artefakt (bootJar) i jak uruchamiamy proces wewnątrz kontenera. Gdy tylko Dockerfile staje się w stylu „chyba działa, ale nie wiadomo dlaczego", tracisz główną zaletę podejścia inżynierskiego: możliwość przewidywalnego zmieniania zachowania uruchomienia, nie psując wszystkiego wokół.
Naiwny Dockerfile już rozwiązywał jedno zadanie: wziąć Java runtime, położyć jar do obrazu i jakoś go uruchomić. Ale osobno już zobaczyliśmy, że tego za mało: build-side i run-side trzeba rozdzielić, ENTRYPOINT powinien uruchamiać Javę bezpośrednio, a zmienne parametry nie mogą być zapiekane przez każde przebudowanie. Teraz pozostaje zebrać to wszystko w jeden stabilny wariant Dockerfile, żeby optymalizacja warstw i cache szła potem na stabilnym uruchomieniu, a nie zamiast niego.
2. Szkielet Dockerfile: pięć bloków
Naiwny wariant zazwyczaj wygląda bardzo krótko: obraz bazowy, COPY jar, java -jar. Do pierwszego uruchomienia wystarczy. Czytelny wariant różni się nie liczbą linii, lecz tym, że każda linia utrwala osobną umowę: gdzie mieszka aplikacja, jak trafia do obrazu, jakie wartości domyślne są już w nim zadane i jaki proces Docker uważa za główny.
Końcowy Dockerfile dnia
# 1) Baza: Java 25 runtime (tylko do uruchamiania, bez budowania)
FROM eclipse-temurin:25-jre-jammy
# 2) Katalog roboczy aplikacji wewnątrz kontenera
WORKDIR /opt/app
# 3) Wkładamy do obrazu gotowy artefakt budowania (Spring Boot bootJar)
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
# 4) Domyślne wartości do uruchomienia (można nadpisać przez docker run -e ...)
ENV SPRING_PROFILES_ACTIVE=standalone
ENV SERVER_PORT=8080
# 5) Dokumentujemy port, którego słucha aplikacja wewnątrz kontenera
EXPOSE 8080
# 6) Startujemy Javę jako główny proces kontenera (exec-form)
ENTRYPOINT ["java", "-jar", "app.jar"]
To ten wariant, gdzie wszystkie umowy wreszcie są zebrane w jednym miejscu. Tu świadomie nie dodajemy jeszcze CMD z tym samym portem czy profilem: komenda uruchomienia już jest stabilna przez ENTRYPOINT, a wartości domyślne żyją w ENV i mogą być nadpisane w konkretnym uruchomieniu.
Od góry do dołu ten Dockerfile czyta się tak:
flowchart TD
A["FROM (baza: Java runtime)"] --> B["WORKDIR (katalog roboczy)"]
B --> C["ARG + COPY (przeniesienie bootJar)"]
C --> D["ENV + EXPOSE (wartości domyślne i dokumentacja portu)"]
D --> E["ENTRYPOINT (exec-form uruchomienie java)"]
| Blok Dockerfile | Za co odpowiada | Co mamy w naszym Dockerfile |
|---|---|---|
| FROM | Jaka Java i jakie środowisko bazowe trafią do kontenera | obraz runtime z Java 25 |
| WORKDIR | Gdzie mieszka aplikacja wewnątrz obrazu | /opt/app |
| ARG + COPY | Jak bootJar trafia do obrazu pod stabilną nazwą | build/libs/*.jar → app.jar |
| ENV + EXPOSE | Jakie wartości domyślne widzi kontener i jaki wewnętrzny port jest oczekiwany | SPRING_PROFILES_ACTIVE, SERVER_PORT, EXPOSE 8080 |
| ENTRYPOINT | Jak uruchamiany jest główny proces kontenera | |
CMD trzymamy w głowie jako osobny wzorzec dla wartości domyślnych argumentów aplikacji. Ale w tym Dockerfile jest on świadomie niepotrzebny: te same startowe wartości już żyją w ENV, a drugie źródło wartości domyślnych tylko wprowadza zamieszanie.
Baza i katalog roboczy
FROM i WORKDIR są tu potrzebne nie dla zbędnej teorii, lecz żeby Dockerfile od razu czytał się od góry do dołu bez chaosu ścieżek. Od razu widzimy, na jakiej Javie żyje serwis i gdzie wewnątrz obrazu jest jego „dom".
Dzięki temu app.jar nie błąka się po przypadkowych katalogach, a wszystkie kolejne instrukcje działają względem jednego zrozumiałego katalogu. Rzecz mała, ale właśnie na takich drobiazgach plik przestaje być kruchy.
Artefakt budowania
Świadomie kopiujemy nie cały projekt, lecz wynik bootJar. ARG JAR_FILE zostawia buildowi mały margines, jeśli trzeba wskazać jar jawnie, a COPY ${JAR_FILE} app.jar utrwala wewnątrz obrazu stabilną nazwę pliku.
To jest wygodne zarówno do czytania, jak i do uruchamiania: ENTRYPOINT już nie zależy od nazwy wersjonowanego artefaktu z build/libs/, a Dockerfile nie zamienia się w listę przypadkowych plików, które trafiły do obrazu „na wszelki wypadek".
Wartości domyślne i port
ENV zadaje rozsądne wartości domyślne: profil standalone i wewnętrzny port 8080. To nie są „na zawsze przykute" wartości, lecz startowe umowy, które można pobić przez docker run -e lub argumenty aplikacji.
EXPOSE obok jest potrzebny nie do publikacji portu na zewnątrz, lecz żeby od razu było widać, jaki port serwis oczekuje wewnątrz kontenera. Zewnętrzny dostęp nadal konfiguruje się wyłącznie przez docker run -p.
Uruchomienie procesu
ENTRYPOINT ["java", "-jar", "app.jar"] utrwala to co najważniejsze: Docker uruchamia Javę bezpośrednio, bez pośrednika shellowego, i właśnie JVM staje się głównym procesem kontenera. Dzięki temu stop/restart zachowują się przewidywalnie, a Spring dostaje normalną szansę na poprawne zakończenie.
CMD pamiętamy jako działający wzorzec dla domyślnych argumentów aplikacji, gdy naprawdę wygodnie je trzymać osobno. Ale w tym Dockerfile świadomie go nie używamy dla portu i profilu: duplikowanie tych samych wartości zarówno w ENV, jak i w CMD — to tylko mnożenie zamieszania.
3. Budowanie i uruchamianie bez edytowania Dockerfile
Teraz warto sprawdzić sam princip w praktyce: jeden i ten sam obraz uruchamiany w kilku trybach, a Dockerfile przy tym pozostaje ten sam.
Najpierw, jak poprzednio, budujemy jar i obraz:
# Budujemy executable jar (Spring Boot bootJar) ./gradlew bootJar # Budujemy Docker image z bieżącego katalogu (Docker build context = .) docker build -t docker-java-catalog-service .
Podstawowe uruchomienie „jak jest", z wartościami domyślnymi z Dockerfile:
docker run --rm -p 8080:8080 docker-java-catalog-service
Jeśli wszystko dobrze, w logach zobaczysz oznaki startu i port. Logi u Spring Boot bywają długie, ale teraz ważne jest, żebyś zobaczył sensowne kawałki: linię o aktywnym profilu i linię o tym, że serwer podniósł się na potrzebnym porcie. Sformułowanie może się różnić, ale idea jest jedna.
Teraz uruchamiamy ten sam obraz na innym porcie przez zmienne env:
docker run --rm -p 9090:9090 \ -e SERVER_PORT=9090 \ -e SPRING_PROFILES_ACTIVE=standalone \ docker-java-catalog-service
Tu ważne jest, żeby nie pomylić dwóch światów. -p 9090:9090 — to o sieci Dockera (host:container). SERVER_PORT=9090 — to o tym, na jakim porcie sama aplikacja wewnątrz kontenera będzie słuchać HTTP. Jeśli zmieniłeś jedno i zapomniałeś o drugim, dostaniesz klasyczne „kontener żyje, ale serwis się nie otwiera".
Ten sam efekt można osiągnąć przez argumenty aplikacji: przy exec-form ENTRYPOINT wszystko, co dopiszesz po nazwie image, trafi do Spring Boot jako app args.
docker run --rm -p 9090:9090 \ docker-java-catalog-service \ --server.port=9090 \ --spring.profiles.active=standalone
A oto osobny kanał dla opcji JVM — JAVA_TOOL_OPTIONS. Jego sens polega na tym, że to sposób na dodanie parametrów JVM, bez zamieniania ENTRYPOINT w shell-form i bez przyklejania tam „sklejonego ciągu".
Na przykład ograniczymy heap — czysto jako demonstrację kanału, bez fanatyzmu:
docker run --rm -p 8080:8080 \ -e JAVA_TOOL_OPTIONS="-Xms256m -Xmx256m" \ docker-java-catalog-service
Zwróć uwagę na przyjemną rzecz: Dockerfile się nie zmienia. Nie przebudowujemy obrazu dla „inny port" lub „mniej pamięci". Po prostu uruchamiamy ten sam artefakt w różnych trybach, i właśnie po to w ogóle wymyślono normalny model ENTRYPOINT + zewnętrzna konfiguracja.
Żeby nie trzymać wszystkiego w głowie, można posługiwać się małą pamięcią:
| Co chcesz zmienić | Najprostszy kanał dziś | Przykład |
|---|---|---|
| Port aplikacji | env var lub app args | -e SERVER_PORT=9090 lub --server.port=9090 |
| Aktywny profil | env var lub app args | -e SPRING_PROFILES_ACTIVE=standalone lub --spring.profiles.active=standalone |
| Parametry JVM | JAVA_TOOL_OPTIONS | -e JAVA_TOOL_OPTIONS="-Xmx256m" |
Ta tabelka nie jest po to, żeby zakuwać, lecz żeby szybko odróżniać kanał Dockera od kanału JVM i kanału Spring Boot.
4. Typowe błędy przy pracy z Dockerfile
Błąd nr 1: Dockerfile nie widzi jar, choć na pewno jest.
Najczęściej przyczyna jest banalna: jar nie trafił do build context. Niekiedy studenci zbyt agresywnie skonfigurowali .dockerignore i wykluczyli build/ w całości, a potem dziwią się, że COPY build/libs/*.jar nie działa. Leczy się to nie mistyką, lecz sprawdzeniem: gdzie jar leży, co reale jest wysyłane do build context i jakie reguły ignorowania zadziałały.
Błąd nr 2: EXPOSE 8080 napisano, ale serwis się nie otwiera.
EXPOSE nie publikuje portu na zewnątrz. Tylko dokumentuje oczekiwany port wewnątrz kontenera. Zewnętrzny dostęp pojawia się wyłącznie przez docker run -p hostPort:containerPort. Jeśli pomylić te role, można bezustannie zmieniać Dockerfile, choć problem leży w komendzie uruchomienia.
Błąd nr 3: przejście na shell-form dla „ładnej linii".
Niekiedy chce się napisać ENTRYPOINT java -jar app.jar, bo tak wygląda „jak w terminalu". Ale to shell-form, i ciągnie za sobą powłokę jako pośredni proces. W efekcie pogarszasz dostarczanie sygnałów do procesu Java i dostajesz mniej stabilne zachowanie zatrzymania kontenera. Dla serwisu Java podstawowy wariant powinien być nudny i prawidłowy: ENTRYPOINT ["java", "-jar", "app.jar"].
Błąd nr 4: mieszanie ARG z ENV, i wszystko zaczyna wydawać się nieprzewidywalne.
ARG żyje tylko podczas budowania. Jeśli spróbujesz przez ARG „zadać port" lub „włączyć profil", dostaniesz niespodziankę: kontener tego nie dostanie. Dla parametrów runtime potrzebny jest ENV jako wartość domyślna obrazu lub parametry docker run jako nadpisanie. Gdy tylko rozdzielisz build-time i runtime w głowie, połowa zamieszania znika.
Błąd nr 5: niespójność portu w trzech miejscach (i potem boli).
Student zmienia -p 9090:8080, potem ustawia SERVER_PORT=9090, potem dodaje --server.port=7070, a na górze jeszcze chce położyć ten sam port w CMD. Pomaga tu prosta reguła: w jednym uruchomieniu staraj się zadawać parametr jednym kanałem i zachowuj zgodność między portem aplikacji a mapowaniem portów kontenera. Inaczej będziesz diagnozował nie Dockera, lecz własną kombinację parametrów.
GO TO FULL VERSION