Dockerfile – wszystko o budowaniu własnych obrazów

You are currently viewing Dockerfile – wszystko o budowaniu własnych obrazów

Wstęp

Jeśli śledzisz serię o Dockerze na moim blogu, to pewnie masz już ogarnięte uruchamianie kontenerów, sieci, mapowanie portów i pobieranie gotowych obrazów z DockerHuba. Fajnie – to solidna baza. Ale prawdziwa wartość konteneryzacji zaczyna się tam, gdzie nie używamy tylko gotowych obrazów, ale też zaczynami tworzyć własne – dokładnie takich, jakich potrzebuje Twoja aplikacja.

Tutaj właśnie wchodzi DockerFile. Przepis na budowę obrazu – linijka po linijce definiujesz, co ma się znaleźć w środku i jak ma działać Twoje środowisko. Dzięki temu możesz odtworzyć identyczne warunki działania aplikacji niezależnie od tego, gdzie ją uruchamiasz – lokalnie, w CI, na serwerze testowym czy produkcyjnym. Zero zgadywania, zero „a u mnie działa”. Wszystko jasno opisane i powtarzalne.

W tym wpisie przeprowadzę Cię przez wszystko, co musisz wiedzieć, żeby pisać Dockerfile’e z głową. Zaczniemy od podstaw – poznasz kluczowe instrukcje jak FROM, RUN, COPY, CMD. Potem krok po kroku pokażę, jak tworzyć lżejsze, szybsze i bezpieczniejsze obrazy – m.in. przez porządkowanie warstw, multi-stage buildy czy dobrze użyty plik .dockerignore. Będzie też o konfiguracji przez zmienne środowiskowe, tagowaniu i unikaniu typowych pułapek.

Podstawy Dockerfile

Na początek przyjrzyjmy się, czym dokładnie jest Dockerfile oraz zapoznajmy się z jego kluczowymi instrukcjami, aby lepiej zrozumieć, z czego się składa.

Co to jest DockerFile

Dockerfile to plik tekstowy bez rozszerzenia (zapisujemy po prostu go jako Dockerfile nie dodając żadnych rozszerzeń jak .txt itp.), który zawiera sekwencję instrukcji służących do automatycznego zbudowania obrazu Dockera. Każda jedna linijka tego pliku to kolejny krok, który Docker musi wykonać, aby stworzyć finalny produkt – obraz.

Kluczową koncepcją, którą trzeba zrozumieć, jest system warstw. Każda instrukcja w pliku Dockerfile (np. RUN, COPY, ADD – opisane poniżej) tworzy nową warstwę w obrazie. Warstwy te są nakładane na siebie, a Docker sprytnie je buforuje (cache’uje). Jeśli nie zmienisz instrukcji ani plików, których ona dotyczy, Docker ponownie wykorzysta istniejącą warstwę z pamięci podręcznej, zamiast budować ją od nowa. To drastycznie przyspiesza proces budowania obrazów, zwłaszcza podczas developmentu.

Dajmy prosty przykład. Wyobraźmy sobie, że chcemy stworzyć obraz, który będzie serwował prostą stronę internetową za pomocą serwera Nginx. Nasz Dockerfile mógłby wyglądać tak:

# Krok 1: Określ, na jakim obrazie bazowym chcesz pracować.
# Użyjemy oficjalnego, lekkiego obrazu Nginx.
FROM nginx:alpine

# Krok 2: Skopiuj pliki swojej strony do odpowiedniego katalogu w obrazie.
# Kopiujemy nasz lokalny plik `index.html` do katalogu, z którego Nginx serwuje strony.
COPY index.html /usr/share/nginx/html/index.html

To wszystko! Ten prosty, dwuetapowy przepis mówi Dockerowi:

  1. Weź gotowy serwer Nginx w wersji alpine.
  2. Dodaj do niego nasz własny plik index.html.

Po zbudowaniu obrazu z tego pliku, każdy uruchomiony z niego kontener będzie serwował naszą niestandardową stronę. To pokazuje, jak Dockerfile pozwala na modyfikowanie i rozszerzanie istniejących obrazów w prosty i powtarzalny sposób.

Najważniejsze instrukcje

Dockerfile posiada bogaty zestaw instrukcji. Poniżej omawiam te najważniejsze i najczęściej używane, wraz z tymi, które warto znać, aby poszerzyć swoje możliwości.

FROM

To obowiązkowa i zawsze pierwsza instrukcja w każdym Dockerfile. Określa obraz bazowy, na którym będziemy budować nasz własny. Zazwyczaj jest to oficjalny obraz jakiegoś systemu operacyjnego lub środowiska uruchomieniowego, na przykład openjdk, node czy python.

