Problem grubych obrazów
Każdy, kto pracował z Dockerem, zna ten scenariusz: budujesz obraz, w którym instalujesz kompilator, narzędzia budowania, zależności deweloperskie — a potem wysyłasz to wszystko na serwer produkcyjny. Efekt? Obraz ważący setki megabajtów, pełen narzędzi, które nigdy nie zostaną użyte w produkcji, za to stanowią potencjalny wektor ataku.
W naszej infrastrukturze hosting'owej obsługujemy kilka serwisów Go, z których każdy kiedyś ważył ponad 300 MB jako obraz Docker. Dziś, dzięki multi-stage builds, największy z nich waży niecałe 15 MB. Oto jak do tego doszliśmy.
Czym jest multi-stage build?
Multi-stage build to technika pozwalająca użyć wielu instrukcji FROM w jednym pliku Dockerfile. Każda instrukcja FROM rozpoczyna nowy etap budowania, a z poprzednich etapów można selektywnie kopiować tylko potrzebne artefakty.
Najprostszy przykład dla aplikacji Go:
# Etap 1: Budowanie
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Etap 2: Produkcja
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
Kluczowa jest instrukcja COPY --from=builder — kopiuje ona plik binarny z etapu budowania do nowego, czystego obrazu. Cały kompilator Go, kod źródłowy i narzędzia budowania zostają za nami.
Wybór bazowego obrazu — Alpine vs Scratch vs Distroless
Etap produkcyjny powinien używać możliwie najmniejszego bazowego obrazu. Mamy kilka opcji:
Alpine Linux (~5 MB) — nasz preferowany wybór. Zawiera podstawowy system plików, menedżer pakietów apk i powłokę shell. Pozwala na instalację dodatkowych narzędzi diagnostycznych i debugowanie kontenerów na produkcji.
scratch (0 MB) — pusty obraz, absolutne minimum. Idealny dla statycznie linkowanych binarek Go. Wadą jest brak jakichkolwiek narzędzi — nie można się nawet zalogować do kontenera w celu diagnostyki.
Distroless (~2 MB) — obrazy Google'a zawierające minimalny runtime bez powłoki. Kompromis między scratch a Alpine — trochę większe, ale zapewniają certyfikaty SSL i podstawowe biblioteki.
W praktyce polecamy Alpine dla większości zastosowań. Różnica kilku megabajtów jest nieistotna, a możliwość uruchomienia sh w kontenerze produkcyjnym potrafi uratować sytuację podczas nocnej awarii.
Optymalizacja warstw — kolejność ma znaczenie
Docker cache'uje każdą warstwę (instrukcję) w Dockerfile. Jeśli warstwa się nie zmieniła, Docker użyje wersji z cache'u zamiast budować ją od nowa. Dlatego kolejność instrukcji ma ogromne znaczenie:
# Źle — każda zmiana kodu wymusza ponowne pobranie zależności
COPY . .
RUN go mod download
RUN go build -o server .
# Dobrze — zależności są cache'owane osobno
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .
W drugiej wersji zmiana kodu źródłowego nie invaliduje warstwy z zależnościami. Pobranie modułów Go (które może trwać minuty) zostanie pominięte, o ile pliki go.mod i go.sum się nie zmieniły.
Bezpieczeństwo — mniejsza powierzchnia ataku
Multi-stage builds mają bezpośredni wpływ na bezpieczeństwo:
- Mniej zainstalowanego oprogramowania — w obrazie produkcyjnym nie ma kompilatora, menedżera pakietów deweloperskich ani narzędzi budowania. Potencjalny atakujący ma mniej narzędzi do dyspozycji.
- Brak kodu źródłowego — w końcowym obrazie nie ma plików
.go, konfiguracji budowania ani plików.env. - Mniejsza liczba podatności — mniej pakietów oznacza mniej potencjalnych CVE do monitorowania.
Dodatkową warstwę bezpieczeństwa zapewnia uruchamianie procesu jako użytkownik nieprivilegowany:
FROM alpine:3.19
RUN adduser -D -g '' appuser
COPY --from=builder /app/server /home/appuser/
USER appuser
CMD ["./server"]
Skanowanie obrazów pod kątem podatności
Sam multi-stage build to nie wszystko. Warto regularnie skanować obrazy produkcyjne pod kątem znanych podatności. Najpopularniejsze narzędzia to:
- docker scout — wbudowany w Docker Desktop, integruje się z Docker Hub
- Trivy (Aqua Security) — darmowy, open-source, doskonały do CI/CD
- Snyk — komercyjny, ale z darmowym planem dla małych projektów
Przykładowe skanowanie z Trivy:
trivy image my-app:latest
Raport wskaże wszystkie znane podatności (CVE) w pakietach bazowego obrazu i zainstalowanych zależnościach, wraz z informacją o dostępności poprawek.
Praktyczne wskazówki
Po latach pracy z multi-stage builds zebraliśmy kilka dodatkowych porad:
- Używaj
.dockerignore— wykluczaj katalogi takie jak.git,node_modules, pliki*.mdi inne niepotrzebne w kontekście budowania - Pinuj wersje obrazów bazowych — zamiast
golang:latestużywajgolang:1.23-alpine, aby uniknąć niespodzianek - Buduj z flagą
--no-cachew CI — w środowisku CI lepiej budować od zera, aby uniknąć nieaktualnego cache'u - Testuj obraz lokalnie przed wdrożeniem —
docker run --rm -p 8080:8080 my-app:latest
Efekty w liczbach
W naszej infrastrukturze przejście na multi-stage builds przyniosło konkretne rezultaty:
| Metryka | Przed | Po | |---|---|---| | Rozmiar obrazu | 320 MB | 12 MB | | Czas budowania (z cache) | 45s | 15s | | Podatności (Trivy) | 47 | 3 | | Czas deploymentu | 30s | 5s |
Te liczby mówią same za siebie. Mniejsze obrazy to szybsze wdrożenia, mniejsze zużycie dysku i przede wszystkim — bezpieczniejsza infrastruktura.
Jeśli Twoje obrazy Docker są większe, niż powinny, chętnie pomożemy je zoptymalizować. Napisz do nas — audyt Dockerfile'a to dobry punkt wyjścia.