Ile kosztuje źle napisany Dockerfile

1
2
3
4
5
6
FROM node:latest

COPY . .
RUN npm install

CMD ["node", "server.js"]

Cztery linijki, a każda coś kosztuje. docker build uruchamia npm install od nowa przy każdej zmianie kodu, bo COPY . . psuje cache warstw, zanim krok instalacji w ogóle dostanie szansę na ponowne użycie. Finalny obraz niesie ze sobą całą bazę Debiana, cały katalog node_modules razem z zależnościami deweloperskimi i wszystkimi narzędziami budowania, które npm install ściągnął do kompilacji modułów natywnych. Nic nie jest odrzucane. Kontener działa jako root, bo nikt mu nie kazał inaczej, a node:latest oznacza, że obraz bazowy może się zmienić między jedną a drugą buildą, bez żadnego śladu tego, co faktycznie wypuściłeś.

Nic z tego nie wygląda jak błąd. Obraz się buduje, kontener startuje, aplikacja odpowiada. Koszt pojawia się później: pięciominutowy build CI, bo każdy krok zaczyna od zera, i obraz o wadze 1,1 GB dla aplikacji, której własny kod waży kilkaset kilobajtów. Każda poprawka poniżej jest niewielka. Razem zmieniają, ile Dockerfile kosztuje cię każdego dnia.

Jak Docker decyduje, co przebudować w cache warstw

Docker buduje obraz instrukcja po instrukcji i zapisuje wynik każdej z nich w cache jako warstwę. Przy kolejnej buildzie przechodzi Dockerfile jeszcze raz i wykorzystuje warstwę z cache, dopóki instrukcja jest identyczna, a warstwa poprzedzająca ją w łańcuchu się nie zmieniła. Gdy jedna instrukcja nie trafi w cache, wszystkie kolejne też uruchamiają się od nowa. Trafienia w cache wydłużają łańcuch tylko od góry. Jeśli obrazy, warstwy i kontenery wciąż są niejasne, czym jest Docker i jak działają kontenery opisuje podstawy, które ten artykuł zakłada jako znane.

COPY i ADD unieważniają cache na podstawie zawartości, nie samego tekstu instrukcji: jeśli jakikolwiek kopiowany plik się zmienił, cache tej warstwy znika, a razem z nim wszystko, co jest po niej. COPY . . kopiuje cały kontekst budowania, więc unieważnia cache przy każdej zmianie w dowolnym miejscu projektu, nawet przy przeredagowanym komentarzu w pliku, który nie ma nic wspólnego z zależnościami. Umieść RUN npm install zaraz po takim COPY, a będzie się uruchamiać przy każdej pojedynczej buildzie.

Jak ustawić kolejność instrukcji w Dockerfile pod trafienia w cache

Kopiuj tylko to, czego dana instrukcja potrzebuje, w kolejności, w jakiej rzeczy naprawdę się zmieniają: manifesty zależności rzadko, kod źródłowy bez przerwy.

1
2
3
4
5
6
7
8
9
FROM node:24-slim
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

CMD ["node", "server.js"]

package.json i package-lock.json zmieniają się, gdy dodajesz lub aktualizujesz zależność. RUN npm ci trafia teraz w cache tylko na podstawie tych dwóch plików. Zmień plik źródłowy i przebuduj: Docker wykorzystuje warstwę instalacji z cache i uruchamia od nowa tylko końcowy COPY i to, co po nim następuje. Najwolniejszy krok buildy uruchamia się tylko wtedy, gdy naprawdę musi.

npm ci zamiast npm install to celowy wybór. Instaluje dokładnie to, co jest w pliku lockfile, i kończy się błędem, jeśli lockfile i package.json się nie zgadzają, zamiast po cichu rozwiązywać nowe wersje wewnątrz buildy, która miała być powtarzalna.

Ta sama kolejność obowiązuje poza Node: projekt w Pythonie kopiuje requirements.txt i uruchamia pip install przed resztą kodu źródłowego, projekt w Go kopiuje go.mod/go.sum i najpierw uruchamia go mod download.

Jak multi-stage build zmniejsza finalny obraz

