Ile kosztuje źle napisany Dockerfile
| |
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.
| |
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.
| |
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ą.
| |
Ż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.
| |
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ć.
| |
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:
| |
--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ę:
| |
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:
| |
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:
| |
Umieść sprzątanie w tej samej warstwie co instalację:
| |
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:
| |
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.