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.

Powtarzalność nie oznacza braku aktualizacji

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:

  • dependencies instaluje zależności na podstawie manifestu i lockfile,
  • build kopiuje kod, uruchamia testy lub kompilację i tworzy artefakt,
  • runtime zawiera 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.

.dockerignore
.git
.github
.env*
node_modules
dist
coverage
*.log
Dockerfile*
docker-compose*.yml

Lista 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.

Dockerfile — cache zależności
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .
RUN npm run build

Mount 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.

Dockerfile — pakiety systemowe
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:

Dockerfile — secret mount
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
shell
docker 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 EXPOSE dokumentuje 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.

Dockerfile
# 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.

CI — minimalna bramka jakości
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:ci

Progi 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.
  • CMD lub ENTRYPOINT poprawnie 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

Potrzebujesz przewidywalnego delivery?

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
← Wróć do wszystkich wpisów