Zmiana kolejności naprawia cache. Nie zmienia niczego w obrazie, który wciąż niesie zależności deweloperskie, narzędzia budowania i wszystko, co npm ci ściągnął do kompilacji modułów natywnych, choć aplikacja niczego z tego nie potrzebuje do działania. Multi-stage build oddziela to, czego potrzeba do zbudowania aplikacji, od tego, czego potrzeba do jej uruchomienia.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# syntax=docker/dockerfile:1
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist

CMD ["node", "dist/server.js"]

Dwie linijki FROM, dwa etapy. Pierwszy, nazwany build, instaluje wszystkie zależności (łącznie z tymi tylko deweloperskimi, jak bundler czy TypeScript) i tworzy dist/. Drugi zaczyna od nowa z tego samego obrazu bazowego, instaluje tylko zależności produkcyjne i pobiera jeden katalog z pierwszego etapu poleceniem COPY --from=build. Kompilator TypeScript, źródłowe pliki .ts, cache npm: nic z tego nie trafia do finalnego obrazu, bo etap budowania jest wyrzucany zaraz po zakończeniu buildy.

Różnica w rozmiarze jest tu kluczowa. node:latest, pełny obraz oparty na Debianie, waży blisko 1,1 GB, zanim dodasz choćby jedną zależność. node:24-slim mieści się poniżej 300 MB, a node:24-alpine poniżej 200 MB, jeśli twoje zależności nie potrzebują glibc. Pomnóż to przez każdy serwis, który uruchamiasz, i każdy runner CI, który ściąga obraz, a różnica zamienia się w realną przestrzeń dyskową i realne minuty.

Efekt jest jeszcze większy w przypadku języka kompilowanego, gdzie binarka nie potrzebuje niczego poza sobą samą.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Żadnej powłoki, żadnego menedżera pakietów, żadnego toolchainu Go w finalnym obrazie: tylko statyczna binarka plus certyfikaty CA i dane stref czasowych, które dostarcza distroless/static. Atakujący, który zdobędzie wykonanie kodu w takim kontenerze, nie ma sh do uruchomienia ani apt, żeby taką powłokę zainstalować.

Co umieścić w .dockerignore

COPY . . wysyła cały kontekst budowania do daemona Docker, zanim build w ogóle się zacznie, a ten kontekst zawiera pliki, których nigdy nie chciałeś wysyłać: .git, lokalny node_modules, pliki .env z prawdziwymi danymi uwierzytelniającymi, konfigurację edytora. .dockerignore obok Dockerfile wyklucza je, używając tej samej składni wzorców co .gitignore.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.git
.gitignore
node_modules
npm-debug.log
.env
.env.*
Dockerfile
.dockerignore
dist
*.md

Rozmiar to nie jedyny powód, żeby wykluczyć node_modules. Jeśli lokalna instalacja na twojej maszynie ma moduły natywne skompilowane dla macOS i arm64, COPY . . wysyła te binarki do kontenera linuksowego, gdzie się nie załadują. Pozwól kontenerowi instalować własne zależności.

Jak uruchomić kontener jako użytkownik non-root

Nic w zwykłym Dockerfile nie powstrzymuje kontenera przed działaniem jako root, więc domyślnie tak właśnie się dzieje. Jeśli atakujący zdobędzie wykonanie kodu wewnątrz, przez podatność w zależności albo błąd deserializacji, roota w kontenerze od roota na hoście dzieli tylko błąd jądra albo źle skonfigurowany mount. Większość oficjalnych obrazów ma już gotowego użytkownika, na którego możesz się przełączyć.

1
2
3
4
5
6
7
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

Obraz node tworzy użytkownika node, więc USER node to jedyna linijka, którą dodajesz. Jeśli obraz bazowy nie ma takiego użytkownika, stwórz go, zanim się przełączysz:

1
2
RUN groupadd -r app && useradd -r -g app app
USER app

--chown liczy się tak samo jak USER. Bez niego pliki należą do roota, podczas gdy proces, który je czyta, działa jako app, i wszystko, co aplikacja zapisuje w czasie działania, kończy się błędem uprawnień, który odkrywasz dopiero na produkcji. To samo dotyczy woluminu Docker zamontowanego w kontenerze: jego zawartość musi być zapisywalna przez UID, na który się przełączyłeś, inaczej pierwszy zapis padnie już przy starcie.

Jak przypiąć tag lub digest obrazu bazowego

