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 *.md i inne niepotrzebne w kontekście budowania
  • Pinuj wersje obrazów bazowych — zamiast golang:latest używaj golang:1.23-alpine, aby uniknąć niespodzianek
  • Buduj z flagą --no-cache w 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.