FROM openjdk:17-slim

COPY

Służy do kopiowania plików lub całych katalogów z kontekstu budowania (czyli z katalogu, w którym uruchamiasz komendę docker build) bezpośrednio do systemu plików wewnątrz kontenera. Jest to preferowana i najbezpieczniejsza instrukcja do przenoszenia lokalnych plików do obrazu.

COPY target/my-app.jar app.jar

Tworząc własne obrazy będziesz dość często używał tej instrukcji właśnie do przenoszenia pakietu lub plików swojej aplikacji bezpośrednio do obrazu, aby potem móc odpalić kontener z tymi plikami.

ADD

Instrukcja ADD jest podobna do COPY, ale posiada dwie dodatkowe funkcje:

  • Potrafi automatycznie rozpakowywać lokalne archiwa (.tar, .gz, .zip)
  • Także umożliwia pobieranie plików ze zdalnych adresów URL.

Ze względu na jej bardziej złożone i mniej przewidywalne zachowanie (np. nie zawsze chcemy auto-rozpakowania), ogólną zasadą jest używanie jednak instrukcji COPY, chyba że świadomie potrzebujemy którejś z dodatkowych możliwości ADD.

ADD https://example.com/file.txt /app/

RUN

RUN wykonuje dowolne polecenie w powłoce systemowej w nowej warstwie obrazu, a następnie zatwierdza (commituje) zmiany. Jest to instrukcja używana najczęściej do zadań takich jak instalacja pakietów oprogramowania (apt-get install), aktualizacja systemu czy kompilacja kodu źródłowego.

RUN apt-get update && apt-get install -y curl

CMD

Określa domyślne polecenie, które zostanie wykonane podczas uruchamiania kontenera. W jednym Dockerfile może znajdować się tylko jedna instrukcja CMD. Jej główną cechą jest to, że może być łatwo nadpisana przez użytkownika, który poda inne polecenie przy wywołaniu docker run. Służy do definiowania domyślnego zachowania kontenera.

CMD ["java", "-jar", "app.jar"]

ENTRYPOINT

Konfiguruje kontener tak, aby zachowywał się jak plik wykonywalny (np. pliki z rozszerzeniem .exe). W przeciwieństwie do CMD, argumenty podane przy docker run nie nadpisują ENTRYPOINT, lecz są do niego dołączane jako argumenty. Jest to idealne rozwiązanie do tworzenia obrazów, które wykonują jedno, konkretne zadanie. ENTRYPOINT i CMD można używać razem, gdzie ENTRYPOINT definiuje polecenie, a CMD dostarcza dla niego domyślne parametry.

ENTRYPOINT ["java", "-jar", "app.jar"]

WORKDIR

Ta instrukcja ustawia katalog roboczy dla wszystkich kolejnych poleceń, takich jak RUN, CMD, ENTRYPOINT, COPY czy ADD, które zostaną wykonane wewnątrz kontenera. Jeżeli wskazany katalog nie istnieje, Docker utworzy go automatycznie. Jest to znacznie lepsza i czystsza praktyka niż ręczne wywoływanie RUN cd /katalog.

WORKDIR /app

ENV

Instrukcja ENV służy do ustawiania zmiennych środowiskowych. Zmienne te są dostępne zarówno podczas procesu budowania obrazu (dla wszystkich instrukcji, które następują po ENV), jak i wewnątrz uruchomionego kontenera, gdzie mogą konfigurować działanie aplikacji.

ENV SPRING_PROFILES_ACTIVE=production

ARG

Definiuje zmienną dostępną tylko i wyłącznie podczas budowania obrazu. Jej wartość nie jest w żaden sposób zapisywana w finalnym obrazie ani dostępna dla uruchomionego kontenera. Jest to idealne narzędzie do parametryzacji procesu budowy, np. do przekazywania wersji zależności czy włączania opcjonalnych kroków kompilacji.

ARG JAR_FILE_PATH=target/my-app.jar

EXPOSE

EXPOSE to forma dokumentacji, która informuje Dockera, że kontener po uruchomieniu będzie nasłuchiwał na określonych portach sieciowych. Co ważne, ta instrukcja sama w sobie nie publikuje portu. Aby udostępnić port na zewnątrz i umożliwić komunikację z hostem, nadal trzeba użyć flagi -p lub -P w komendzie docker run. Tak, więc mimo że sobie tutaj zdefiniujemy port to aplikacja nie będzie na nim nasłuchiwać.

EXPOSE 8080

LABEL

Służy do dodawania metadanych do obrazu w formacie pary klucz-wartość. Jest to nowoczesny sposób na organizację i dokumentację obrazów. Można tu umieścić informacje o autorze, wersji oprogramowania, link do repozytorium czy opis projektu.

