Jak zmniejszyć obraz Dockera? Od 1.3 GB do 8 MB w kilku krokach
#docker #devops #optymalizacja #golang #java #kontenery

Jak zmniejszyć obraz Dockera? Od 1.3 GB do 8 MB w kilku krokach

Pokażę Ci konkretne techniki, które pozwoliły mi zbić obraz Dockera z ponad gigabajta do kilku megabajtów. Multistage builds, distroless, scratch, warstwy cache — bez teorii, same liczby.

6 min czytania

Jak zmniejszyć obraz Dockera

Kiedy budowałem swój pierwszy obraz Dockerowy, zrobiłem to tak, jak większość osób na początku: FROM golang:1.23, skopiuj pliki, zbuduj binarke, gotowe. Obraz ważył 1.34 GB. Aplikacja? 7 MB.

Tak, dobrze widzisz — 7 MB kodu w opakowaniu z 1.34 GB narzędzi, których w produkcji nie potrzebuję. To jak wieźć kierowcę F1 na wyścig w ciężarówce pełnej piaskownicy.

W tym poście pokażę konkretne techniki, które pozwoliły mi sprowadzić ten sam obraz do 7.79 MB. Nie teorii — tylko konfiguracje i liczby.


Dlaczego w ogóle zależy nam na rozmiarze?

Zanim przejdziemy do technik, szybko powiem dlaczego to ważne:

  • Szybsze deploye — mniejszy obraz to krótszy docker pull w CI/CD. Przy wielu deployach dziennie, różnica między 1 GB a 10 MB jest odczuwalna.
  • Mniejsza powierzchnia ataku — każda paczka w obrazie to potencjalna luka bezpieczeństwa. Golang 1.23 miał 45 CVE (2 krytyczne), a po przejściu na minimalistyczny obraz zostają tylko te związane z samą aplikacją.
  • Koszt — registry przechowuje obrazy. Przy dużej organizacji i setkach serwisów, setki megabajtów na każdą wersję szybko się sumują.
  • Zimny start — szczególnie ważne przy skalowaniu horyzontalnym w Kubernetes. Nowy pod musi pobrać obraz przed uruchomieniem.

1. Multistage builds — największy przełom

Multistage builds — diagram

To jedna zmiana, która daje największy efekt. Zamiast budować i uruchamiać aplikację w jednym kontenerze, rozdzielamy etap budowania od etapu uruchamiania.

Jak wygląda naiwny Dockerfile dla Go?

FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main .
EXPOSE 8080
CMD ["./main"]

Wynik: 1.34 GB. W środku jest cały toolchain Go, kompilator, debugger — rzeczy, których nie potrzebujesz do uruchamiania aplikacji w produkcji.

Multistage build — etap 1: buduj

FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main .

Dwa szczegóły, które robią różnicę:

  • CGO_ENABLED=0 — kompiluje statycznie zlinkowaną binarke. Nie wymaga żadnych bibliotek systemowych w runtime.
  • -ldflags="-s -w" — usuwa informacje debuggera i tablicę symboli z binarki. Sama binarka jest lżejsza o kilkanaście procent.

Multistage build — etap 2: uruchamiaj

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/main .
USER 65534
EXPOSE 8080
CMD ["./main"]

FROM scratch to dosłownie pusty obraz. Zero systemu operacyjnego, zero shellu, zero narzędzi. Kopiujemy tylko to, czego potrzebujemy: binarke i certyfikaty CA (do połączeń HTTPS).

Wynik: 7.79 MB. Redukcja o 99.4%.


2. Distroless — kompromis między scratch a pełnym OS

scratch jest świetny, ale ma jeden problem: brak shellu oznacza, że nie możesz zrobić docker exec -it <container> /bin/sh do debugowania. Nie ma też wbudowanego wsparcia dla healthchecka przez wget/curl.

Google’s distroless to środkowa droga: minimalne środowisko uruchomieniowe bez menedżera pakietów, bez shellu, ale z podstawowymi plikami systemowymi.

FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main .

# Etap pośredni — pobieramy statyczny wget do healthchecka
FROM alpine:3.19 AS wget-src
RUN apk add --no-cache busybox-static

FROM gcr.io/distroless/static:nonroot
COPY --from=wget-src /bin/busybox.static /usr/bin/wget
COPY --from=builder /app/main .
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
  CMD ["/usr/bin/wget", "--spider", "-q", "http://localhost:8080/health"]
USER nonroot
EXPOSE 8080
CMD ["./main"]

Wynik: 15.9 MB — trochę więcej niż scratch, ale mamy healthcheck i lepszy baseline bezpieczeństwa.

