Co to znaczy dobrze
Pytanie z pozoru banalne. Dobry Dockerfile to taki, który się buduje, prawda? No nie do końca.
Wyobraź sobię sytuacje w której wszystko super działa. Wrzucacie nową aplikacje do git, automat już zaczyna wszystko budować, a ty zadowolny czekasz na efekt i czekasz... i czekasz. W końcu masz dość i uznajesz że idziesz do domu bo już ci lecą nadgodziny. Wracasz do domu, nawet nie odłożyłeś plecaka i słyszysz telefon. Okazuje się że po pół godziny czekania produkcja się wywaliła i trzeba siadać do naprawy. To jest moment w którym jednak działa, ale tylko czasami.
I to jest chyba najbardziej mylące określenie w całym IT - "działa". Bo działa na twoim laptopie, działało na demo, działało nawet wczoraj o tej samej porze. A dzisiaj nie działa, bo obraz jest tak wielki, że rejestr zdążył się zatkać, albo używasz bazowego :latest i wprowadziło ci aktualizacje niekompatybiną z twoim kodem, albo ktoś zmienił jedną linijkę w kodzie i cache poszedł w drzazgi, więc build zamiast trzydziestu sekund trwa trzydzieści minut, akurat wtedy, kiedy najbardziej ci się spieszy.
"Dobrze" nie znaczy więc "się zbudował" albo "jakoś się teraz buduje". Znaczy: zbudował się szybko, przewidywalnie, i nie robi po drodze niczego, co mogłoby cię zaskoczyć, zwłąszcza w piątek o 15:00. Poniżej napisałem, moim zdaniem, dziesięć nawyków, które (jak się nie stosuję) prędzej czy później wracają do mnie dokładnie w takiej formie, jak w naszej historyjce.
1. Nie uruchamiaj kontenera jako root
Jak nic nie ustawisz, kontener startuje jako root. Nikt tego specjalnie nie wybiera, po prostu tak jest domyślnie. I dopóki nic złego się nie dzieje, nie robi to żadnej różnicy. Problem zaczyna się w momencie, gdy coś jednak pójdzie nie tak - podatność w bibliotece, źle zabezpieczony endpoint, cokolwiek z długiej listy rzeczy, które zdarzają się nawet najlepszym. Wtedy root w kontenerze przestaje być teorią, a zaczyna być realną drogą do czegoś poważniejszego niż wywalenie jednego serwisu.
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app # Tworzenie użytkownika/grupy
WORKDIR /app
COPY --chown=app:app . . # Kopiowanie z nadaniem uprawnień
USER app # Ustawienie default user jako app
CMD ["node", "server.js"]
Trzy linijki, a robią różnicę. Jedna droga ataku mniej do rozważenia.
2. Używaj Multi-Stage Build, czyli buduj w jednym etapie, wysyłaj z drugiego
Żeby coś zbudować, potrzebujesz kompilatora, SDK, czasem połowy narzędzi deweloperskich. Żeby to uruchomić, nie potrzebujesz z tego kompletnie nic. Multi-stage build rozdziela te dwa etapy: jeden buduje, drugi tylko przenosi gotowy kod do czystego obrazu.
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o app .
FROM alpine:3.19
COPY --from=builder /src/app /app
ENTRYPOINT ["/app"]
Finalny obraz nie ma pojęcia, że kiedykolwiek istniał tam kompilator. Zna tylko gotową binarkę - i to mu w zupełności wystarcza.
3. Wsadzaj do jednego kontenera tylko jedną usługę
Backend, worker i baza w jednym obrazie brzmią kusząco wygodnie - jeden docker run i wszystko rusza naraz. Tyle że kontener nigdy nie miał być małą maszyną wirtualną, tylko opakowaniem dla pojedynczej usługi.
Upchnięcie kilku usług w jednym miejscu oznacza, że nie da się ich skalować osobno, a logi zamieniają się w jeden strumień, z którego trzeba się domyślać, który proces akurat oszalał. Jedna usługa, jeden kontener - a jak potrzebujesz kilku naraz, od tego jest docker-compose albo Kubernetes.
4. Używaj konkretnego tagu obrazu, nigdy latest
Wracamy do tego, co wywaliło produkcję na samym początku. latest to nie jest wersja, to punkt w czasie. Dzisiaj wskazuje na jedną rzecz, za pół roku - być może na zupełnie inną, z inną wersją biblioteki i innym zachowaniem, którego nikt się nie spodziewał.
FROM python:latest # Tak nie
FROM python:3.12.3-slim # Tak lepiej
Konkretny tag daje dużo większą przewidywalność niż latest, bo świadomie wskazujesz wersję obrazu, na której chcesz budować. Jeśli potrzebujesz pełnej powtarzalności, możesz pójść krok dalej i przypiąć obraz po digest sha256, który wskazuje konkretną, niezmienną zawartość obrazu.
5. Kopiuj zależności przed kodem źródłowym
Obrazy są jak cebule... mają warstwy. Docker trzyma warstwy jak stos - zmiana czegoś na dole oznacza, że wszystko powyżej trzeba poukładać od nowa. Dlatego to, co zmienia się rzadko, czyli zależności, powinno lądować w Dockerfile przed tym, co zmienia się bez przerwy, czyli kodem źródłowym.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # Najpierw zależności
RUN npm ci
COPY . .
RUN npm run build # Potem kod źródłowy
CMD ["node", "dist/index.js"]
Dzięki temu jedna zmieniona linijka w kodzie nie każe instalować node_modules od zera. Build, który potrafił rozciągnąć się na kilkadziesiąt minut budowania, potrafi zejść do kilkunastu, kilkudziesięciu sekund.
6. Kontroluj, co dokładnie wpada do obrazu
Docker domyślnie nie wie, co jest ważne, a co śmieciem. Kopiuje to, co mu każesz. Są tu dwa poziomy kontroli i oba warto ogarnąć razem, bo działają na tej samej zasadzie: mniej niekontrolowanego kopiowania.
Pierwszy poziom to .dockerignore - filtr na wejściu do kontekstu builda:
.git
node_modules
.env
*.log
Dockerfile
Bez tego pliku Docker pakuje do kontekstu builda wszystko, co akurat leży w katalogu. Łącznie z sekretami w .env i całą historią z .git. To nie jest kwestia porządku. To prosta droga do tego, żeby ktoś kiedyś wyciągnął z obrazu coś, czego wyciągnąć nie powinien.
Drugi poziom to sposób, w jaki kopiujesz to, co zostało po filtrze. COPY . bierze do obrazu cały pozostały katalog jedną komendą i unieważnia cache przy każdej, nawet najdrobniejszej zmianie. Również w plikach, które finalnej aplikacji zupełnie nie interesują.
# Zamiast tego
COPY . /app
# Lepiej
COPY package*.json ./
COPY src ./src
Kopiowanie punktowo, tylko tego, co faktycznie potrzebne, daje mniejszy obraz i cache, który przeżywa więcej niż jeden build. .dockerignore mówi "tego nigdy nie wpuszczaj", selektywny COPY mówi "to, co wpuszczasz, wpuszczaj w kawałkach".
7. Skanuj gotowy obraz pod kątem podatności
Sam fakt, że Dockerfile się zbudował, nic nie mówi o tym, co po drodze wylądowało w środku. Nawet oficjalne obrazy bazowe potrafią przynosić ze sobą znane podatności w pakietach systemowych.
trivy image moja-aplikacja:1.0
Najlepiej wpiąć taki skan bezpośrednio do pipeline'u, tak żeby obraz z krytyczną podatnością nie miał prawa dojechać dalej niż do czerwonego komunikatu w CI.
I tu mała riposta losu, o której warto wiedzieć. W marcu 2026 sam Trivy padł ofiarą ataku na łańcuch dostaw - ktoś przejął pipeline CI/CD Aqua Security i wstrzyknął złośliwy kod do obrazów aquasec/trivy, między innymi do tagu latest. Kto w tym czasie ściągał skaner z poprawnie przypiętą wersją, był bezpieczny. Kto leciał na latest, mógł wpuścić sobie do CI narzędzie do kradzieży sekretów zamiast narzędzia do wykrywania podatności.
8. Wybieraj minimalny obraz bazowy
Zamiana pełnego Debian na Alpine czy wariant slim potrafi ściągnąć rozmiar obrazu z kilkuset megabajtów do kilkudziesięciu. Mniej megabajtów to szybszy pull na produkcji, mniej miejsca na dysku i mniej zainstalowanych pakietów, które teoretycznie mogłyby przynieść ze sobą problem. Wszystko szybko i sprawnie.
Warto tylko pamiętać, że Alpine korzysta z musl zamiast standardowego glibc, więc czasem jakaś binarka potrafi zachować się inaczej, niż się spodziewasz. To nie powód, żeby z Alpine rezygnować. To powód, żeby przetestować obraz, zanim zrobi to klient na produkcji.
9. Dodaj HEALTHCHECK, żeby orkiestrator wiedział, kiedy naprawdę coś nie działa
Proces w kontenerze potrafi wisieć zawieszony, nie odpowiadając na nic, a Docker czy Kubernetes i tak widzi go jako "działający", bo z ich perspektywy proces po prostu żyje, co z tego, że jako żywy trup. HEALTHCHECK daje orkiestratorowi sposób, żeby zapytać aplikację wprost: żyjesz naprawdę, czy tylko formalnie?
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci && npm run build
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
Bez tego kontener, który się zawiesił, może stać jako "zdrowy" godzinami, aż ktoś zauważy ręcznie, że coś jest nie tak, a w gorszym scenariuszu zauważy to dopiero klient. Z HEALTHCHECK orkiestrator sam wykryje problem i zrestartuje kontener, zanim ktokolwiek w ogóle będzie musiał odebrać telefon.
10. Wychodź od oficjalnego obrazu bazowego, nie buduj go od zera
Zanim zaczniesz ręcznie instalować runtime przez serię wget i apt-get na gołym ubuntu, sprawdź, czy dany język albo framework nie ma już swojego oficjalnego obrazu.
FROM eclipse-temurin:25-alpine
Oszczędzasz sobie czas, a przy okazji dostajesz obraz, który zwykle jest regularnie łatany przez kogoś, kto robi to zawodowo. Jeśli do tego używasz tego samego obrazu bazowego w kilku projektach, wszystkie korzystają z tych samych, już raz ściągniętych warstw.
Podsumowanie - "dobrze" to suma drobiazgów
Żadna z tych dziesięciu zasad osobno nie robi rewolucji. Jedna linijka USER tutaj, konkretny tag zamiast latest tam. Ale złożone razem dają obraz, który buduje się w sekundach zamiast trzydziestu minutach, waży dziesiątki megabajtów zamiast gigabajta i, co najważniejsze, nie sprawia niespodzianek, więc można zająć się swoim wieczornym odpoczynkiem.
Nie trzeba wdrażać wszystkiego jednego dnia. Zacznij od .dockerignore i konkretnego taga zamiast latest - to pięć minut. Potem kolejność instrukcji i multi-stage build, bo tam zwykle jest największy zysk. Reszta przyjdzie naturalnie, gdy Dockerfile przestanie być plikiem, który się po prostu odpala, a stanie się czymś, co świadomie piszesz. Najlepiej zanim, a nie po tym, jak zadzwoni telefon.