Docker’da İmaj Boyutunu Küçültme: Multi-Stage Build Tekniği

Production ortamında Docker imajlarıyla çalışırken er ya da geç şu soruyla yüzleşiyorsunuz: “Bu imaj neden bu kadar büyük?” Geliştirme sürecinde oluşturduğunuz bir Node.js uygulaması 1.2 GB, bir Go servisi 800 MB olabiliyor. Oysa uygulamanın kendisi belki birkaç MB. İşte bu noktada multi-stage build tekniği devreye giriyor ve imaj boyutlarını dramatik biçimde küçültüyor. Bu yazıda konuyu derinlemesine ele alacağız, gerçek dünya senaryolarıyla pratik örnekler vereceğiz.

Multi-Stage Build Nedir ve Neden Önemlidir?

Klasik Docker yaklaşımında tek bir Dockerfile içinde hem derleme ortamını hem de çalışma ortamını bir arada tutuyorduk. Derleyiciler, build araçları, test framework’leri, development dependency’ler… Hepsi son imaja giriyordu. Bu yaklaşım işe yarıyor ama şişman, yavaş ve güvenlik açısından riskli imajlar ortaya çıkarıyor.

Multi-stage build, Docker 17.05 ile hayatımıza girdi. Tek bir Dockerfile içinde birden fazla FROM satırı kullanarak farklı aşamalar tanımlayabiliyorsunuz. Her aşama kendi bağımsız ortamını oluşturuyor, bir sonraki aşama sadece ihtiyaç duyduğu dosyaları önceki aşamadan kopyalayabiliyor. Sonuç: production’a giden imaj sadece çalışması gereken şeyleri içeriyor.

Neden bu kadar önemli?

  • Güvenlik: Derleme araçları, kaynak kodlar ve geliştirme araçları son imaja girmiyor. Saldırı yüzeyi dramatik biçimde küçülüyor.
  • Hız: Daha küçük imajlar registry’den daha hızlı pull ediliyor. CI/CD pipeline’larınız hızlanıyor.
  • Depolama: Registry’de ve her host üzerinde daha az yer kaplıyor.
  • Network maliyeti: Cloud ortamlarında veri transferi maliyet demek. Küçük imaj, küçük fatura.

Temel Kavramları Anlayalım

Konuya girmeden önce birkaç temel kavramı netleştirelim.

Build aşaması (stage): Her FROM satırıyla yeni bir aşama başlar. Bu aşama geçici bir ortam oluşturur.

COPY –from: Bir aşamadan diğerine dosya kopyalamanın yolu. İşin kalbi bu direktif.

AS keyword: Aşamalara isim vermek için kullanılır. İsimsiz aşamalar da çalışır ama indeksle referans vermeniz gerekir (0, 1, 2…).

Basit bir örnek görelim:

# Aşama 1: Derleme ortamı
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .

# Aşama 2: Çalışma ortamı
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Burada golang:1.21 imajı yaklaşık 800 MB. Ama son imaj alpine:3.18 üzerine kuruluyor, sadece derlenmiş binary kopyalanıyor. Sonuç: 10-15 MB civarında bir imaj.

Go Uygulaması: En Dramatik Fark

Go, multi-stage build’in faydalarını en net gördüğünüz dildir. Statik olarak derlenmiş binary’ler sayesinde neredeyse sıfır dependency ile çalışabilirsiniz.

FROM golang:1.21-alpine AS builder

# Güvenlik için non-root kullanıcı bilgilerini önceden tanımlıyoruz
RUN adduser -D -g '' appuser

WORKDIR /build

# Önce dependency dosyalarını kopyala (cache optimizasyonu)
COPY go.mod go.sum ./
RUN go mod download

# Kaynak kodu kopyala
COPY . .

# Statik binary derle
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build 
    -ldflags="-w -s -extldflags '-static'" 
    -o /build/app ./cmd/main.go

# ---
# Final aşama: scratch veya distroless
FROM scratch

# Sertifikaları kopyala (HTTPS çağrıları için)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Kullanıcı bilgilerini kopyala
COPY --from=builder /etc/passwd /etc/passwd

# Binary'i kopyala
COPY --from=builder /build/app /app

USER appuser

ENTRYPOINT ["/app"]

Burada birkaç kritik nokta var:

  • CGO_ENABLED=0 ile CGO devre dışı bırakıyoruz, saf statik binary elde ediyoruz
  • -ldflags="-w -s" ile debug sembollerini ve symbol table’ı kaldırıyoruz, binary daha da küçülüyor
  • FROM scratch kullanıyoruz, içinde hiçbir şey yok, sadece bizim binary’imiz
  • CA sertifikalarını kopyalıyoruz çünkü scratch’te bunlar yok ama HTTPS için gerekli