Uwaga dla właścicieli Mac z ARM: Ścieżka do busybox.static różni się w zależności od architektury. Na arm64 to /bin/busybox.static, na x86_64 to /usr/sbin/busybox.static. Jeśli budujesz obraz na Macu i deployszesz na x86 serwerze, może Cię to zaskoczyć.


3. Java — z 1.43 GB do 324 MB

W Javie nie zrobimy scratch. JVM jest JVM. Ale możemy wycisnąć dużo przez wybór właściwego base image.

Krok 1: JDK tylko do budowania

FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
# Najpierw tylko pom.xml — cache zależności Maven
COPY pom.xml .
RUN mvn dependency:go-offline -q
# Potem źródła
COPY src ./src
RUN mvn package -DskipTests -q

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

eclipse-temurin:21-jre-alpine zamiast eclipse-temurin:21-jdk — JRE bez kompilatora i narzędzi deweloperskich, na Alpine Linux zamiast Debiana.

Wynik: 324 MB zamiast 1.43 GB. Redukcja o 77%.

Krok 2: Spring Boot Layer Tools — optymalizacja cache

Spring Boot od wersji 2.3 potrafi “rozłożyć” fat jar na 4 warstwy według częstotliwości zmian:

  1. dependencies — biblioteki zewnętrzne (rzadko się zmieniają)
  2. spring-boot-loader — loader Springa (prawie nigdy)
  3. snapshot-dependencies — snapshoty (okazjonalnie)
  4. application — nasz kod (zmienia się przy każdym commicie)
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q

FROM eclipse-temurin:21-jdk AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
# Warstwy od najrzadziej do najczęściej zmienianych
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
USER app
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Rozmiar jest identyczny (325 MB), ale rebuild po zmianie kodu trwa 2.9s zamiast 3.1s — bo Docker cache’uje 3 z 4 warstw i rebuild’uje tylko tę z naszym kodem.


4. Kolejność instrukcji — cache to Twój przyjaciel

Kolejność warstw Dockera

Docker buduje obrazy warstwa po warstwie. Jeśli warstwa się zmieni, wszystkie warstwy po niej są inwalidowane.

Źle (cache zawsze się psuje):

FROM node:20-alpine
WORKDIR /app
COPY . .          # ← kopiujemy ŹLE wszystko na raz
RUN npm install   # ← ta warstwa zmienia się przy każdej zmianie kodu

Dobrze (cache przeżywa zmiany w kodzie):

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./  # ← tylko manifesty
RUN npm ci                               # ← ta warstwa jest cache'owana dopóki manifesty się nie zmienią
COPY . .                                 # ← kod kopiujemy po zależnościach

Zasada: od najrzadziej zmienianych do najczęściej zmienianych.


5. .dockerignore — co nie wchodzi do obrazu

Bez .dockerignore COPY . . wrzuca do obrazu wszystko: node_modules, .git, pliki testowe, dokumentację, IDE config. To zarówno spowalnia build (kopiowanie przez sieć do Daemon), jak i powiększa obraz.

Przykładowy .dockerignore dla projektu Go:

# Artefakty budowania
/main
*.exe
*.test

# Wersjonowanie
.git/
.gitignore

# Zależności (powinny być odtworzone przez go mod)
vendor/

# Środowisko
.env
.env.*
*.env

# Dokumentacja i testy (nie potrzebne w runtime)
docs/
**/*_test.go
README.md

Dla projektu Maven/Spring:

.git/
target/
.mvn/wrapper/
*.md
docs/
**/*Test.java
**/*IT.java
.env

Podsumowanie — ile możesz zyskać?

Porównanie rozmiarów obrazów Dockera

Kluczowe wnioski:

  1. Multistage builds to jedna zmiana w Dockerfile, która daje redukcję o 77–99%.
  2. CGO_ENABLED=0 + ldflags to obowiązek dla każdego projektu Go idącego do produkcji.
  3. distroless to dobry kompromis — masz healthcheck i bezpieczeństwo bez pełnego OS.
  4. Kolejność warstw decyduje o tym, jak długo trwa rebuild w CI — sortuj od stałych do zmiennych.
  5. Spring Boot Layer Tools — jeśli piszesz w Javie, warto ustawić raz i zapomnieć.

Rozmiar obrazu nie jest tylko estetyczny — to bezpieczeństwo, szybkość deploymentu i koszt przechowywania. Każda z tych technik aplikuje się osobno, ale razem robią prawdziwą różnicę.


A Ty? Jakie masz obrazy w swoim projekcie i od czego zaczniesz? Chętnie poczytam w komentarzach.

Udostępnij: LinkedIn