Dlaczego obraz Docker waży dziesięć razy więcej niż aplikacja
Usługa Node z kilkuset kilobajtami kodu źródłowego kończy jako obraz ważący ponad 1 GB. To normalny wynik działającego builda, nie błąd w jakiejś konkretnej linii. Waga bierze się z obrazu bazowego, który dźwiga cały system operacyjny, z toolchaina do builda, którego działająca aplikacja nigdy nie wywołuje, i z cache menedżera pakietów, o którego usunięcie nikt builda nie poprosił.
Płacisz za to przy każdym deployu: docker pull działa wolniej, a skaner podatności ma kilkaset dodatkowych pakietów do zgłoszenia. Miejsce na dysku też się kumuluje, na każdym tagu, jaki kiedykolwiek wypchnąłeś. Naprawienie tego oznacza przepisanie Dockerfile, nie aplikacji.
Który obraz bazowy wybrać: alpine, slim czy distroless
Linia FROM ustala punkt wyjścia. Wybierz ją źle, a nic innego w pliku nie nadrobi tej różnicy.
| Obraz bazowy | Przybliżony rozmiar | Co zawiera |
|---|---|---|
node:26 | 1,77 GB | Pełny Debian, kompilatory, wiele runtime’ów różnych języków, dokumentacja |
node:26-slim | 371 MB | Okrojony Debian, bez toolchaina do builda |
node:26-alpine | 247 MB | musl libc, apk, około 50 pakietów |
gcr.io/distroless/nodejs22-debian12 | 212 MB | Sam runtime Node, brak powłoki, brak menedżera pakietów, około 10 pakietów |
Ten wzorzec powtarza się poza Node. debian:bookworm-slim waży około 74 MB wobec 77 MB dla ubuntu:22.04. alpine:latest to około 7 MB. gcr.io/distroless/static-debian12, zbudowany z myślą o statycznie zlinkowanym pliku binarnym Go lub Rust, który nie potrzebuje niczego poza certyfikatami CA, zbliża się do 2 MB.
Alpine osiąga swój rozmiar, zamieniając glibc na musl libc, i ta zamiana jest kosztem: moduły natywne skompilowane pod glibc (część pakietów npm, większość pakietów pip z rozszerzeniami w C) pod musl kończą się segfaultem albo rzucają błędy o brakującym symbolu. Tag -slim omija to ryzyko i mimo to tnie obraz o dwie trzecie. Na Alpine przechodź dopiero po potwierdzeniu, że twoim zależnościom to nie przeszkadza.
Distroless idzie dalej: nie ma powłoki, menedżera pakietów ani niczego, co atakujący mógłby uruchomić po zdobyciu możliwości wykonania kodu. Ty też nie masz sh — i to staje się realnym kosztem, gdy kontener zacznie się zachowywać źle na produkcji.
Kolejność warstw, .dockerignore i mechanika builda wieloetapowego są opisane w Dobre Praktyki Dockerfile: Mniejsze i Szybsze Buildy. Tutaj zakładamy, że ten podział już istnieje, i skupiamy się na tym, czego sam etap builda nie naprawia.
Jak trzymać cache menedżera pakietów poza obrazem
Build wieloetapowy trzyma kompilator poza etapem runtime. Nie usuwa jednak cache menedżera pakietów, który ląduje w tej samej warstwie, w której działała instalacja: niegroźny w etapie builda, który i tak wyrzucasz, ale martwy ciężar w finalnym etapie, jeśli i on coś instaluje.
Cache mounty BuildKit rozwiązują to bez żadnego kroku sprzątającego:
| |
--mount=type=cache daje temu RUN katalog, który przetrwa między buildami i nigdy nie trafia do warstwy. Cache npm nadal przyspiesza kolejny build; nic z tego nie trafia do obrazu. Dla Pythona to RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, dla Go --mount=type=cache,target=/root/.cache/go-build.
Bez BuildKit powiedz instalatorowi, żeby w ogóle nie robił cache: pip install --no-cache-dir, albo npm ci && npm cache clean --force. Obie połówki idą do tego samego RUN. Usunięcie pliku w kolejnej warstwie chowa go za whiteout markerem i zostawia bajty w warstwie poniżej, więc wersja rozbita na dwa RUN i tak wysyła cache. Usługa zbudowana przez Docker Compose z build: . uruchamia ten sam Dockerfile na tym samym builderze, więc cache mounty działają tak samo.
Jak usunąć symbole debugowania i pliki, których aplikacja nigdy nie czyta
Zależności builda to tylko połowa problemu. Zależności runtime niosą pliki, których na produkcji nikt nigdy nie czyta: zestawy testów, dokumentację .md, cache bytecode .pyc, source mapy.
Dla Pythona pomiń cache pip i cache bytecode w warstwie, która instaluje:
| |
Skompilowany plik binarny niesie informacje debugowe, których produkcja nigdy nie czyta. Jedna flaga w etapie builda je usuwa:
| |
| |
-ldflags="-s -w" zwykle zdejmuje 20-30% z binarki Go. Na małym CLI tego nie zauważysz, ale przy dużym grafie zależności to realne bajty. Usunięte symbole kosztują tylko wtedy, gdy podpinasz debugger dokładnie pod binarkę działającą na produkcji, a tego się nie robi na kontenerze bez powłoki, do której mógłbyś się podłączyć.
Jak znaleźć warstwę, która powiększa obraz
Docker zapisał, gdzie poszły bajty. Wystarczy to odczytać:
| |
Jedna linia na warstwę, z instrukcją, która ją utworzyła, i jej rozmiarem. Dodaj --no-trunc, gdy kilka linii RUN wygląda podobnie w przyciętym widoku.
To wskazuje kosztowną instrukcję, nie kosztowne pliki w jej środku. dive robi drugą połowę roboty:
| |
Otwiera terminalowy interfejs do przeglądania warstw, koloruje pliki dodane, zmodyfikowane i usunięte oraz raportuje wynik efektywności razem z całkowitą liczbą zmarnowanych bajtów: miejscem zajętym przez pliki, które kolejna warstwa nadpisała lub usunęła, ale które nigdy nie zniknęły z obrazu. CI=true dive myapp:latest uruchamia ten sam check nieinteraktywnie i kończy się kodem różnym od zera, gdy obraz nie spełnia progów z pliku .dive-ci:
| |
Obraz, który urósł po cichu, przestaje być czymś, co kolega zauważa tygodnie później, a staje się checkiem, który pada na pull requeście, który to spowodował.
Kiedy zmniejszanie obrazu nie jest warte wysiłku
Skrypt, który budujesz raz i uruchamiasz na własnej maszynie, nie potrzebuje bazy distroless ani binarki pozbawionej symboli. Obraz nigdy nie opuszcza twojego dysku, a praca kosztuje więcej niż zaoszczędzony gigabajt. To samo dotyczy obrazu krótkotrwałego joba CI, budowanego od zera przy każdym uruchomieniu: zdjęcie 200 MB z czegoś, co jest pobierane raz i wyrzucane, niczego nie daje.
Ten kompromis daje się we znaki najbardziej na małym końcu skali. Obraz distroless albo scratch nie ma powłoki, więc docker exec -it myapp sh na źle zachowującym się kontenerze po prostu zawodzi. Debugujesz na podstawie logów i lokalnej reprodukcji albo trzymasz osobny wariant debugowy dokładnie do tego celu (distroless publikuje tagi -debug z powłoką busybox). Alpine rezygnuje z mniejszej części tej wygody i w zamian daje ryzyko musl. Żaden z tych kosztów nie jest ukryty i dlatego “wszędzie używaj najmniejszej możliwej bazy” to gorsza rada niż wybieranie bazy usługa po usłudze, w zależności od tego, czego dana usługa naprawdę potrzebuje.
Od czego zacząć przy obrazie, który już masz
Uruchom docker history na tym, co dziś wysyłasz, zanim cokolwiek zmienisz. Największa warstwa to zwykle oczywista poprawka, gdy tylko wiesz, która instrukcja ją utworzyła. Potem uruchom dive na tym samym obrazie z progami podanymi wyżej: uruchomienie, które pada za pierwszym razem, daje ci liczbę do pobicia i raport na poziomie plików pokazujący, gdzie leży marnotrawstwo, co jest lepsze niż zgadywanie w Dockerfile na podstawie tego, co zwykle rozdyma obrazy.