Sonuç olarak bir Go web servisi için 800 MB yerine 8-12 MB imaj elde ediyorsunuz. Bu gerçek bir rakam, ben bunu production’da defalarca gördüm.

Node.js Uygulaması: Asıl Savaş Alanı

Node.js uygulamaları node_modules klasörü yüzünden en şişman imajları üretenler arasında. Bir orta ölçekli Next.js veya Express uygulaması kolayca 1.5 GB’ı aşabiliyor.

# Aşama 1: Dependency kurulumu
FROM node:20-alpine AS deps
WORKDIR /app

# Package dosyalarını kopyala
COPY package.json package-lock.json ./

# Sadece production dependency'lerini kur
RUN npm ci --only=production

# ---
# Aşama 2: Build
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

# TypeScript derleme veya Next.js build
RUN npm run build

# ---
# Aşama 3: Final production imajı
FROM node:20-alpine AS runner

# Güvenlik: non-root kullanıcı
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

WORKDIR /app

# Production dependency'leri kopyala
COPY --from=deps /app/node_modules ./node_modules

# Build çıktısını kopyala
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

# Non-root kullanıcıya geç
USER nextjs

EXPOSE 3000
CMD ["node", "dist/index.js"]

Bu yaklaşım development’a kıyasla genellikle 60-70% boyut küçültmesi sağlıyor. devDependencies final imaja girmiyor: TypeScript compiler, ESLint, Jest, Webpack… Bunların hiçbiri production’da işinize yaramıyor.

Java/Spring Boot: Layered JAR Yaklaşımı

Java dünyasında multi-stage build biraz daha incelikli. Spring Boot’un layered JAR özelliğini kullanarak Docker layer cache’ini zekice kullanabiliriz.

# Aşama 1: Maven build
FROM maven:3.9-eclipse-temurin-21 AS builder

WORKDIR /build

# Önce sadece pom.xml kopyala, dependency'leri indir (cache kazanımı)
COPY pom.xml .
RUN mvn dependency:go-offline -q

# Kaynak kodu kopyala ve derle
COPY src ./src
RUN mvn package -DskipTests -q