LABEL maintainer="kontakt@uprogramisty.pl" version="1.0"

VOLUME

Tworzy punkt montowania o podanej ścieżce i oznacza go jako wolumen, który ma przechowywać dane generowane przez kontener. Wolumeny są zarządzane przez Dockera i istnieją niezależnie od cyklu życia kontenera, co pozwala na trwałe przechowywanie danych (np. bazy danych, logów), które nie powinny zniknąć po usunięciu kontenera.

VOLUME /var/lib/mysql

USER

Ustawia nazwę użytkownika (lub jego UID), który będzie używany do wykonania wszystkich kolejnych instrukcji RUN, CMD i ENTRYPOINT. Domyślnie kontenery działają jako root, co jest ryzykowne z punktu widzenia bezpieczeństwa. Dobrą praktyką jest utworzenie dedykowanego użytkownika bez uprawnień roota i przełączenie się na niego.

RUN useradd -ms /bin/bash appuser
USER appuser

HEALTHCHECK

Definiuje polecenie, za pomocą którego Docker może regularnie sprawdzać, czy aplikacja wewnątrz kontenera jest w pełni sprawna, a nie tylko „uruchomiona”. Docker będzie cyklicznie wykonywał to polecenie, a jego kod wyjścia (0 dla sukcesu, 1 dla błędu) określi status kontenera (healthy/unhealthy). Jest to kluczowe dla systemów orkiestracji, które mogą automatycznie restartować „niezdrowe” kontenery.

HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:8080/health || exit 1

Stwórzmy swój pierwszy obraz dla aplikacji Spring Boot

Mieliśmy dużo teorii to czas przejść do praktycznego przykładu. Będziemy chcieli przygotować obraz dla prostej aplikacji napisanej w Spring Boot, gdzie będziemy mieć dostępny jeden prosty endpoint generujący liczbę z przedziału od 0 do 10000:

@RestController
@RequestMapping("/numbers")
class NumberController {

    private final Random random = new Random();

    @GetMapping("/random")
    public int getRandomNumber() {
        return random.nextInt(10_001);
    }

}

Mając już gotową aplikacje, możemy teraz przygotować odpowiedni Dockerfile:

# 1. Wybierz stabilny i lekki obraz bazowy z Javą
FROM openjdk:21-slim

# 2. Dodaj metadane do obrazu za pomocą instrukcji LABEL.
# Jest to opcjonalny krok
LABEL maintainer="Piotr <kontakt@uprogramisty.pl>" \
      version="1.0" \
      description="Aplikacja Spring Boot."

# 3. Ustaw katalog roboczy wewnątrz kontenera
WORKDIR /app

