Cel: odchudzić obraz Dockera bez utraty stabilności
Intencja jest prosta: Docker image ma być mały, szybki w deploymencie i przewidywalny, bez niespodzianek typu: „lokalnie działa, w klastrze nie wstaje”, „po zmianie base image posypały się locale” albo „nowy build jest mniejszy, ale testy e2e świecą na czerwono”. Smukły obraz ma przyspieszyć cały łańcuch CI/CD, nie wprowadzać nowych regresji.
Mit, który ciągle się przewija: „rozmiar obrazu jest drugorzędny, ważne żeby działało”. Rzeczywistość: w pojedynczym kontenerze różnica nie rzuca się w oczy, ale przy dziesiątkach środowisk, setkach podów i wielokrotnych deploymentach dziennie każde kilkaset megabajtów mniej to minuty mniej czekania, niższe obciążenie sieci i mniej okazji na błędy.
Dlaczego ciężkie obrazy Dockera zabijają tempo wdrożeń
Rozmiar obrazu a czas pobierania i startu kontenerów
Każdy bajt w obrazie musi zostać pobrany z registry do węzła, który uruchomi kontener. W praktyce oznacza to, że:
- ciężkie obrazy znacząco wydłużają czas pobierania (pull) przy pierwszym wdrożeniu lub na nowym węźle,
- zwiększa się cold start podów w Kubernetesie lub tasków w ECS/Fargate,
- czas rolloutu nowych wersji rośnie, bo nowe nody potrzebują więcej czasu na dociągnięcie warstw.
Przy pojedynczym serwisie różnica między 300 MB a 1,5 GB wydaje się akceptowalna. W mikroserwisowej architekturze, gdzie podczas jednego release’u aktualizuje się kilkanaście aplikacji, każdy z kilkoma replikami, różnica pomiędzy obrazami „fit” i „otyłymi” nagle zaczyna oznaczać minuty lub dziesiątki minut opóźnienia.
Do tego dochodzą sytuacje awaryjne. Przy autoscalingu lub restarcie node’a klaster musi szybko odtworzyć pody. Mały obraz startuje niemal natychmiast, duży – blokuje się na pobieraniu, a w międzyczasie rośnie latencja i maleje dostępność aplikacji.
Ciężkie obrazy a koszty i obciążenie infrastruktury
Rozmiar obrazu wpływa nie tylko na czas, ale i na koszty:
- zużycie transferu sieciowego między registry a workerami rośnie proporcjonalnie do rozmiaru obrazów i częstotliwości deploymentów,
- storage w registry (np. ECR, GCR, Harbor) szybciej się zapełnia, co wymusza agresywne polityki retencji lub dopłacanie za miejsce,
- większe obrazy to częstsze cache misses i więcej I/O na węzłach, które muszą zmieścić równolegle wiele obrazów.
Do tego dochodzi subtelny efekt: ciężkie obrazy są zwykle budowane z ciężkimi Dockerfile’ami – pełnymi zbędnych warstw, narzędzi debugowych, pakietów dev, dokumentacji i testów. Im więcej tego w runtime, tym większa powierzchnia ataku dla potencjalnego intruza, ale także tym większe ryzyko przypadkowej regresji po aktualizacji jakiegoś pakietu systemowego.
Rzeczywistość produkcyjna jest prosta: płaci się za wszystko, czego się używa – także za niepotrzebne biblioteki, które tylko zajmują miejsce i ryzykują podatności.
Złożoność obrazu a ryzyko regresji
Obraz Dockera jest mini-systemem operacyjnym z prekonfigurowanym środowiskiem. Im więcej elementów dodasz, tym trudniej opanować wszystkie interakcje między nimi. Typowe źródła regresji w „przekarmionych” obrazach to:
- dodatkowe repozytoria pakietów (PPA, trzecie mirror’y) z innymi wersjami bibliotek,
- instalowanie „na wszelki wypadek” pełnych metapakietów typu build-essential, curl, git, vim, które w runtime są kompletnie zbędne,
- ręczne „grzebanie” w systemie (zmiana locale, certyfikatów, timezone) bez pełnej kontroli nad efektami ubocznymi.
Każde z tych działań zwiększa entropię środowiska. Potem, przy aktualizacji base image lub rebuildzie, drobna różnica w kolejności instalacji albo w wersjach zależności powoduje zachowanie inne niż w poprzednim wydaniu. Stąd biorą się klasyczne „regresje środowiskowe”, które ciężko odtworzyć.
Smukły obraz wymusza świadomą selekcję: tylko to, co naprawdę jest potrzebne do uruchomienia aplikacji. Mniej komponentów – mniej możliwych interakcji – mniejsze ryzyko, że przy kolejnym deploymencie coś nagle przestanie działać.
Typowe „grzechy główne” w Dockerfile
Praktyka z code review Dockerfile’ów pokazuje zaskakująco powtarzalny zestaw błędów:
- używanie masywnych base image typu ubuntu:latest tam, gdzie wystarczyłby debian:bookworm-slim lub obraz językowy -slim,
- COPY . . bez .dockerignore, przez co do obrazu lądują:
- node_modules, .git, logi, artefakty buildów, pliki tymczasowe,
- konfiguracje lokalne, których nie powinno być w obrazie w ogóle,
- osobne RUN-y dla każdej komendy typu apt-get update, apt-get install, rm -rf /var/lib/apt/lists/*, zamiast zlania ich w jedną warstwę,
- instalowanie narzędzi buildowych w runtime (gcc, make, git) bez multi-stage build,
- pozostawienie w obrazie testów, dokumentacji, sample’i zewnętrznych bibliotek.
Osobna kategoria to mit „obraz jest tylko do developmentu, w produkcji użyjemy innego”. Jeśli produkcyjny obraz buduje się inaczej niż developerski, ryzyko regresji rośnie wykładniczo. Jedna, jasno zdefiniowana ścieżka budowy, zoptymalizowana i testowana, jest zdecydowanie bezpieczniejsza.

Podstawy: jak Docker buduje obraz i skąd biorą się gigabajty
Warstwy obrazu i union filesystem
Docker image składa się z warstw (layers). Każda instrukcja FROM, RUN, COPY, ADD w Dockerfile tworzy nową warstwę. Warstwy są niezmienne: zmiany nie nadpisują poprzedniej warstwy, tylko tworzą nową „nakładkę”.
System plików, który z tego powstaje (overlay2, AUFS, btrfs i podobne), działa na zasadzie union filesystem: kontener widzi połączony widok wszystkich warstw, jakby to był jeden spójny katalog. Gdy w jednej z późniejszych warstw usuniesz plik, fizycznie nadal istnieje on w niższej warstwie, tylko jest oznaczony jako usunięty w wyższej.
To klucz do zrozumienia, dlaczego proste „rm -rf” w osobnym RUN nie zawsze zmniejszy finalny rozmiar obrazu. Jeśli w jednej warstwie dodasz 500 MB, a w następnej usuniesz te pliki, Docker wciąż przechowuje obie warstwy. Dopiero usunięcie plików w tej samej warstwie (czyli w tym samym RUN) zmniejszy rzeczywisty footprint.
Każdy RUN/COPY/ADD ma swoją cenę
Każda instrukcja, która modyfikuje system plików, zostawia ślad w rozmiarze obrazu:
- RUN – wynik działania komendy (zainstalowane pakiety, wygenerowane pliki) trafia do nowej warstwy,
- COPY – dodaje do obrazu wskazane pliki z kontekstu builda,
- ADD – poza kopiowaniem potrafi rozpakowywać archiwa i pobierać pliki przez HTTP/S, co łatwo generuje niespodziewane megabajty.
Przykład problematycznego fragmentu:
RUN apt-get update
RUN apt-get install -y build-essential curl
RUN rm -rf /var/lib/apt/lists/*
Trzy warstwy, z czego ostatnia usuwa cache apt, ale poprzednia już zapisała go trwale. Prawidłowe, „odchudzające” podejście:
RUN apt-get update &&
apt-get install -y --no-install-recommends build-essential curl &&
rm -rf /var/lib/apt/lists/*
Teraz cache apt nie „przetrwa” w żadnej trwałej warstwie – powstaje jedna warstwa zawierająca tylko to, co zostało po usunięciu zbędnych plików.
Rozmiar logiczny vs realnie przesyłane warstwy
Przy pracy z Dockerem przewija się kilka różnych metryk:
- rozmiar obrazu z docker image ls – suma rozmiarów warstw (często uproszczona),
- rozmiar każdej warstwy z docker history,
- ilość danych do pobrania przy pull/push – zależy od tego, które warstwy są już w cache.
Jeśli w klastrze większość węzłów ma już warstwy base image, to przy aktualizacji aplikacji wysyłasz/pobierasz głównie warstwy z własnym kodem. Ma to duże znaczenie: mniejszy „dodatkowy” rozmiar warstw oznacza szybsze rollouty, nawet jeśli teoretyczny rozmiar obrazu z docker image ls wygląda podobnie.
Dlatego tak istotne jest projektowanie Dockerfile z myślą o cache i re-używalności warstw. Jeśli każda drobna zmiana w kodzie przebudowuje cały obraz od zera, tracisz korzyści płynące z cache’u.
Jak Docker cache’uje warstwy i dlaczego kolejność instrukcji ma znaczenie
Docker korzysta z cache warstw w oparciu o:
- treść instrukcji (np. dokładny tekst RUN, COPY),
- zawartość plików kopiowanych w tej instrukcji (np. hash plików z COPY),
- warstwy bazowe (cache jest zależny od tego, co jest poniżej).
Jeżeli zmienisz coś w górnej części Dockerfile, wszystkie poniższe instrukcje przestają być cache’owane i są wykonywane od nowa. To klasyczne źródło wydłużenia czasu buildów w CI: jedna niewinna zmiana konfiguracji przeniesiona „wyżej” skutkuje przebudową całego łańcucha.
Reguła, którą opłaca się stosować: od najmniej zmiennych do najbardziej zmiennych. W praktyce:
- najpierw instalacja systemowych zależności, rzadko zmienianych,
- potem instalacja zależności językowych (na podstawie requirements.txt, package-lock.json itp.),
- na końcu kopiowanie źródeł aplikacji, które zmieniają się najczęściej.
Praktyczny przykład: wykrywanie ciężkiej warstwy
Załóżmy, że obraz ma 1,2 GB i nie bardzo wiadomo, skąd ten rozmiar. Krótka diagnostyka:
docker image ls
docker history <image-id>
Wyjście z docker history pokazuje każdą warstwę z rozmiarem. Typowy „antywzorzec”: jedna z warstw RUN ma rozmiar kilkuset MB, a w komentarzu instrukcję typu:
RUN apt-get update && apt-get install -y some-large-tool
Po sprawdzeniu okazuje się, że some-large-tool w ogóle nie jest potrzebny w runtime – był kiedyś używany w buildzie albo do debuggingu. Samo usunięcie tej instrukcji i przebudowa obrazu potrafi „zrzuć” kilkaset MB bez jakichkolwiek zmian funkcjonalnych.
Wybór base image: od „latest” do kontrolowanych, minimalnych fundamentów
Porównanie typowych baz: ubuntu, debian, alpine, distroless, obrazy „-slim”
Base image to fundament. Od niego zależy nie tylko rozmiar, lecz także bezpieczeństwo i komfort debugowania. Najczęściej spotykane opcje:
| Typ base image | Przybliżona charakterystyka | Plusy | Minusy |
|---|---|---|---|
| ubuntu:latest | Pełna dystrybucja | Znane środowisko, dużo pakietów | Duży rozmiar, spory „szum” pakietów |
| debian:bookworm-slim | Odchudzony Debian | Dobre kompromisy rozmiar/kompatybilność | Mniej pakietów w domyślnym repo |
| alpine:3.x | Bardzo mała dystrybucja z musl | Niewielki rozmiar, proste pakiety | Problemy z glibc, niektórymi binarkami |
| distroless (np. gcr. |
Świadomy dobór wariantu obrazów językowych
Większość języków ma dziś oficjalne obrazy w kilku wariantach: pełny, -slim, -alpine, czasem -bullseye vs -bookworm. Zamiast startować od ogólnego ubuntu:latest, lepiej sięgnąć po „językowy” fundament i dobrać do niego wariant rozmiarowy.
Dla Pythona wybór często sprowadza się do:
python:3.12– pełny Debian z kompletem narzędzi,python:3.12-slim– okrojony Debian, istotnie mniejszy,python:3.12-alpine– minimalny Alpine z musl.
Mit bywa taki: „alpine jest zawsze najlepszy, bo najlżejszy”. Rzeczywistość: dla aplikacji z dużym udziałem natywnych bibliotek (numpy, psycopg2, cryptography) Alpine potrafi być problematyczny – dodatkowa kompilacja, trudniejsze debugowanie, sporadyczne błędy związane z musl. W wielu projektach -slim kończy z lepszym stosunkiem kłopot/korzyść.
Podobnie w Node.js: przejście z node:20 na node:20-slim zbija dziesiątki megabajtów bez żadnej zmiany w aplikacji. Jeśli konieczny jest node:20-bullseye (np. przez zależności systemowe), sensowniej jest oszczędzać na warstwach aplikacyjnych niż wymyślać na siłę egzotyczne kombinacje z Alpine.
Bezpieczne „przycinanie” bazowego systemu
Gdy bazujesz na Debienie/Ubuntu, w zasięgu ręki jest kilka prostych oszczędności:
- używanie
--no-install-recommendsprzyapt-get install, - czyszczenie cache menedżera pakietów w tej samej warstwie,
- usuwanie zbędnych stron manuali i lokalizacji, jeśli obraz ma naprawdę być minimalny.
Przykład „zdrowego” fragmentu w obrazie runtime’owym na Debienie:
RUN apt-get update &&
apt-get install -y --no-install-recommends ca-certificates tzdata &&
rm -rf /var/lib/apt/lists/*
Zmniejszanie bez opamiętania ma jednak drugą stronę. Pojawiają się rady, żeby wyrzucać wszystko, łącznie z ca-certificates czy strefami czasowymi – bo „zawsze można to dociągnąć w runtime”. W praktyce kończy się to dziwnymi błędami TLS, nieprawidłową obsługą czasu lub skomplikowanymi init skryptami. Minimalizować warto, ale bez strzału w stopę.
Gdzie mają sens obrazy distroless
Distroless (np. gcr.io/distroless/base, .../static) to obrazy niemal całkowicie pozbawione userlandu: brak powłoki, menedżera pakietów, narzędzi systemowych. Jest tylko runtime potrzebny do uruchomienia aplikacji (np. JDK, libc) i sama aplikacja.
Ich naturalne zastosowanie:
- proste serwisy HTTP,
- mikroserwisy bez zewnętrznych narzędzi w runtime,
- aplikacje kompilowane statycznie (Go, Rust) – często z obrazem
distroless:static.
Mit krążący wokół distroless brzmi: „distroless jest nie do debugowania, więc nadaje się tylko dla desperatów”. Rzeczywistość: debugujesz ten sam artefakt binarny, ale w innym obrazie – deweloperskim, „grubszym”. Wystarczy utrzymać jednoznaczną, powtarzalną ścieżkę builda i dwa warianty stage’u runtime: „debug” i „prod”.
Projektowanie Dockerfile: praktyczne wzorce redukcji rozmiaru
Porządek instrukcji: od stabilnych do zmiennych
Dla czasu builda i wykorzystania cache kolejność ma krytyczne znaczenie. Dobrze zaprojektowany Dockerfile najczęściej będzie miał układ:
- definicja
FROMi globalnych zmiennych środowiskowych, - instalacja rzadko zmieniających się zależności systemowych,
- kopiowanie i instalacja zależności językowych (requirements, lockfile),
- kopiowanie kodu aplikacji,
- budowanie artefaktów aplikacji (jeśli nie ma osobnego stage’a build),
- definicja
CMD/ENTRYPOINT.
Przestawienie COPY . . wyżej w pliku jest wygodne (bo „od razu mamy wszystko”), ale każda zmiana w kodzie przepala cache także dla instalacji zależności. Przy większych projektach skrócenie builda o kilkadziesiąt sekund w CI robi realną różnicę przy setkach pipeline’ów dziennie.
Scalanie RUN i kontrola nad tym, co ląduje w warstwie
Łączenie wielu komend w jednym RUN to nie tyle „sztuczka”, co normalna higiena przy Dockerze. Chodzi mniej o samą liczbę warstw, a bardziej o to, że masz pełną kontrolę nad tym, co w tej warstwie zostanie.
Przykład z projektem Node.js:
RUN apt-get update &&
apt-get install -y --no-install-recommends python3 make g++ &&
npm ci --only=production &&
apt-get purge -y python3 make g++ &&
apt-get autoremove -y &&
rm -rf /var/lib/apt/lists/*
Narzędzia buildowe istnieją tylko „chwilowo” w tej samej warstwie, co ich usunięcie – finalnie w obrazie zostają tylko zależności Node. Wersja z osobnymi RUN-ami zostawiłaby ich ślady w historii warstw.
Świadome zarządzanie użytkownikami i uprawnieniami
Tworzenie użytkownika nie-root i katalogów roboczych też da się zrobić w sposób sprzyjający powtarzalności i cache’owaniu. Przykładowy fragment:
RUN useradd -r -u 1001 -g root appuser &&
mkdir -p /opt/app &&
chown -R appuser:root /opt/app
WORKDIR /opt/app
USER appuser
Użycie stałego UID i GID ułatwia pracę z wolumenami w Kubernetesie oraz ogranicza niespodzianki z uprawnieniami. Osobny RUN na każde mkdir i chown nie zwiększy radykalnie rozmiaru, ale utrudni analizę obrazu i powielanie wzorców.
Wykorzystanie metadanych LABEL do kontroli nad obrazem
LABEL nie wpływa znacząco na rozmiar, ale ma wpływ na bezpieczeństwo i zarządzanie. Dodanie informacji o wersji aplikacji, commicie, z którego zbudowano obraz, i kontakcie do właściciela serwisu potrafi zaoszczędzić godziny w trakcie incydentu.
LABEL org.opencontainers.image.source="https://git.example.com/team/service"
org.opencontainers.image.revision="<GIT_SHA>"
org.opencontainers.image.created="2024-03-01T10:15:00Z"
Z punktu widzenia regresji to drobnostka, ale gdy masz w rejestrze kilkaset tagów, możliwość szybkiego przypisania, który obraz odpowiada której gałęzi i commitowi, zmniejsza ryzyko wdrożenia „złego” wariantu.

Multi-stage builds: rozdzielenie etapów build vs runtime bez bólu
Podstawowy wzorzec dwóch stage’y
Klasyczny wzór, który działa dla większości projektów:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
USER node
CMD ["node", "dist/index.js"]
W pierwszym stage’u masz wszystko, co potrzebne do zbudowania artefaktów (toolchain, devDependencies). W drugim – tylko to, co jest używane przy uruchamianiu. Regresja wynikająca z różnicy środowisk jest ograniczona, bo oba stage’e używają tego samego base image.
Re-użycie stage’y jako „półproduktów”
Przy bardziej złożonych aplikacjach stage’e buildowe mogą służyć jako materiał wyjściowy dla kilku różnych wariantów runtime. Przydaje się to np. gdy z jednego repo budujesz:
- obraz „web” z serwerem HTTP,
- obraz „worker” z wykonywaniem zadań asynchronicznych,
- obraz „migration” uruchamiany jednorazowo do migracji bazy.
Wzór:
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app ./cmd/web
RUN CGO_ENABLED=0 go build -o worker ./cmd/worker
RUN CGO_ENABLED=0 go build -o migrate ./cmd/migrate
FROM gcr.io/distroless/base-debian12 AS web
COPY --from=build /src/app /app
ENTRYPOINT ["/app"]
FROM gcr.io/distroless/base-debian12 AS worker
COPY --from=build /src/worker /worker
ENTRYPOINT ["/worker"]
FROM gcr.io/distroless/base-debian12 AS migrate
COPY --from=build /src/migrate /migrate
ENTRYPOINT ["/migrate"]
Trzy różne obrazy runtime dzielą wspólny, zcache’owany stage buildowy. Nie ma potrzeby instalowania toolchainu Go w obrazie produkcyjnym, a jednocześnie binarki budowane są identycznie dla wszystkich wariantów.
Multi-stage w kontekście bezpieczeństwa
Oprócz rozmiaru multi-stage znacząco ogranicza powierzchnię ataku. Niejednokrotnie w starych obrazach produkcyjnych leżą zapomniane binarki typu curl, wget, git czy pełne toolchainy, które powstały „na wszelki wypadek”. W środowisku z potencjalnym RCE to niepotrzebny prezent.
Odcinając stage buildowy, w runtime trzymasz tylko to, co jest niezbędne do działania serwisu. Mniej dostępnych narzędzi systemowych oznacza trudniejsze eskalacje dla atakującego i mniejsze skutki uboczne ewentualnego błędu.
Łączenie multi-stage z cache zewnętrznym
Powszechny mit: „multi-stage zawsze spowalnia build, bo jest więcej kroków”. Rzeczywistość: dobrze zaprojektowany multi-stage ułatwia wykorzystanie cache – również zewnętrznego, np. w rejestrze lub w builderach typu BuildKit. Można oddzielnie cache’ować warstwy z dependency build od tych z kompilacją właściwego kodu.
Przy dużych projektach monolitycznych bywa, że stage „dependency” (instalacja bibliotek) jest identyczny przez tygodnie, a zmienia się tylko kod. CI może wtedy używać gotowego obrazu z tego stage’a jako base image zamiast ciągle powtarzać ciężką instalację zależności.
Minimalizacja zależności i plików w obrazie aplikacji
.dockerignore jako pierwszy, najtańszy filtr
Bez pliku .dockerignore każdy plik w katalogu kontekstu builda trafia do demona Dockera. Nawet jeśli nie zostanie potem skopiowany do obrazu, jest przesyłany przez socket i brany pod uwagę przy cache’owaniu COPY. To kosztuje czas i pamięć.
Przykładowy .dockerignore dla Node.js:
.git
node_modules
dist
coverage
*.log
Dockerfile
docker-compose*.yml
Im większe repo, tym mocniej rosną korzyści. W jednym z projektów samo dodanie poprawnego .dockerignore skróciło build w CI z kilku minut do kilkudziesięciu sekund – bez żadnego dotykania kodu aplikacji.
Instalowanie tylko tego, co potrzebne w runtime
W ekosystemach z rozbudowanym drzewem zależności (npm, pip, gem) łatwo ściągnąć setki paczek „bo tak wyszło”. Kilka drobnych decyzji robi różnicę:
- oddzielenie zależności deweloperskich od produkcyjnych (
devDependenciesvsdependencies,requirements-dev.txtvsrequirements.txt), - budowanie w osobnym stage’u i kopiowanie tylko skompilowanych artefaktów (np. JS po bundlingu),
- używanie lockfile (package-lock, poetry.lock, Pipfile.lock) w celu przewidywalności.
Mit: „kilkadziesiąt megabajtów w node_modules nic nie zmienia”. W pojedynczym obrazie faktycznie nie wygląda to groźnie. W klastrze z kilkudziesięcioma serwisami, każdy z własną wersją tych samych bibliotek front-endowych, robi się z tego realny narzut na storage, sieć i czas rolloutów.
Wycinanie testów, dokumentacji i przykładowych danych
Wiele bibliotek dostarczanych w formie archiwów lub paczek PyPI/npm zawiera testy, dokumentację HTML, grafiki i dane przykładowe. Dla produkcyjnego serwisu webowego nie mają one znaczenia, a przy większej liczbie bibliotek generują dziesiątki megabajtów.
Opcje są dwie:
- użycie mechanizmów managera pakietów (np. w pip – instalowanie z wheel zamiast z sdist, w npm –
npm prune --productionw build stage’u), - dedykowany krok czyszczący w multi-stage: kopiuje tylko katalogi/plik, które są potrzebne (np. samo
dist/inode_modules).
Przykład dla aplikacji Python z wirtualnym środowiskiem:
Świadome kopiowanie artefaktów zamiast całych katalogów
Przy multi-stage łatwo wpaść w pułapkę „COPY . .” również między stage’ami. Skoro już zadajesz sobie trud rozdzielenia build vs runtime, dociągnij to do końca i kopiuj tylko to, co jest naprawdę potrzebne do uruchomienia procesu.
Przykład dla Pythona z venv:
FROM python:3.12-slim AS build
WORKDIR /app
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY pyproject.toml poetry.lock ./
RUN pip install --upgrade pip setuptools wheel &&
pip install poetry &&
poetry install --only main --no-root
COPY . .
RUN python -m compileall .
FROM python:3.12-slim AS runtime
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /app
# Kopiujemy samo venv i kod źródłowy, bez cache, testów, narzędzi
COPY --from=build /opt/venv /opt/venv
COPY --from=build /app/app /app/app
COPY --from=build /app/main.py /app/main.py
CMD ["python", "main.py"]
Mit: „kopiowanie całego /app z build stage’u do runtime i tak jest tanie, bo to tylko wewnątrz obrazu”. Rzeczywistość: to nadal nowa warstwa, która trafia do rejestru, jest ściągana przez kubelet, przechowywana na node’ach i duplikuje śmieci, których runtime nie potrzebuje.
Usuwanie śladów po kompilacji i cache managerów pakietów
Cache pip/npm/apt przyspiesza buildy, ale nie jest potrzebny w gotowym obrazie. Utrzymywanie go w runtime dokłada dziesiątki megabajtów, a w skali klastra – gigabajty nieużywanego balastu.
Przykładowe wzorce czyszczenia:
# Python
RUN pip install -r requirements.txt &&
rm -rf /root/.cache/pip
# Node
RUN npm ci --only=production &&
npm cache clean --force
# Alpine + apk
RUN apk add --no-cache build-base ... &&
... &&
apk del build-base
Jeśli korzystasz z multi-stage, cache narzędzi buildowych może spokojnie zostać w stage’u buildowym. Do runtime nie musi się przedostawać nic poza finalnym artefaktem.
Minimalne runtime’y: distroless, scratch i ich pułapki
Obrazy typu distroless lub scratch drastycznie redukują rozmiar, ale odcinają też sporą część „wygody” znanej z klasycznych dystrybucji. Brak shella, package managera, a czasem nawet podstawowych narzędzi diagnostycznych wymusza inne podejście do debugowania.
Przykład z Go na scratch:
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app ./cmd/server
FROM scratch
COPY --from=build /app /app
ENTRYPOINT ["/app"]
Mit: „distroless utrudnia debugowanie, więc nie ma sensu w produkcji”. Rzeczywistość: debugujesz na obrazach „fat” lub w dedykowanych sandboxach, a do produkcji wypychasz minimalny runtime. Telemetria (logs/metrics/traces) i tak powinna wychodzić przez stdout/stderr lub sidecary, a nie przez SSH do kontenera.
Rozdzielenie obrazów: debug vs production
Zamiast próbować połączyć potrzeby developera i produkcji w jednym obrazie, lepiej zbudować dwa warianty z jednego Dockerfile. Jeden zawiera narzędzia diagnostyczne, drugi – tylko to, co konieczne dla serwisu.
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
FROM base AS debug
RUN apt-get update &&
apt-get install -y --no-install-recommends vim curl netcat-openbsd &&
rm -rf /var/lib/apt/lists/*
CMD ["python", "main.py"]
FROM base AS production
CMD ["gunicorn", "app.wsgi:application", "-b", "0.0.0.0:8000"]
CI może budować oba i wypychać z czytelnie opisanymi tagami, np. :1.0.0 i :1.0.0-debug. W klastrze ląduje tylko wariant produkcyjny, a debugowy służy do lokalnych reprodukcji lub krótkotrwałych środowisk diagnostycznych.
Związek optymalizacji obrazu z pipeline’ami CI/CD
Cache warstw jako pierwszy „booster” CI
Nawet najlepiej napisany Dockerfile będzie wolny, jeśli pipeline za każdym razem buduje obraz od zera. Integracja cache warstw z systemem CI to zazwyczaj najszybszy, najtańszy zysk wydajności.
Kluczowe elementy:
- stabilne, przewidywalne base image (konkretne tagi, nie
latest), - osobne kroki COPY dla dependency vs kodu,
- odseparowanie kroków, które zmieniają się rzadko (instalacja toolchainu, dependency) od tych, które zmieniają się często (kod źródłowy).
Na przykład w GitLab CI:
build:
image: docker:24
services:
- docker:24-dind
script:
- docker build
--pull
--cache-from=$CI_REGISTRY_IMAGE:build-cache
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:build-cache
- docker push $CI_REGISTRY_IMAGE:build-cache
Jeden tag (build-cache) przechowuje ostatni znany stan warstw, dzięki czemu kolejne joby budują tylko to, co faktycznie się zmieniło.
BuildKit i cache zdalny
Klasyczny daemon Docker ma ograniczone możliwości cachowania między maszynami CI. BuildKit rozwiązuje ten problem, pozwalając wypychać cache do rejestru lub zewnętrznego backendu i współdzielić go między workerami.
docker buildx build
--builder mybuilder
--cache-to=type=registry,ref=registry.example.com/team/app:cache,mode=max
--cache-from=type=registry,ref=registry.example.com/team/app:cache
-t registry.example.com/team/app:${GIT_SHA} .
Mit: „cache ma sens tylko na pojedynczym serwerze”. Rzeczywistość: przy BuildKit’cie to normalne, że kilka runnerów CI korzysta z wspólnego cache dla dependency. Im więcej buildów na dobę, tym szybciej taki cache się „spłaca”.
Strategia tagowania wspierająca rollback bez regresji
Rozsądne tagowanie obrazów ma większy wpływ na ryzyko regresji niż sam rozmiar. Bez jednoznacznego powiązania: commit → tag → wersja w środowisku, rollback staje się loterią.
Praktyczny minimalny zestaw tagów:
:<GIT_SHA>– niezmienny, powiązany 1:1 z commitem,:<semver>– np.:1.4.2dla wersji releasowych,:<branch>– np.:mainjako „poruszający się” wskaźnik na ostatni build z danej gałęzi.
W manifestach Kubernetesa warto używać tagów niezmiennych (SHA lub semver), a zmienne typu :main wykorzystywać jedynie w narzędziach automatyzujących rollout. Wtedy rollback polega na zmianie tagu na konkretną znaną wersję, a nie „spróbujmy poprzedni build”.
Budowanie obrazów tylko tam, gdzie ma to sens
Duża część zespołów buduje obraz na każdym commitcie do dowolnej gałęzi. Przy większym monolicie i kilkudziesięciu developerkach to przepis na zatkane rejestry i kolejki w CI.
Rozsądniejsze podejście:
- budowanie pełnych obrazów tylko z wybranych gałęzi (main/release/hotfix),
- w gałęziach feature budowanie lżejszych artefaktów (np. same testy + lint),
- ewentualnie build on-demand na feature branch (ręcznie wywołany pipeline).
Obrazy z gałęzi feature można agresywnie czyścić z rejestru po kilku dniach bez zmian. Dla main/release trzyma się dłuższy retention, bo to one wspierają rollbacki.
Walidacja bezpieczeństwa jako element builda, nie osobny proces
Skoro celem jest szybki, przewidywalny deployment bez regresji, to policy typu „skenujemy obrazy raz na tydzień” działa przeciwko tobie. Błąd w dependency wykryty dopiero po wypchnięciu do produkcji to też regresja, tylko bezpieczeństwa.
Dobry pipeline:
- skanuje obraz pod kątem znanych podatności (Trivy, Grype, Snyk) na etapie CI,
- zatrzymuje build lub przynajmniej oznacza go jako „podejrzany” przy krytycznych CVE,
- korzysta z minimalnych, regularnie aktualizowanych base image – dzięki czemu noise z CVE w systemie bazowym jest mniejszy.
Przykład integracji Trivy w GitHub Actions:
- name: Build image
run: docker build -t ghcr.io/org/app:${{ github.sha }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@v0
with:
image-ref: 'ghcr.io/org/app:${{ github.sha }}'
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'
Mit: „skanery obrazów dramatycznie spowalniają CI”. Rzeczywistość: przy rozsądnym cache i selektywnych regułach w większości projektów dodają kilkanaście–kilkadziesiąt sekund. Koszt jednego poważnego incydentu łatwo przesłania taką oszczędność.
Promocja obrazów między środowiskami zamiast ich ponownego buildowania
Powtórne budowanie tego samego obrazka dla DEV/QA/PROD to prosty sposób na wprowadzenie niechcianych różnic, zwłaszcza jeśli base image używają ruchomych tagów. Bezpieczniej jest zbudować obraz raz, a potem promować go między rejestrami/namespace’ami.
Przykładowy model:
- CI po merge do main buduje obraz i taguje go
:<GIT_SHA>, wpycha do rejestru „ci”. - Deploy do DEV/QA referuje
ci/app:<GIT_SHA>. - Po zatwierdzeniu release osobny job kopiuje dokładnie ten sam obraz (docker manifest, nie rebuild) do rejestru „prod” lub pod nowy tag produkcyjny.
W Kubernetesie różne klastry/stage’e mogą wskazywać na różne rejestry lub namespace’y, ale hash obrazu pozostaje ten sam. Jeśli coś działa na QA, wiesz, że w produkcji ląduje identyczny binarnie artefakt.
Obserwowalność procesu build & deploy jako barometr zdrowia optymalizacji
Zmniejszenie obrazu nie jest celem samym w sobie. Liczy się czas od commita do działającego podu oraz powtarzalność. Bez pomiaru tych parametrów trudno ocenić, czy kolejne „optymalizacje” faktycznie pomagają.
Najprościej mierzyć:
- czas budowania obrazu w CI (job „build”),
- czas rollout’u w klastrze (od startu deploy do momentu, gdy wszystkie repliki są „ready”),
- wielkość obrazu i średni czas pobierania na node (dostępny np. w metrykach kubelet/cAdvisor).
Mały, ale powtarzalnie szybki obraz bywa cenniejszy niż ekstremalnie „wycięty” wariant, który wymaga skomplikowanych obejść w pipeline’ach i utrudnia debugging. Tam, gdzie liczysz sekundy i minuty rollout’u, liczby przestają być abstrakcyjne i łatwo zobaczyć, które decyzje w Dockerfile naprawdę robią różnicę.
Najczęściej zadawane pytania (FAQ)
Jak realnie zmniejszyć rozmiar Docker image bez psucia środowiska?
Najwięcej zysku daje kombinacja kilku prostych praktyk: użycie lekkiego base image (np. debian:bookworm-slim zamiast ubuntu:latest), włączenie .dockerignore, łączenie komend w jednym RUN oraz usunięcie z obrazu wszystkiego, co służy tylko do builda (kompilatory, narzędzia dev, testy). Dzięki temu do runtime trafia wyłącznie to, co jest potrzebne aplikacji.
Kluczowy trik: wszystko, co instalujesz tymczasowo, instaluj i usuwaj w jednym RUN. Jeśli w jednej warstwie dołożysz 500 MB, a w kolejnej je skasujesz, realny rozmiar obrazu się nie zmniejszy – Docker zachowa obie warstwy. Z perspektywy bezpieczeństwa i stabilności zyskujesz też mniej pakietów systemowych, a więc mniej potencjalnych podatności i niespodzianek przy aktualizacji.
Czy rozmiar obrazu Dockera naprawdę ma znaczenie dla czasu deploymentu?
Ma i to bardzo, zwłaszcza przy mikroserwisach. Każdy bajt obrazu musi zostać pobrany z registry na node. Przy jednym serwisie różnica między 300 MB a 1,5 GB jest „jakoś do przeżycia”. Przy kilkunastu serwisach, kilku replikach każdego i kilku rolloutach dziennie robi się z tego zauważalne opóźnienie deploymentu.
Różnica jest szczególnie bolesna przy cold startach – nowe nody w klastrze, autoscaling, restart po awarii. Mały obraz wstaje prawie od razu, duży stoi na pullu, a w tym czasie rośnie latencja i spada dostępność. Mit brzmi: „ważne, żeby działało, rozmiar jest drugorzędny”. Rzeczywistość: rozmiar bezpośrednio przekłada się na tempo i niezawodność rolloutów.
Jak odchudzić Dockerfile w praktyce? Jakie błędy popełnia się najczęściej?
Najczęstsze grzechy: zbyt ciężki base image, brak .dockerignore, instalowanie wszystkiego „na wszelki wypadek” i osobne RUN-y dla apt-get update / install / cleanup. Do tego dochodzi kopiowanie całego repo (COPY . .) razem z node_modules, .git, logami i artefaktami buildów, które w obrazie runtime są kompletnie zbędne.
Praktyczny zestaw usprawnień to m.in.:
- zmiana base image na wersję -slim lub lżejszą dystrybucję,
- dodanie .dockerignore (node_modules, .git, logi, pliki tymczasowe),
- łączenie apt-get update, install i rm -rf /var/lib/apt/lists/* w jednym RUN,
- usunięcie narzędzi typu git, vim, build-essential z finalnego runtime.
Mit: „parę dodatkowych warstw i pakietów nic nie zmienia”. W praktyce każda warstwa i każdy pakiet to dodatkowy rozmiar, I/O i potencjalne źródło regresji.
Na czym polega multi-stage build i jak pomaga zmniejszyć obraz Dockera?
Multi-stage build polega na zdefiniowaniu kilku etapów w jednym Dockerfile. W pierwszym (build) instalujesz ciężkie narzędzia: kompilatory, SDK, testy. Budujesz aplikację, a do kolejnego etapu (runtime) kopiujesz tylko gotowy artefakt – binarkę, zbudowany katalog dist, skompilowane jar’y.
Dzięki temu finalny obraz runtime może opierać się na bardzo lekkim base image i nie zawiera niczego, co służy tylko do builda. Znika potrzeba wożenia gcc, make, npm, maven czy pełnego toolchaina w kontenerze produkcyjnym. Z perspektywy stabilności zyskujesz także prostsze, bardziej przewidywalne środowisko uruchomieniowe – mniej komponentów, mniej interakcji między nimi.
Czy warto mieć osobny obraz Dockera dla developmentu i produkcji?
To popularna pokusa, ale jeden z bardziej ryzykownych pomysłów. Jeśli build produkcyjny różni się ścieżką, base image lub zestawem kroków od builda developerskiego, każda różnica w środowisku może wygenerować regresję, której nie wychwycisz lokalnie ani na testach. „U nas działa, w produkcji nie” często bierze się właśnie z rozjechanych obrazów.
Bezpieczniejsze jest jedno źródło prawdy: ten sam Dockerfile i ten sam pipeline, który produkuje obrazy na wszystkie środowiska. Różnią się tylko konfiguracją (env, sekrety, feature flagi), a nie składem systemu i bibliotek. Mit, że „dev image może być brudny, bo i tak w prod użyjemy innego”, kończy się zwykle polowaniem na trudne, środowiskowe bugi.
Jak rozmiar obrazu Dockera wpływa na koszty i zasoby infrastruktury?
Ciężkie obrazy to większe zużycie transferu sieciowego (pull/push między registry a workerami), szybsze zapychanie storage’u w registry i więcej I/O na węzłach. W praktyce oznacza to wyższe rachunki za storage i sieć oraz częstsze czyszczenie cache, co dodatkowo wydłuża czas kolejnych rolloutów.
Do tego dochodzi aspekt bezpieczeństwa: ciężki obraz zwykle zawiera sporo nieużywanych bibliotek, dokumentacji, testów i narzędzi dev. Każdy taki komponent to potencjalna podatność i dodatkowa praca przy łataniu. Rzeczywistość jest prosta: płacisz nie tylko za GB w registry, ale też za obsługę i ryzyko, które te zbędne pakiety dokładają do twojej platformy.
Czemu usuwanie plików w osobnym RUN nie zmniejsza realnie obrazu Dockera?
Docker buduje obraz z niezmiennych warstw. Każdy RUN, COPY i ADD tworzy nową warstwę, a system plików łączy je w jeden widok (union filesystem). Jeśli w pierwszym RUN dołożysz 500 MB, a w drugim RUN je usuniesz, fizycznie przechowywane są obie warstwy: jedna z plikami, druga z informacją, że zostały usunięte.
Efekt: docker image ls pokaże duży rozmiar, a podczas pull/push nadal trzeba przesłać wszystkie warstwy, które nie są w cache. Realne „odchudzanie” polega na tym, żeby pliki tymczasowe (np. cache managera pakietów) pojawiały się i znikały w tej samej warstwie – czyli w tym samym RUN. Mit „zawsze można potem zrobić rm -rf w kolejnym kroku” jest jedną z głównych przyczyn zaskakująco ciężkich obrazów.