FROM node:latest odwołuje się do tego, na co wskazuje latest w dniu, w którym budujesz. Przebuduj ten sam Dockerfile w przyszłym miesiącu, a możesz trafić na inną wersję major, inne wydanie Debiana, inne pakiety systemowe, bez żadnego diffa w repozytorium pokazującego, co się zmieniło. Przypnij wersję:

1
FROM node:24.9.0-slim

Dla buildy, która musi być odtwarzalna bajt w bajt, przypnij też digest. Wiąże to build z jednym niezmiennym obrazem, bez względu na to, co stanie się z tagiem:

1
FROM node:24.9.0-slim@sha256:9c1f9c1a...

Digest zdobędziesz poleceniem docker pull node:24.9.0-slim, a potem docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Przypinanie tagu wystarcza w większości projektów. Przypinanie digesta jest dla pipeline’ów, gdzie na pytanie “co dokładnie wypuściliśmy” trzeba umieć odpowiedzieć jeszcze miesiące później.

Kiedy łączyć kilka RUN w jedną warstwę

Każdy RUN tworzy warstwę, a warstwa może tylko rosnąć. Usunięcie pliku w kolejnej warstwie nie zmniejsza obrazu, tylko ukrywa plik za whiteout markerem, podczas gdy wcześniejsza warstwa nadal niesie te bajty. Dlatego rozdzielenie instalacji i sprzątania na dwa osobne RUN niczego nie oszczędza:

1
2
3
4
# Źle: cache apt z pierwszego RUN zostaje zapieczony w warstwie,
# której drugi RUN nie może usunąć z obrazu
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

Umieść sprzątanie w tej samej warstwie co instalację:

1
2
3
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

Dotyczy to instalacji, które zostawiają po sobie pliki: cache menedżerów pakietów, pobrane archiwa, artefakty budowania, które już skopiowałeś gdzie indziej. To nie jest argument za łączeniem każdego RUN w pliku. Kilka czytelnych warstw łatwiej debugować niż jedną 400-znakową linijkę, a sama liczba warstw nie jest tym, co kosztuje cię rozmiar.

Kiedy te praktyki się nie opłacają

Jednorazowy skrypt, który uruchamiasz lokalnie poleceniem docker build -t scratch . && docker run --rm scratch, nie potrzebuje ani multi-stage buildu, ani przypiętego digesta. Cała ta ceremonia kosztuje więcej niż ryzyko, którego unika.

Mniejszy rozmiar Alpine bierze się z musl libc zamiast glibc, co psuje natywne moduły Node i Pythona skompilowane pod glibc. Segfaulty albo błędy brakującego symbolu po przejściu na -alpine to zwykle właśnie to. Użyj -slim, gdy nie masz pewności.

A przypinanie wersji to wybór, który ma swój koszt: nieprzypięty obraz bazowy dostaje poprawki bezpieczeństwa przy kolejnym docker build --pull, bez żadnej twojej ingerencji. Jeśli tego chcesz, podejmij tę decyzję świadomie, wiedząc, że build może zacząć zachowywać się inaczej z powodów, których nie ma w twoim diffie. Zostawienie taga otwartego przez przypadek to nie ta sama decyzja.

Jak zmierzyć rozmiar obrazu i czas buildy

Zbuduj obie wersje tej samej aplikacji i porównaj:

1
2
3
4
docker build -t app:before -f Dockerfile.before .
docker build -t app:after -f Dockerfile.after .
docker images app --format "table {{.Tag}}\t{{.Size}}"
docker history app:after

docker images pokazuje różnicę w rozmiarze. docker history pokazuje, która instrukcja utworzyła którą warstwę i ile ona waży — to najszybszy sposób, żeby znaleźć ten jeden RUN, który wciąż ciągnie za sobą coś, czego nie powinien. Nic z tego się nie zmienia, jeśli twoją aplikację buduje Docker Compose: serwis z build: . używa tego samego Dockerfile, tego samego cache’a i tego samego kontekstu budowania, więc docker compose build zyskuje te same korzyści.

Odziedziczyłeś Dockerfile sprzed tych wszystkich zasad? Zbuduj go raz w obecnej postaci, uruchom docker history i popraw warstwę, która najbardziej zaskakuje. Ta jedna warstwa zwykle stanowi większość różnicy.

Powiązane artykuły