# 4. Zdefiniuj argument dla ścieżki do pliku
# Uwgaga! W zależności od użytego narzędzia Maven czy Gradle ścieżka do kopiowania zbudowanej aplikacji może się różnić!
ARG APP_FILE=build/libs/*.jar

# 5. Skopiuj zbudowany plik do kontenera
COPY ${APP_FILE} app.jar

# 6. Poinformuj Dockera, na jakim porcie działa aplikacja
EXPOSE 8080

# 7. Zdefiniuj polecenie uruchamiające aplikację
ENTRYPOINT ["java", "-jar", "app.jar"]

Budowanie obrazu z użyciem docker build

Teraz czas na zbudowanie obrazu. Ale zanim zaczniemy budować obraz, najpierw należy zbudować aplikacje, żeby nam się uworzył plik JAR, którego będziemy mogli skopiować do naszego obrazu.

Pewnie się zastanawiasz, dlaczego musimy najpierw zbudować aplikacje, żeby utworzyć własny obrazy. Teraz robimy prosty przykład jak ogólnie buduje się własne obrazy dockerowe. Docelowo można stworzyć mechanizm czy to wewnątrz Dockerfile, który będzie nam budował aplikację lub również możemy skorzystać z zewnętrznego narzędzia CI/CD, który automatycznie może budować aplikację i na tej podstawie zbudować też obraz.

Ale wracając do naszego przykładu. Jeśli mamy już zbudowaną aplikację to poniższą komendą możemy zbudować obraz (tworzymy obraz o nazwie moja-aplikacja):

docker build -t moja-aplikacja .

Po uruchomieniu komendy powinieneś dostać informację, jak po kolei jest budowany Twój obraz.

Budowanie własnego obrazu

Po zbudowaniu obrazu wykonując teraz komenda docker image ls możemy znaleźć już nasz zbudowany obraz:

Wynik komendy docker ls

Pewnie może Cię zdziwić rozmiar naszego obrazu jak na tak prostą aplikację. Chciałem tym przykład pokazać, że podczas tworzenia własnego obrazu trzeba dobierać odpowiednie obrazy bazowe, z których korzystamy, bo inaczej nie potrzebnie będziemy tracić miejsce na dysku.

Uruchamianie obrazu z użyciem docker run

Teraz nie pozostało nam nic innego jak uruchomić nasz obraz. Uruchommy go na porcie 8080:

docker run -d -p 8080:8080 moja-aplikacja

Teraz za każdym razem po wejściu na stronę http://localhost:8080/numbers/random powinniśmy dostawać losową liczbę z przedziału od 1 do 10000.

Struktura Dockerfile – jak budować lekkie obrazy?

Stworzenie działającego Dockerfile to jedno, ale stworzenie zoptymalizowanego Dockerfile to już sztuka. Dwa kluczowe aspekty optymalizacji to wybór odpowiedniego obrazu bazowego i strategiczne ułożenie instrukcji w celu jak najlepszego wykorzystania cache’owania warstw.

Wybór odpowiedniego obrazu bazowego

Obraz bazowy, który wybierzesz w instrukcji FROM, ma ogromny wpływ na rozmiar i bezpieczeństwo Twojego finalnego obrazu.

  • Obrazy typu alpine – Są ekstremalnie małe (często tylko kilka MB), co czyni je świetnym wyborem dla środowisk produkcyjnych. Bazują na dystrybucji Alpine Linux, która używa musl libc zamiast powszechniejszego glibc. Może to czasem powodować problemy z kompatybilnością niektórych narzędzi.
  • Obrazy typu slim – To kompromis między pełnym obrazem a alpine. Są znacznie mniejsze niż standardowe obrazy (np. oparte na Debianie czy Ubuntu), ale zawierają więcej standardowych narzędzi i używają glibc, co zapewnia lepszą kompatybilność.
  • Pełne obrazy (np. ubuntu, debian) – Są największe, ale zawierają pełny zestaw narzędzi systemowych. Używaj ich głównie do celów deweloperskich lub gdy Twoja aplikacja ma bardzo specyficzne zależności systemowe, których brakuje w lżejszych obrazach.

Należy też wystrzegać się używania tagu :latest w produkcji – zawsze należy określać konkretną wersję, aby zapewnić powtarzalność buildów.

Porządek instrukcji – dlaczego ma znaczenie

Docker buduje obrazy warstwowo i mocno cache’uje wyniki. Jeśli Docker napotka instrukcję, którą już kiedyś wykonywał (a pliki, od których zależy, się nie zmieniły), użyje warstwy z cache’a. Jednak gdy tylko jedna warstwa zostanie unieważniona, wszystkie kolejne warstwy również muszą zostać zbudowane od nowa. Dlatego dobrą praktyką jest umieszczać instrukcję, które zmieniają się najrzadziej, na górze pliku, a te które zmieniają się najczęściej na dole.

Dla aplikacji Javy przykładowa optymalizacja może wyglądać tak (dla narzędzia Maven):

  1. Skopiuj plik pom.xml.
  2. Zainstaluj zależności (np. za pomocą mvn dependency:go-offline). Ten krok jest długotrwały, ale zależności zmieniają się stosunkowo rzadko.
  3. Skopiuj resztę kodu źródłowego.
  4. Zbuduj aplikację (np. mvn package).

Dzięki temu, jeśli zmienisz tylko jedną linijkę w kodzie Javy, a pom.xml pozostanie bez zmian, Docker ponownie wykorzysta warstwę z zależnościami, a przebuduje tylko warstwy z kodem i kompilacją.

Przykład optymalizacji aplikacji Spring Boot

W poprzedniej sekcji robiliśmy przykład z obrazem, który zajmował 470MB. Spróbjmy go lekko zmniejszyć.

Zróbmy taki sam przykład jak poprzednio z tym, że zamiast używać obrazu openjdk:21-slim, użyjemy obrazu eclipse-temurin:21-jre.

Zbudujmy taki obraz:

docker build -t moja-aplikacja2 .

Ponownie użyjmy komendy docker image ls i w odpowiedzi dostaniemy:

wynik komendy docker image ls po optymalizacji

Jest różnica? Tym prostym przykładem chciałem Ci pokazać, że zmiana samego obrazu bazowego potrafi istotnie wpłynąć na rozmiar naszego obrazu. Teraz nasz obraz jest lżejszy, a robi dokładnie to samo.

Multi-stage builds – zmniejszanie rozmiaru obrazów

Jedną z najważniejszych technik optymalizacji Dockerfile jest stosowanie wieloetapowych buildów (multi-stage builds). Pozwalają one na drastyczne zmniejszenie rozmiaru finalnego obrazu produkcyjnego poprzez oddzielenie środowiska potrzebnego do zbudowania aplikacji od środowiska wymaganego do jej uruchomienia.

Czym są wieloetapowe buildy i kiedy ich używać

Multi-stage build to technika polegająca na użyciu wielu instrukcji FROM w jednym pliku Dockerfile. Każda instrukcja FROM rozpoczyna nowy, niezależny etap budowania. Możemy nadać każdemu etapowi nazwę (np. AS builder), co pozwala na odwoływanie się do niego w kolejnych krokach.

Magia tej techniki tkwi w możliwości selektywnego kopiowania plików (tzw. artefaktów) z jednego etapu do drugiego za pomocą flagi --from w instrukcji COPY. Dzięki temu możemy w pierwszym etapie użyć dużego obrazu z pełnym zestawem narzędzi deweloperskich (np. JDK, Gradle, Maven), zbudować naszą aplikację, a następnie w drugim etapie, bazując na minimalnym obrazie (np. z samym JRE), skopiować tylko gotowy plik .jar lub .war, odrzucając całe środowisko budowania.

Kiedy ich używać? Praktycznie zawsze, gdy proces kompilacji lub budowania wymaga narzędzi, które nie są potrzebne w środowisku uruchomieniowym. Jest to standard dla:

  • Języków kompilowanych, takich jak Java, Kotlin, Go czy Rust.
  • Aplikacji frontendowych, gdzie do zbudowania paczki z kodem używamy Node.js i npm/yarn, ale do serwowania gotowych plików wystarczy lekki serwer jak Nginx.

Porównanie rozmiarów: tradycyjny vs multi-stage build

Aby zobaczyć różnica stosując to rozwiązania, porównajmy dwa podejścia do budowania obrazu dla prostej aplikacji w Spring Boot.

Podejście tradycyjne (jedna faza, duży obraz). W tym podejściu całe środowisko budowania (Gradle, JDK) jest spakowane w jednym obrazie razem z aplikacją.

# Używamy obrazu z JDK i Gradle
FROM gradle:jdk21

# Ustawiamy katalog roboczy
WORKDIR /app

# Kopiujemy cały projekt
COPY . .

# Budujemy aplikację
RUN gradle build -x test

EXPOSE 8080

# Uruchamiamy aplikację
CMD ["java", "-jar", "build/libs/dev-0.0.1-SNAPSHOT.jar"]

Jaki tu mamy problem? Finalny obraz zawiera nie tylko naszą aplikację, ale także całe środowisko Gradle, pełne JDK, pobrane zależności i kod źródłowy.

Teraz zróbmy to samo, ale z podejściem z multi-stage build (dwie fazy, mały obraz):

# --- ETAP 1: Budowanie (Builder) ---
# Używamy obrazu z pełnym JDK i Gradle do zbudowania aplikacji
FROM gradle:jdk21 AS builder

WORKDIR /app
COPY . .
RUN gradle build -x test

# --- ETAP 2: Uruchamianie (Runner) ---
# Zaczynamy od nowa, z lekkiego obrazu zawierającego tylko JRE
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# Kopiujemy tylko finalny plik .jar z etapu "builder"
COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Obraz finalny jest tworzony na podstawie drugiego etapu (runner), który bazuje na minimalnym obrazie eclipse-temurin:21-jre-alpine. Kopiujemy do niego tylko i wyłącznie plik app.jar z etapu builder. Całe środowisko budowania zostaje odrzucone.

A na koniec porównajmy rozmiar tych dwóch obrazów (obraz app-pelna przed optymalizacją oraz app-lekka po optymalizacji) wykonując komenda docker image ls:

komenda docker image ls po mocnej optymalizacji

Wydaję się, że jest lekka różnica w rozmiarach obrazu.

Konfigurowanie aplikacji – ARG i ENV w praktyce

Elastyczne obrazy to takie, które można skonfigurować bez potrzeby ich przebudowywania. Do tego służą instrukcje ARG i ENV, ale kluczowe jest zrozumienie ich fundamentalnych różnic.

Różnice między ARG a ENV

Na początek zacznijmy od porównania tych dwóch instrukcji.

ARG (Argument budowania):

  • Istnieje tylko podczas procesu budowania obrazu (docker build).
  • Nie jest dostępna wewnątrz uruchomionego kontenera.
  • Służy do parametryzacji samego procesu budowania, np. przekazania wersji zależności, włączenia/wyłączenia jakiegoś kroku.
  • Wartość domyślną można zdefiniować w Dockerfile, a nadpisać flagą --build-arg przy docker build.

ENV (Zmienna środowiskowa):

  • Jest dostępna zarówno podczas budowania (dla instrukcji, które po niej następują), jak i wewnątrz uruchomionego kontenera.
  • Służy do konfigurowania zachowania aplikacji w czasie jej działania, np. adres bazy danych, klucze API, aktywne profile.
  • Wartość można nadpisać flagą -e przy docker run.
# ARG jest widoczne tylko tu, podczas budowania
ARG APP_VERSION=1.0.0

# ENV będzie widoczne w kontenerze
ENV APP_NAME="Moja Aplikacja"
ENV APP_RELEASE_VERSION=${APP_VERSION} # Można przekazać wartość z ARG do ENV

RUN echo "Buduję wersję ${APP_VERSION} aplikacji ${APP_NAME}"

CMD ["echo", "Uruchomiono aplikację ${APP_NAME} w wersji ${APP_RELEASE_VERSION}"]

Bezpieczne zarządzanie hasłami i kluczami API

Absolutnie kluczowa zasada: Nigdy, przenigdy nie umieszczaj haseł, kluczy API ani żadnych innych sekretów bezpośrednio w Dockerfile za pomocą ENV!

Dlaczego? Ponieważ wartości ENV są zapisywane w metadanych obrazu i każdy, kto ma do niego dostęp, może je łatwo odczytać za pomocą docker inspect.

Jak więc zarządzać sekretami?

  1. Przekazuj je jako zmienne środowiskowe w czasie uruchomienia:
    docker run -e DB_PASSWORD="super-tajne-haslo" moj-obraz
  2. Używaj Docker Secrets – W środowiskach klastrowych (Docker Swarm, Kubernetes) jest to preferowany, bezpieczny sposób na zarządzanie sekretami.
  3. Korzystaj z zewnętrznych narzędzi do zarządzania sekretami – takich jak HashiCorp Vault czy systemy zarządzania sekretami dostawców chmurowych (AWS Secrets Manager, Azure Key Vault).

Przykłady dla aplikacji Spring Boot

W aplikacjach Spring Boot zmienne środowiskowe to świetny mechanizm konfiguracyjny. Spring potrafi automatycznie mapować zmienne środowiskowe na właściwości w application.properties.

Jako przykład przeróbmy naszą aplikację tak, że przed losową liczbą będziemy też pisać nazwę naszej aplikacji:

@RestController
@RequestMapping("/numbers")
class NumberController {

    @Value("${spring.application.name}")
    private String name;

    private final Random random = new Random();

    @GetMapping("/random")
    public String getRandomNumber() {
        return name + " " + random.nextInt(10_001);
    }

}

Natomiast nasz plik Dockerfile przerobimy tak, że

FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# Dostępne tylko podczas budowania
ARG APP_FILE=build/libs/*.jar

COPY ${APP_FILE} app.jar

# Ustawiamy tutaj wartość domyślną tego parametru dla obrazu jeśli nie zostanie podana na wejściu
ENV SPRING_APPLICATION_NAME="Domyślna nazwa aplikacja"

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Budujemy obraz:

docker build -t app .  

Podczas uruchamiania obrazu, nadpisujemy też zmienną środowiskową SPRING_APPLICATION_NAME:

docker run -d -p 8080:8080 -e SPRING_APPLICATION_NAME="Moja super nazwa aplikacji 1" app 

Teraz za każdym razem po wejściu na stronę http://localhost:8080/numbers/random powinniśmy dostawać nową nazwę aplikacji 'Moja super nazwa aplikacji 1′ wraz z losową liczbę z przedziału od 1 do 10000.

Tagi – wersjonowanie i organizacja obrazów

Tagi to etykiety, które pozwalają na identyfikację i wersjonowanie obrazów. Dobre praktyki tagowania są kluczowe dla utrzymania porządku i zapewnienia stabilności w procesach wdrożeniowych.

Konwencje tagowania

Poleganie na latest to prosta droga do katastrofy na produkcji. latest jest tagiem ruchomym – dzisiaj może wskazywać na wersję 1.0, a jutro na 2.0. Użycie go w systemach produkcyjnych prowadzi do nieprzewidywalnych i trudnych do zdiagnozowania problemów.

Podczas tagowania obrazów dobrze jest podchodzić konwencją semantycznego wersjonowania (Semantic versioning). Tagowane obrazy są zgodne z wersją aplikacji, którą zawierają, np. moja-aplikacja:1.2.3. Format MAJOR.MINOR.PATCH (np. 1.2.3) to uniwersalny język, który komunikuje rodzaj zmian wprowadzonych w nowej wersji:

  • MAJOR (np. 1.2.3) – Zwiększasz tę cyfrę, gdy wprowadzasz zmiany niekompatybilne wstecz (tzw. breaking changes). Oznacza to, że nowa wersja może nie działać z konfiguracją lub kodem napisanym pod wersję poprzednią. Aktualizacja do nowej wersji MAJOR prawie zawsze wymaga dodatkowej pracy i testów.
  • MINOR (np. 1.2.3) – Tę cyfrę zmieniasz, gdy dodajesz nowe funkcjonalności w sposób kompatybilny wstecz. Aplikacja zyskuje nowe możliwości, ale wszystko, co działało do tej pory, powinno działać nadal bez zmian.
  • PATCH (np. 1.2.3) – Służy do oznaczania poprawek błędów, które są kompatybilne wstecz. To najbezpieczniejszy rodzaj aktualizacji – nic nowego nie dochodzi, po prostu naprawiasz to, co nie działało poprawnie.

Oprócz samej wersji, można do tagu jeszcze np. dorzucić informację jakiego środowiska dotyczy dana wersja obrazu, albo też dodać konkretny hash commit.

# Semantyczne wersjonowanie
docker build -t moja-aplikacja:1.2.3 .
docker build -t moja-aplikacja:1.2 .
docker build -t moja-aplikacja:1 .

# Z informacją o środowisku
docker build -t moja-aplikacja:1.2.3-alpine .
docker build -t moja-aplikacja:1.2.3-slim .

# Z hash'em commit'a
docker build -t moja-aplikacja:1.2.3-abc123f .

Automatyzacja tagowania w CI/CD

Ręczne tagowanie jest uciążliwe i podatne na błędy. Zawsze praktycznie pracując z aplikacjami i wydania na różnych środowiskach, niezależnie czy to będzie środowisko deweloperskie, przedprodukcyjne czy produkcyjne, używamy procesów CI/CD (np. w Jenkins, GitLab CI, GitHub Actions). Robimy to po to, aby w pełni zautomatyzować tworzenia obrazów, a także, żeby uniknąć popełniania niepotrzebnych błędów w przypadku robienia tego ręcznie.

Przykładowy scenariusz w GitLab CI:

build_image:
  stage: build
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    # Tagowanie hashem commita
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    # Jeśli to tag w Git, dodaj tag wersji
    - if [ -n "$CI_COMMIT_TAG" ]; then
        docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG;
        docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG;
      fi

W tym przykładzie każdy commit buduje obraz z tagiem w postaci hasha. Jeśli dodatkowo stworzymy w Gicie tag (np. v1.2.3), obraz zostanie otagowany również tą wersją.

Plik .dockerignore

Plik .dockerignore działa analogicznie do .gitignore. Zawiera listę plików i katalogów, które mają zostać wykluczone z kontekstu budowania. Kontekst budowania to wszystko, co Docker wysyła do swojego demona przed rozpoczęciem budowy obrazu.

Dlaczego jest to tak ważne?

  1. Wydajność – Im mniejszy kontekst, tym szybciej rozpocznie się proces budowania. Wysyłanie gigabajtów niepotrzebnych plików (jak katalogi target/, build/, node_modules/ czy całe repozytorium .git) jest marnotrawstwem czasu i zasobów.
  2. Bezpieczeństwo – Zapobiega przypadkowemu skopiowaniu do obrazu wrażliwych plików, takich jak lokalne pliki konfiguracyjne, klucze SSH czy pliki z sekretami.
  3. Unikanie problemów z cache’em – Jeśli w kontekście znajdą się pliki, które często się zmieniają, ale nie są potrzebne do budowy (np. logi, pliki IDE), mogą one niepotrzebnie unieważniać cache warstw.

Przykład pliku .dockerignore dla projektu Java/Maven:

# Pliki generowane przez Mavena
target/

# Pliki i katalogi Gita
.git
.gitignore

# Pliki konfiguracyjne IDE
.idea/
*.iml

# Pliki logów
*.log

# System operacyjny
.DS_Store

Unikanie problemów z cache’owaniem warstw

Mimo że cache warstw to jedna z największych zalet Dockera, czasem może sprawiać problemy. Najczęstszym jest sytuacja, w której chcemy celowo unieważnić cache dla danej warstwy, nawet jeśli jej definicja się nie zmieniła.

Przykład: Twoja instrukcja RUN apt-get update jest w cache’u. Jednak od ostatniego builda w repozytoriach apt pojawiły się nowe wersje pakietów. Ponieważ sama instrukcja się nie zmieniła, Docker użyje starej, zbuforowanej warstwy, a Ty nie dostaniesz najnowszych aktualizacji bezpieczeństwa.

Jak sobie z tym radzić?

  • Cache busting za pomocą ARG – Możesz przekazać argument budowania, który będzie się zmieniał przy każdym buildzie, zmuszając Dockera do przebudowania warstwy.
    ARG CACHE_BUSTER=1
    RUN apt-get update && apt-get install -y ...

    Wywołanie docker build --build-arg CACHE_BUSTER=$(date +%s) . za każdym razem unieważni cache.
  • Opcja --no-cache – Flaga docker build --no-cache . całkowicie ignoruje cache i buduje wszystkie warstwy od zera. Używaj jej, gdy chcesz mieć 100% pewności, że wszystko jest budowane na nowo.

Zaawansowane Dockerfile

HEALTHCHECK

Jak już wspomniałem, ta instrukcja pozwala Dockerowi monitorować „zdrowie” aplikacji wewnątrz kontenera. To znacznie więcej niż sprawdzanie, czy proces kontenera jest uruchomiony. Dobrze skonfigurowany HEALTHCHECK może wykryć, że aplikacja zawiesiła się, straciła połączenie z bazą danych lub nie odpowiada na żądania. W środowiskach klastrowych orkiestrator (np. Kubernetes, Swarm) może automatycznie restartować „niezdrowe” kontenery.

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

Parametry:

  • --interval – jak często sprawdzać
  • --timeout – maksymalny czas oczekiwania
  • --start-period – opóźnienie przed pierwszym sprawdzeniem
  • --retries – ile prób przed uznaniem za niezdrowy

Uruchamianie jako użytkownik bez uprawnień roota

Domyślnie procesy w kontenerze działają jako użytkownik root. Jest to poważne ryzyko bezpieczeństwa. Jeśli atakujący uzyska kontrolę nad takim kontenerem, będzie miał uprawnienia root w jego obrębie. Zawsze staraj się tworzyć dedykowanego użytkownika i przełączać się na niego za pomocą instrukcji USER.

# ...
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# ...

Łączenie poleceń w jednej warstwie RUN

Aby zminimalizować liczbę warstw i rozmiar obrazu, łącz powiązane ze sobą polecenia w jednej instrukcji RUN, używając &&. Pamiętaj też o sprzątaniu po sobie w tej samej warstwie!

Źle (tworzy dwie warstwy, zostawia śmieci):

RUN apt-get update
RUN apt-get install -y curl

Dobrze (jedna warstwa, posprzątane):

RUN apt-get update && \
    apt-get install -y curl && \
    rm -rf /var/lib/apt/lists/*

Usunięcie /var/lib/apt/lists/* w tej samej warstwie, w której uruchomiono apt-get update, skutecznie zmniejsza jej rozmiar.

Podsumowanie

Tym o to sposobem dotarliśmy do końca arytukułu o DockerFile. Jak widzisz, ten prosty plik tekstowy kryje w sobie ogromne możliwości. Opanowanie sztuki jego pisania to kluczowa umiejętność każdego nowoczesnego programisty i specjalisty DevOps.

Podsumujmy zatem kluczowe wnioski w temacie budowania własnych plików Dockerfile:

  • Mały obraz – Przy tworzeniu własnych obrazów koniecznie trzeba wybierać lekkie obrazy bazowe (slim, alpine), aby finalne obrazy były jak najmniejsze.
  • Myślenie o warstwach – Układanie w logiczny sposób instrukcji w Dockerfile – od tych najrzadziej zmieniających się do tych najczęstszych – aby maksymalnie wykorzystać cache Dockera.
  • Używanie multi-stage builds – To absolutny standard w budowaniu obrazów produkcyjnych. Oddzielanie środowiska budowania od środowiska uruchomieniowego.
  • Mądre konfigurowanie – Trzeba rozróżniać parametry ARG (dla budowania) od ENV (dla uruchamiania) i nigdy nie umieszczać sekretów bezpośrednio w obrazie.
  • Automatyzuj tagowanie – Oprócz samego tagowania obrazów (nie posługiwanie się tylko tagiem latest), dobrze jest wdrożyć spójne strategie wersjonowania obrazów w swoim potoku CI/CD.
  • Nie należy zapominać o .dockerignore: – Jak chcemy być dobrymi programistami to musimy pamiętać, aby utrzymywać kontekst budowania w czystości, aby przyspieszyć buildy i zwiększyć bezpieczeństwo.

Opanowanie Dockerfile to inwestycja, która zwraca się wielokrotnie w postaci stabilniejszych wdrożeń, szybszego developmentu i mniejszej liczby problemów związanych ze środowiskiem.

Mam nadzieję, że ten artykuł okazał się pomocny. Jeśli masz jakieś pytania, uwagi lub chcesz podzielić się swoimi trikami związanymi z Dockerfile – zostaw komentarz poniżej!

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów
Opinie w linii
Zobacz wszystkie komentarze