# JAR'ı layer'lara ayır
RUN java -Djarmode=layertools -jar target/*.jar extract

# ---
# Aşama 2: Minimal JRE ile production imajı
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# Güvenlik
RUN addgroup -S spring && adduser -S spring -G spring

# Spring Boot layer sıralaması önemli: en az değişenden en çok değişene
COPY --from=builder /build/dependencies/ ./
COPY --from=builder /build/spring-boot-loader/ ./
COPY --from=builder /build/snapshot-dependencies/ ./
COPY --from=builder /build/application/ ./

USER spring:spring

EXPOSE 8080

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Bu yaklaşımın güzelliği şu: Her build’de sadece değişen layer’lar yeniden oluşturuluyor. Kodunuzu değiştirdiniz ama dependency’ler aynı kaldı mı? Docker cache sayesinde sadece application layer yeniden build ediliyor. Bu CI/CD süreçlerinde ciddi zaman tasarrufu sağlıyor.

Full JDK (400+ MB) yerine sadece JRE (200 MB civarı) kullanıyoruz. Alpine base image ile de ekstra küçültme sağlıyoruz.

Python Uygulaması: Wheel Tabanlı Yaklaşım

Python’da native extension içeren paketler (NumPy, Pillow, psycopg2 gibi) derleme araçları gerektiriyor. Multi-stage bu sorunu da çözüyor.

# Aşama 1: Build ortamı (derleme araçları var)
FROM python:3.11-slim AS builder

# Build dependency'leri kur
RUN apt-get update && apt-get install -y 
    gcc 
    libpq-dev 
    && rm -rf /var/lib/apt/lists/*

WORKDIR /build

# Virtual environment oluştur
RUN python -m venv /build/venv
ENV PATH="/build/venv/bin:$PATH"

COPY requirements.txt .

# Wheel'leri derle ve kur
RUN pip install --upgrade pip && 
    pip install --no-cache-dir -r requirements.txt

# ---
# Aşama 2: Production
FROM python:3.11-slim AS runner

# Sadece runtime library'leri kur (derleme araçları yok)
RUN apt-get update && apt-get install -y 
    libpq5 
    && rm -rf /var/lib/apt/lists/*

# Non-root kullanıcı
RUN useradd --create-home --shell /bin/bash appuser

WORKDIR /app

# Virtual environment'ı kopyala
COPY --from=builder /build/venv /app/venv

# Uygulama kodunu kopyala
COPY --chown=appuser:appuser . .

USER appuser

ENV PATH="/app/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:application"]

Builder aşamasında gcc ve diğer derleme araçları var, psycopg2 gibi paketler bunlarla derleniyor. Final imajda ise sadece runtime library libpq5 var, derleme araçlarının hiçbiri yok. Boyut farkı: yaklaşık 300-400 MB.

Gelişmiş Teknikler: BuildKit ve Cache Mount

Docker BuildKit, multi-stage build’in üstüne ekstra optimizasyonlar getiriyor. Özellikle --mount=type=cache direktifi build sürelerini ciddi ölçüde kısaltıyor.

# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./

# Go module cache'ini kalıcı tut
RUN --mount=type=cache,target=/go/pkg/mod 
    go mod download

COPY . .

# Build cache'ini kalıcı tut
RUN --mount=type=cache,target=/root/.cache/go-build 
    CGO_ENABLED=0 go build -o /app/server ./cmd/server

# ---
FROM alpine:3.18

RUN apk --no-cache add ca-certificates tzdata

COPY --from=builder /app/server /server

ENTRYPOINT ["/server"]

BuildKit’i etkinleştirmek için:

# Environment variable ile
export DOCKER_BUILDKIT=1
docker build -t myapp .

# Veya doğrudan flag ile
docker buildx build -t myapp .

# Build süresi karşılaştırması için
time docker build --no-cache -t myapp .
time docker build -t myapp .  # Cache ile

--mount=type=cache kullandığınızda Go module cache’i build’ler arasında korunuyor. İkinci build’de dependency’leri tekrar indirmiyor. CI/CD ortamında bu özelliği kullandığınızda build sürelerinin yarıya düştüğünü görebilirsiniz.

Belirli Bir Aşamayı Hedefleme

Multi-stage build’in az bilinen ama son derece kullanışlı özelliği: --target flag. Bu sayede development, test ve production için tek bir Dockerfile tutabilirsiniz.

# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./

# ---
FROM base AS development
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# ---
FROM base AS test
RUN npm ci
COPY . .
CMD ["npm", "test"]

# ---
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build

# ---
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

Kullanımı:

# Development ortamı için
docker build --target development -t myapp:dev .

# Test çalıştırmak için
docker build --target test -t myapp:test .
docker run --rm myapp:test

# Production imajı için
docker build --target production -t myapp:prod .

# Boyut karşılaştırması
docker images myapp

Bu yaklaşımın güzelliği tek bir truth source. Tüm ortamlar için ayrı Dockerfile yönetmiyorsunuz, maintenance yükü azalıyor.

Boyut Analizi: Neyi Nereye Koyacaksınız?

Multi-stage build yaparken hangi dosyaların nereye gideceğini bilmek gerekiyor. Bunu belirlemenin pratik yolu docker history ve dive aracı:

# Imaj layer'larını incele
docker history myapp:latest

# Dive aracını kur ve kullan (interaktif analiz)
# https://github.com/wagoodman/dive
docker run --rm -it 
    -v /var/run/docker.sock:/var/run/docker.sock 
    wagoodman/dive:latest myapp:latest

# Imaj boyutunu kontrol et
docker image inspect myapp:latest --format='{{.Size}}' | 
    awk '{printf "%.2f MBn", $1/1024/1024}'

# Tüm imajları boyuta göre listele
docker images --format "table {{.Repository}}t{{.Tag}}t{{.Size}}" | sort -k3 -h

dive aracı gerçekten hayat kurtarıyor. Her layer’da tam olarak ne eklenip ne çıkarıldığını gösteriyor. “Bu 200 MB nereden geldi?” sorusunun cevabını saniyeler içinde buluyorsunuz.

Yaygın Hatalar ve Çözümleri

Hata 1: Her COPY/RUN için yeni layer açmak

# YANLIS: Her komut yeni layer olusturuyor
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*

# DOGRU: Tek komutta birlestir
RUN apt-get update && 
    apt-get install -y curl git && 
    rm -rf /var/lib/apt/lists/*

Hata 2: .dockerignore kullanmamak

# .dockerignore dosyası oluştur
cat > .dockerignore << 'EOF'
node_modules
.git
.gitignore
*.md
.env
.env.*
dist
build
coverage
.nyc_output
*.log
.DS_Store
EOF

Build context boyutu direkt build süresini etkiliyor. node_modules klasörünü build context’e dahil etmek hem yavaşlatıyor hem de yanlış dosyaların kopyalanmasına neden olabiliyor.

Hata 3: Sıralamanın önemi

# Cache'i akıllıca kullanmak için:
# Önce az değişen dosyalar, sonra çok değişenler

FROM node:20-alpine AS builder
WORKDIR /app

# package.json nadiren değişir -> önce kopyala
COPY package*.json ./
RUN npm ci

# Kaynak kod sık değişir -> sonra kopyala
COPY . .
RUN npm run build

Kaynak kodunuzu değiştirdiniz ama package.json aynı kaldıysa Docker, npm ci adımını cache’den kullanacak ve atlaycak. Bu küçük bir değişiklik görünebilir ama aktif geliştirme sürecinde build’ler dakikalarca kısalabiliyor.

CI/CD Entegrasyonu: GitHub Actions Örneği

Tüm bu güzel teknikler CI/CD’de nasıl çalışıyor? Pratik bir GitHub Actions örneği:

# .github/workflows/docker-build.yml
# Bu bir YAML dosyası ama bash kod bloğu olarak gösteriyoruz

# docker buildx create --use
# docker buildx build 
#   --platform linux/amd64,linux/arm64 
#   --cache-from type=registry,ref=myregistry/myapp:buildcache 
#   --cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max 
#   --target production 
#   --tag myregistry/myapp:latest 
#   --push .

# Build ve push scripti
#!/bin/bash
set -euo pipefail

IMAGE_NAME="myregistry/myapp"
GIT_SHA=$(git rev-parse --short HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD)

echo "Building image: ${IMAGE_NAME}:${GIT_SHA}"

docker buildx build 
  --platform linux/amd64,linux/arm64 
  --target production 
  --tag "${IMAGE_NAME}:${GIT_SHA}" 
  --tag "${IMAGE_NAME}:${BRANCH}" 
  --push 
  --progress=plain 
  .

echo "Image size:"
docker buildx imagetools inspect "${IMAGE_NAME}:${GIT_SHA}" 
  --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}: {{.Digest}}{{"n"}}{{end}}'

Multi-platform build yaparken --platform flag’i kullanıyoruz. Bu özellikle M1/M2 Mac’te geliştirip ARM tabanlı serverlara deploy ettiğinizde kritik.

Gerçek Dünyadan Sonuçlar

Bir e-ticaret projesinde uyguladığım optimizasyonların özetini vereyim:

Node.js API servisi:

  • Öncesi: 1.4 GB (node:18 base, tüm devDependencies)
  • Sonrası: 187 MB (node:18-alpine, sadece production deps)
  • Kazanım: %87 küçülme

Go microservice:

  • Öncesi: 812 MB (golang:1.21 base)
  • Sonrası: 11 MB (scratch + binary)
  • Kazanım: %99 küçülme

Python ML servisi:

  • Öncesi: 2.1 GB (python:3.11 full, build tools dahil)
  • Sonrası: 680 MB (python:3.11-slim, sadece runtime)
  • Kazanım: %68 küçülme

Bu rakamlar Kubernetes cluster’ında pod başlatma süresine, registry’deki depolama maliyetine ve network bant genişliğine direkt yansıyor. Özellikle auto-scaling yapılan bir ortamda küçük imajlar, yeni pod’ların çok daha hızlı ayağa kalkması anlamına geliyor.

Sonuç

Multi-stage build Docker’ın en güçlü özelliklerinden biri ve aslında öğrenmesi düşündüğünüzden çok daha kolay. Temel mantık basit: derleme için ihtiyaç duyduğunuz her şeyi builder aşamasında kullanın, production imajına sadece uygulamanın çalışması için gereken minimum dosyaları kopyalayın.

Başlangıç için önerdiğim yol: mevcut tek-aşamalı Dockerfile’larınızı ikiye bölün. Builder aşamasına derleme/build adımlarını, final aşamaya sadece çıktıları koyun. Bu bile %50’nin üzerinde boyut küçültmesi sağlayacak.

Daha ileri gitmek isteyenler için sıradaki adımlar: FROM scratch veya Google’ın distroless imajlarını inceleyin, BuildKit’in cache mount özelliğini CI/CD’ye entegre edin ve dive aracıyla imajlarınızı düzenli analiz edin.

Küçük imaj, hızlı deploy, küçük saldırı yüzeyi, mutlu sysadmin. Bu formül tutarlı biçimde çalışıyor.

Yorum yapın