Dockerfile jest częścią kodu produkcyjnego. Decyduje, jakie pliki i zależności trafią do obrazu, z jakimi uprawnieniami uruchomi się proces oraz czy ten sam commit da podobny rezultat jutro i za pół roku. Traktowanie go jak jednorazowego skryptu instalacyjnego szybko kończy się wolnymi buildami, niepotrzebnie dużymi obrazami i trudnymi do wyjaśnienia różnicami między środowiskami.
Nie istnieje jeden Dockerfile dobry dla każdej aplikacji. Inaczej buduje się statyczny frontend, inaczej usługę PHP-FPM, a jeszcze inaczej skompilowany program w Go. Powtarzają się jednak zasady, które ograniczają powierzchnię ataku i koszt utrzymania niezależnie od stosu technologicznego.
1. Zacznij od celu, nie od listy komend
Przed napisaniem pierwszego FROM warto ustalić cztery wymagania:
- powtarzalność — zależności i baza są kontrolowane, a build nie zależy od przypadkowego stanu komputera,
- minimalny runtime — finalny obraz zawiera tylko to, czego proces potrzebuje podczas działania,
- najmniejsze potrzebne uprawnienia — aplikacja nie działa jako root bez technicznego powodu,
- weryfikowalność — obraz przechodzi testy, lint i skan bezpieczeństwa przed publikacją.
Rozmiar jest ważny, ale nie może być jedyną metryką. Obraz mniejszy o kilka megabajtów nie jest sukcesem, jeżeli utrudnia debugowanie, wymusza ręczne kopiowanie bibliotek albo korzysta z obrazu, której zespół nie umie aktualizować.
2. Wybierz i kontroluj obraz bazowy
Obraz bazowy staje się częścią łańcucha dostaw aplikacji. Wybieraj obrazy z zaufanego źródła, o znanym cyklu wsparcia i możliwie małym zakresie pakietów. Wariant slim albo alpine może być dobrym wyborem, ale nie automatycznie: Alpine używa musl zamiast glibc, co dla części rozszerzeń i binarek oznacza dodatkowe testy lub problemy z kompatybilnością.
Nie używaj latest jako polityki wersjonowania. Jawny tag ogranicza przypadkowe przeskoki między głównymi wersjami, a digest daje pełną kontrolę nad konkretnym manifestem. Pinning do digestu wymaga jednak procesu aktualizacji, nieruszany digest nie pobierze obrazu zawierającego poprawki bezpieczeństwa.
Buduj obrazy regularnie, również bez zmian w kodzie. W CI używaj świadomie --pull, monitoruj bazowy obraz i aktualizuj zatwierdzone wersje lub digesty w kontrolowanym pull requeście.
3. Oddziel budowanie od uruchamiania
Multi-stage build pozwala użyć kompilatorów, nagłówków systemowych i narzędzi developerskich w etapie budowania, a następnie skopiować do finalnego obrazu tylko gotowe artefakty. To ogranicza rozmiar obrazu, liczbę pakietów wymagających aktualizacji oraz zestaw narzędzi dostępnych potencjalnemu atakującemu.
Dobre rozdzielenie etapów zwykle wygląda tak:
dependenciesinstaluje zależności na podstawie manifestu i lockfile,buildkopiuje kod, uruchamia testy lub kompilację i tworzy artefakt,runtimezawiera wyłącznie pliki potrzebne podczas uruchomienia.
Nazywaj etapy przez AS. Dzięki temu COPY --from=build pozostaje czytelne po zmianie ich kolejności, a pojedynczy etap można łatwo zbudować do diagnostyki. Dla statycznego frontendu finalny obraz nie potrzebuje Node.js, wystarczy gotowy katalog z assetami i serwer HTTP.
4. Ogranicz kontekst przez .dockerignore
Builder może korzystać wyłącznie z plików przekazanych w kontekście. Bez .dockerignore trafiają do niego między innymi lokalne zależności, historia Gita, raporty testów, katalog wynikowy i pliki środowiskowe. To spowalnia przesyłanie kontekstu, częściej unieważnia cache i zwiększa ryzyko przypadkowego COPY.
.git
.github
.env*
node_modules
dist
coverage
*.log
Dockerfile*
docker-compose*.ymlLista musi odpowiadać procesowi projektu — na przykład nie ignoruj Dockerfile, jeśli pipeline kopiuje go do obrazu jako dokumentację. Najważniejsze jest świadome podejście. .dockerignore nie jest jednak magazynem sekretów ani granicą bezpieczeństwa: tokenów i kluczy w ogóle nie należy przechowywać w repozytorium.
5. Układaj instrukcje od stabilnych do zmiennych
Cache jest liczony warstwa po warstwie. Jeżeli przed instalacją zależności wykonasz COPY . ., zmiana jednego pliku aplikacji unieważni również kosztowny krok instalacyjny. Najpierw kopiuj manifesty oraz lockfile, potem instaluj zależności, a dopiero na końcu dodawaj często zmieniający się kod.
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run buildMount typu cache w BuildKit pozwala zachować cache menedżera pakietów pomiędzy buildami bez umieszczania go w finalnej warstwie. Przyspiesza to pracę, ale nie zmienia lockfile i nie zastępuje powtarzalnej instalacji. Sam cache również nie odświeża bazowego obrazu, za to odpowiada oddzielna polityka rebuildów.
6. Instaluj zależności deterministycznie i sprzątaj w tej samej warstwie
Korzystaj z trybu respektującego lockfile: na przykład npm ci zamiast swobodnego npm install. W innych ekosystemach obowiązuje ta sama zasada, CI ma odtworzyć zatwierdzony zestaw zależności, a nie rozwiązywać go od nowa według aktualnego stanu rejestru.
Dla obrazów opartych na Debianie lub Ubuntu połącz aktualizację indeksu, instalację i usunięcie list pakietów w jednej instrukcji. Oddzielne RUN apt-get update może zostać odtworzone z cache niezależnie od instalacji.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*Nie łącz jednak całego builda w jeden nieczytelny RUN. Komendy, których wynik zmienia się razem, warto grupować; niezależne i kosztowne kroki lepiej rozdzielać tak, aby cache nadal był użyteczny. Nie instaluj też narzędzi „na wszelki wypadek”, każdy pakiet zwiększa powierzchnię utrzymania.
7. Nie zapisuj sekretów w ARG, ENV ani COPY
Token do prywatnego rejestru potrzebny podczas builda nie może zostać zapisany w warstwie obrazu. Usunięcie pliku w późniejszym RUN nie usuwa go z wcześniejszej warstwy. Z tego samego powodu przekazywanie hasła przez ARG lub trwałe ENV nie jest bezpiecznym mechanizmem na sekrety.
BuildKit udostępnia sekrety wyłącznie na czas konkretnej instrukcji:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm cidocker build --secret id=npmrc,src=$HOME/.npmrc .Konfigurację runtime'u również dostarczaj przy uruchomieniu kontenera, a nie podczas budowania wspólnego obrazu. Jeden artefakt powinien móc przejść przez test, staging i produkcję bez ponownego wbudowywania haseł lub adresów usług.
8. Uruchamiaj tylko potrzebny proces i ogranicz jego uprawnienia
Jeżeli aplikacja nie wymaga roota, utwórz dedykowanego użytkownika i zakończ etap instrukcją USER. Przy kopiowaniu artefaktów ustaw właściciela przez COPY --chown, zamiast naprawiać cały system plików późniejszym chown -R. Nigdy nie rozwiązuj problemów z uprawnieniami przez chmod -R 777.
Dla CMD i ENTRYPOINT preferuj formę exec, na przykładCMD ["node", "dist/server.js"]. Proces aplikacji staje się wtedy PID 1 i może poprawnie otrzymać sygnał zakończenia. Forma shell uruchamia pośrednią powłokę i bez świadomego użycia exec potrafi utrudnić łagodne zatrzymanie kontenera.
Przygotowując runtime, sprawdź również:
- czy aplikacja zapisuje tylko do jawnie wskazanych katalogów,
- czy da się uruchomić obraz z systemem plików tylko do odczytu,
- czy jeden kontener odpowiada za jeden główny proces,
- czy healthcheck sprawdza realną gotowość, a nie wyłącznie istnienie procesu,
- czy
EXPOSEdokumentuje port, na którym usługa faktycznie nasłuchuje.
9. Przykładowy Dockerfile dla aplikacji Node.js
Poniższy przykład pokazuje układ etapów, cache zależności, osobnego użytkownika i exec formę CMD. Tag obrazu skrócono dla czytelności — w produkcji powinien wynikać z zatwierdzonej polityki wersji lub digestów.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM dependencies AS build
COPY . .
RUN npm run test \
&& npm run build \
&& npm prune --omit=dev
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S app \
&& adduser -S app -G app
COPY --from=build --chown=app:app /app/package.json ./
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]Ten wzorzec trzeba dopasować do frameworka. Jeżeli aplikacja generuje wyłącznie statyczne pliki, finalny etap powinien korzystać z serwera HTTP i kopiować tylko katalog wynikowy. Jeżeli runtime potrzebuje rozszerzeń systemowych, instaluj je w finalnym etapie, ale nie przenoś całego środowiska kompilacyjnego.
10. Na końcu dodaj statyczną analizę i skan obrazu: Hadolint i Trivy
Code review nie musi ręcznie wychwytywać każdej pomylonej instrukcji. Hadolint parsuje Dockerfile, sprawdza reguły dobrych praktyk i wykorzystuje ShellCheck do analizy poleceń wykonywanych w instrukcjachRUN. Wykrywa między innymi brak jawnego tagu, uruchamianie jako root, nieprzypięte wersje pakietów, pozostawione listy APT oraz typowe problemy w skryptach powłoki.
Trivy uzupełnia lint o bezpieczeństwo. Polecenie trivy config analizuje Dockerfile pod kątem błędnej konfiguracji, natomiast trivy image sprawdza gotowy obraz i jego zależności pod kątem znanych podatności oraz potencjalnych sekretów. Skan obrazu wykonuj po buildzie, dopiero wtedy wiadomo, co faktycznie trafiło do runtime'u.
hadolint Dockerfile
trivy config --severity HIGH,CRITICAL --exit-code 1 .
docker build --pull -t my-app:ci .
trivy image --severity HIGH,CRITICAL --exit-code 1 my-app:ciProgi błędów dopasuj do ryzyka projektu. Wyjątki powinny być wąskie, opisane i okresowo przeglądane. Globalne wyciszenie reguły zwykle tylko ukrywa dług. Hadolint i Trivy nie zastępują testów ani świadomego przeglądu architektury obrazu, ale tworzą tanią i powtarzalną pierwszą linię kontroli.
Checklista przed publikacją obrazu
- Obraz bazowy pochodzi z zaufanego źródła i ma kontrolowaną wersję.
- Finalny etap nie zawiera kompilatorów ani zależności developerskich.
- Kontekst jest ograniczony przez aktualny
.dockerignore. - Zależności są instalowane na podstawie lockfile.
- Sekrety są przekazywane przez secret mount, a nie zapisywane w warstwach.
- Proces działa jako użytkownik non-root, jeśli tylko pozwala na to runtime.
CMDlubENTRYPOINTpoprawnie obsługuje sygnały.- Hadolint, testy, build i Trivy przechodzą w CI przed publikacją.
Dockerfile powinien być łatwy do wyjaśnienia
Najlepszy Dockerfile nie jest zbiorem sprytnych sztuczek. Każdy etap ma jasny cel, każda kopiowana ścieżka jest potrzebna, a wyjątek od reguły ma uzasadnienie. Dzięki temu obraz można aktualizować regularnie, diagnozować bez zgadywania i bezpiecznie odtwarzać w CI.
Warto zacząć od prostego pomiaru: czasu builda bez i z cache, rozmiaru finalnego obrazu, liczby pakietów oraz wyników skanowania. Optymalizacja staje się wtedy decyzją opartą na danych, a nie konkursem na najkrótszy Dockerfile.
Materiały źródłowe
- Docker Docs — building best practices,
- Docker Docs — multi-stage builds,
- Docker Docs — build secrets,
- Trivy — misconfiguration scanning,
- Trivy — container image scanning,
- Hadolint — oficjalne repozytorium.
Sprawdźmy build, obraz i drogę na produkcję.
Możemy uporządkować Dockerfile, pipeline CI/CD i bramki bezpieczeństwa bez zatrzymywania rozwoju aplikacji.
Porozmawiajmy o DevOps