← Kursa Dön
📄 Text · 25 min

Multi-Stage Build

Giriş

Geleneksel Dockerfile yaklaşımında, uygulamayı önce lokal makinede derleyip (build), sonra JAR dosyasını Docker image'ına kopyalarsınız. Bu yaklaşım iki ciddi sorun yaratır: birincisi, CI/CD pipeline'ında tutarsızlıklara yol açabilir — geliştirici makinesi ile CI sunucusu farklı JDK versiyonlarına sahip olabilir. İkincisi, eğer derlemeyi Docker içinde yaparsanız, JDK, Maven, tüm kaynak kodu ve bağımlılık cache'i final image'da kalır — 800 MB'lık bir image oluşur.

Bunu bir mutfak analojisiyle düşünün: yemek hazırlarken onlarca malzeme, bıçak, tencere kullanırsınız. Ama müşteriye servis ederken sadece tabağı götürürsünüz — mutfağın karmaşasını değil. Multi-stage build tam olarak budur: derleme "mutfağı" ile çalışma "servis tabağı" ayrı aşamalarda tanımlanır.

Multi-stage build, hem derleme hem de çalıştırmayı tek bir Dockerfile'da tanımlar ve final image'ı minimum boyutta tutar.

Problem: Tek Aşamalı Build

# ❌ Tek aşamalı — JDK + Maven + kaynak kodu HEPSİ image'da kalır
FROM eclipse-temurin:21-jdk

WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

ENTRYPOINT ["java", "-jar", "target/myapp.jar"]

Bu image'ın içeriği:

Image boyutu: ~800 MB
├── eclipse-temurin:21-jdk    ~340 MB  (JDK — derleyici, javadoc...)
├── Maven wrapper + .m2 cache ~200 MB  (indirilen bağımlılıklar)
├── Kaynak kodu (src/)        ~10 MB   (Java dosyaları, testler)
├── target/ dizini            ~100 MB  (derleme çıktıları)
└── Uygulama JAR              ~50 MB   (gerçekte ihtiyacınız olan)

Sorunlar:

  • Boyut: 800 MB image, registry'de yer kaplar, pull/push yavaşlar

  • Güvenlik: JDK'daki araçlar (javac, jdb) saldırı yüzeyini genişletir

  • Kaynak kodu: Source code image'da — docker exec ile okunabilir

  • Cache: .m2 klasörü image'da — gereksiz yer kaplar

Multi-Stage Build Çözümü

# ===== Stage 1: Builder =====
FROM eclipse-temurin:21-jdk AS builder

WORKDIR /app

# Önce sadece dependency dosyalarını kopyala — cache optimizasyonu
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw

# Bağımlılıkları indir (bu layer cache'lenir)
RUN ./mvnw dependency:go-offline -B

# Sonra kaynak kodu kopyala ve derle
COPY src src
RUN ./mvnw package -DskipTests -B

# ===== Stage 2: Runtime =====
FROM eclipse-temurin:21-jre-alpine

# Non-root user
RUN addgroup -S spring && adduser -S spring -G spring

WORKDIR /app

# Builder aşamasından SADECE JAR'ı kopyala
COPY --from=builder /app/target/*.jar app.jar

RUN chown spring:spring app.jar
USER spring:spring

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
    CMD wget -qO- http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:+UseG1GC", \
    "-jar", "app.jar"]

Ne Oldu?

Stage 1 (Builder):
├── JDK (~340 MB)           → ATILDI ✗
├── Maven + .m2 (~200 MB)   → ATILDI ✗
├── Kaynak kodu (~10 MB)    → ATILDI ✗
├── target/ dizini          → ATILDI ✗
└── myapp.jar (~50 MB)      → Stage 2'ye KOPYALANDI ✓

Stage 2 (Runtime):
├── JRE-Alpine (~120 MB)
├── myapp.jar (~50 MB)
└── Spring user
= ~170 MB (800 MB yerine!)

Builder aşamasındaki JDK, Maven, .m2 cache, kaynak kodu — hiçbiri final image'a dahil olmaz. COPY --from=builder sadece belirtilen dosyayı kopyalar, builder aşamasının tamamını değil.

Dependency Caching Stratejisi

Multi-stage build'in en önemli optimizasyonu bağımlılık cache'lemesidir. Docker, her layer'ı cache'ler. Bir layer'daki dosyalar değişmediyse, o layer ve sonraki layer'lar cache'ten gelir.

# ❌ YANLIŞ — Her kod değişikliğinde tüm bağımlılıklar yeniden indirilir
COPY . .
RUN ./mvnw package
# "." içinde src/ da var → src/ değiştiğinde COPY layer invalide olur
# → mvnw package yeniden çalışır → bağımlılıklar yeniden indirilir
# ✅ DOĞRU — Bağımlılıklar ayrı layer'da cache'lenir

# Layer 1: pom.xml kopyala (nadiren değişir)
COPY pom.xml .

# Layer 2: Bağımlılıkları indir (pom.xml değişmediyse cache'ten)
RUN ./mvnw dependency:go-offline -B

# Layer 3: Kaynak kodu kopyala (sık değişir)
COPY src src

# Layer 4: Derle (sadece kaynak kodu değiştiyse)
RUN ./mvnw package -DskipTests -B
Senaryo: Sadece Java kodu değişti (pom.xml aynı)

Layer 1: COPY pom.xml     → CACHED ✓ (pom.xml değişmedi)
Layer 2: dependency:go-offline → CACHED ✓ (bağımlılıklar cache'te)
Layer 3: COPY src          → REBUILT (kaynak kodu değişti)
Layer 4: mvnw package      → REBUILT (derle)

Toplam: Bağımlılık indirme atlandı → dakikalar → saniyeler
Senaryo: pom.xml'e yeni bağımlılık eklendi

Layer 1: COPY pom.xml     → REBUILT (pom.xml değişti)
Layer 2: dependency:go-offline → REBUILT (bağımlılıklar yeniden)
Layer 3: COPY src          → REBUILT
Layer 4: mvnw package      → REBUILT

Toplam: Her şey yeniden → bu beklenen davranış

Gradle ile Multi-Stage Build

# ===== Stage 1: Builder =====
FROM eclipse-temurin:21-jdk AS builder

WORKDIR /app

# Gradle wrapper ve yapılandırma
COPY build.gradle settings.gradle gradlew ./
COPY gradle gradle
RUN chmod +x gradlew

# Bağımlılıkları cache'le
RUN ./gradlew dependencies --no-daemon

# Kaynak kodu kopyala ve derle
COPY src src
RUN ./gradlew bootJar --no-daemon -x test

# ===== Stage 2: Runtime =====
FROM eclipse-temurin:21-jre-alpine

RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=builder /app/build/libs/*.jar app.jar
RUN chown spring:spring app.jar
USER spring:spring

EXPOSE 8080

ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-jar", "app.jar"]

💡 İpucu: Gradle'da --no-daemon flag'i önemlidir. Docker build sırasında Gradle daemon oluşturmak gereksizdir ve cache'i bozabilir.

Multi-Module Maven Projesi

Birden fazla Maven modülü olan projelerde:

FROM eclipse-temurin:21-jdk AS builder

WORKDIR /app

# Ana pom.xml ve tüm modül pom.xml'lerini kopyala
COPY pom.xml .
COPY common/pom.xml common/
COPY api/pom.xml api/
COPY service/pom.xml service/
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw

# Tüm bağımlılıkları indir
RUN ./mvnw dependency:go-offline -B -pl service -am

# Kaynak kodları kopyala
COPY common/src common/src
COPY api/src api/src
COPY service/src service/src

# Hedef modülü ve bağımlılıklarını derle
RUN ./mvnw package -DskipTests -B -pl service -am

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

İleri Seviye: Test Stage

Build aşamasında testleri de çalıştırmak istiyorsanız:

# Stage 1: Dependencies
FROM eclipse-temurin:21-jdk AS deps
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B

# Stage 2: Test
FROM deps AS test
COPY src src
RUN ./mvnw test -B
# Test başarısız olursa build burada durur

# Stage 3: Build (test başarılıysa)
FROM deps AS build
COPY src src
RUN ./mvnw package -DskipTests -B

# Stage 4: Runtime
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
RUN chown spring:spring app.jar
USER spring:spring
ENTRYPOINT ["java", "-jar", "app.jar"]
# Sadece test stage'ini çalıştır
docker build --target test -t myapp-test .

# Tam build (test + package + runtime)
docker build -t myapp:1.0.0 .

Uygulamanızın kullandığı Java modüllerini tespit edip, sadece bunları içeren minimal bir JRE oluşturabilirsiniz:

# Stage 1: Build
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests -B

# Stage 2: Custom JRE oluştur
FROM eclipse-temurin:21-jdk AS jre-builder

# Uygulamanın kullandığı modülleri tespit et
RUN jlink \
    --add-modules java.base,java.logging,java.sql,java.naming,\
java.management,java.instrument,java.desktop,java.security.jgss,\
jdk.unsupported,java.net.http \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=zip-6 \
    --output /custom-jre

# Stage 3: Minimal runtime
FROM alpine:3.19

# Custom JRE'yi kopyala
COPY --from=jre-builder /custom-jre /opt/java
ENV PATH="/opt/java/bin:$PATH"

RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=builder /app/target/*.jar app.jar
RUN chown spring:spring app.jar
USER spring:spring

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Boyut karşılaştırması:
eclipse-temurin:21-jre-alpine  → ~120 MB JRE
Custom JRE (jlink)             → ~50 MB JRE
Final image                    → ~100 MB (vs ~170 MB)

⚠️ Dikkat: jlink ile oluşturulan JRE, belirtilen modülleri içerir. Eksik modül varsa runtime'da ClassNotFoundException alırsınız. Spring Boot'un ihtiyaç duyduğu modülleri doğru tespit etmek önemlidir. jdeps aracı ile bağımlılıkları analiz edebilirsiniz.

Image Boyutu Optimizasyonu Karşılaştırması

Yaklaşım                              | Image Boyutu
---------------------------------------|-------------
Tek aşama + JDK                       | ~800 MB
Multi-stage + JRE (Debian)             | ~270 MB
Multi-stage + JRE-Alpine               | ~170 MB
Multi-stage + jlink + Alpine           | ~100 MB
Multi-stage + Distroless               | ~160 MB

RUN Komutlarını Birleştirme

Her RUN yeni bir layer oluşturur. Layer'lar birikir ve image boyutunu artırır:

# ❌ YANLIŞ — 3 layer
RUN apt-get update
RUN apt-get install -y curl wget
RUN rm -rf /var/lib/apt/lists/*
# Silme işlemi önceki layer'daki boyutu azaltmaz!

# ✅ DOĞRU — 1 layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl wget && \
    rm -rf /var/lib/apt/lists/*

BuildKit ve Cache Mount

Docker BuildKit ile bağımlılık cache'ini daha verimli yönetebilirsiniz:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk AS builder

WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw

# BuildKit cache mount — .m2 cache build'ler arasında paylaşılır
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw dependency:go-offline -B

COPY src src
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests -B

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
# BuildKit ile build (cache mount aktif)
DOCKER_BUILDKIT=1 docker build -t myapp:1.0.0 .

--mount=type=cache ile .m2 klasörü build'ler arasında paylaşılır — pom.xml değişse bile mevcut bağımlılıklar cache'te kalır, sadece yeni eklenenler indirilir.

CI/CD Pipeline Entegrasyonu

Multi-stage build, CI/CD pipeline'ınızı basitleştirir. Pipeline'da ayrı bir "Maven build" adımına ihtiyacınız kalmaz — Docker build her şeyi yapar:

# GitHub Actions — multi-stage build ile CI/CD
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build \
            --build-arg APP_VERSION=${{ github.sha }} \
            -t ghcr.io/myorg/myapp:${{ github.sha }} \
            -t ghcr.io/myorg/myapp:latest .

      - name: Run tests in container
        run: |
          docker build --target test -t myapp-test .

      - name: Security scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/myorg/myapp:${{ github.sha }}
          severity: 'CRITICAL,HIGH'

      - name: Push to registry
        run: |
          echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/myorg/myapp:${{ github.sha }}
          docker push ghcr.io/myorg/myapp:latest

Build Arguments ile Versiyon Bilgisi

FROM eclipse-temurin:21-jdk AS builder

ARG APP_VERSION=dev
ARG BUILD_TIME

WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests -B \
    -Dapp.version=${APP_VERSION}

FROM eclipse-temurin:21-jre-alpine

LABEL org.opencontainers.image.version="${APP_VERSION}"
LABEL org.opencontainers.image.created="${BUILD_TIME}"

WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar

ENTRYPOINT ["java", "-jar", "app.jar"]
docker build \
    --build-arg APP_VERSION=1.2.3 \
    --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
    -t myapp:1.2.3 .

Gerçek Dünya Senaryosu: Tam Production Multi-Stage

Bir e-ticaret mikroservisi için tam production Dockerfile:

# syntax=docker/dockerfile:1

# ===================================================
# Stage 1: Dependencies — Bağımlılıkları cache'le
# ===================================================
FROM eclipse-temurin:21-jdk-alpine AS deps

WORKDIR /app

# Maven wrapper ve konfigürasyon
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN chmod +x mvnw

# Bağımlılıkları indir (pom.xml değişmedikçe cache'ten gelir)
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw dependency:go-offline -B

# ===================================================
# Stage 2: Build — Kaynak kodu derle
# ===================================================
FROM deps AS build

# Kaynak kodu kopyala
COPY src/ src/

# Derle
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests -B \
    && mv target/*.jar target/app.jar

# ===================================================
# Stage 3: Test (opsiyonel — CI'da çalıştırılabilir)
# ===================================================
FROM deps AS test

COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 \
    ./mvnw verify -B

# ===================================================
# Stage 4: Runtime — Minimal production image
# ===================================================
FROM eclipse-temurin:21-jre-alpine AS runtime

# Güvenlik: non-root kullanıcı
RUN addgroup -S appgroup && \
    adduser -S appuser -G appgroup && \
    apk add --no-cache wget

WORKDIR /app

# Sadece JAR'ı kopyala
COPY --from=build --chown=appuser:appgroup \
    /app/target/app.jar ./app.jar

# Non-root kullanıcıya geç
USER appuser:appgroup

# Port
EXPOSE 8080

# Health check
HEALTHCHECK --interval=30s --timeout=5s \
    --start-period=60s --retries=3 \
    CMD wget -qO- http://localhost:8080/actuator/health || exit 1

# JVM ayarları
ENV JAVA_OPTS="-XX:+UseContainerSupport \
    -XX:MaxRAMPercentage=75.0 \
    -XX:+UseG1GC \
    -XX:MaxGCPauseMillis=200 \
    -XX:+HeapDumpOnOutOfMemoryError \
    -XX:HeapDumpPath=/tmp/heapdump.hprof \
    -XX:+ExitOnOutOfMemoryError"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
# Tam build (deps + build + runtime)
docker build -t myapp:1.0.0 .

# Sadece test stage
docker build --target test -t myapp-test .

# Build aşamasına kadar (debug)
docker build --target build -t myapp-build .
docker run --rm myapp-build ls -la /app/target/

Spring Boot Native Image ile Multi-Stage

GraalVM native image ile başlama süresi milisaniye düzeyine iner:

# Stage 1: Native compile (uzun sürer, ~5-10 dakika)
FROM ghcr.io/graalvm/native-image:21 AS builder

WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests

# Stage 2: Minimal runtime (JRE bile gerekmez!)
FROM alpine:3.19

RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=builder /app/target/myapp ./myapp
RUN chown spring:spring myapp
USER spring:spring

EXPOSE 8080
ENTRYPOINT ["./myapp"]
Native image avantajları:
- Başlama süresi: ~50ms (JVM: ~3 saniye)
- Bellek kullanımı: ~50 MB (JVM: ~200 MB)
- Image boyutu: ~80 MB (JRE yok)

Dezavantajları:
- Build süresi: 5-10 dakika
- Reflection/proxy sınırlamaları
- Runtime optimizasyonu yok (JIT)

Yaygın Hatalar

1. COPY . . İle Tüm Dosyaları Kopyalamak

# ❌ YANLIŞ — dependency cache'leme çalışmaz
COPY . .
RUN ./mvnw package
# src/ değiştiğinde COPY invalide olur → tüm bağımlılıklar yeniden

# ✅ DOĞRU — önce pom.xml, sonra src/
COPY pom.xml .
RUN ./mvnw dependency:go-offline
COPY src src
RUN ./mvnw package

2. Test Çalıştırmadan Build

# CI/CD'de testleri atlamamalısınız
# docker build sırasında test çalıştırın veya
# ayrı bir test stage kullanın

3. .dockerignore Eksikliği

# .dockerignore olmadan COPY . . çalıştırırsanız:
# .git/ → 100+ MB
# node_modules/ → 500+ MB
# target/ → zaten build edeceksiniz
# .idea/ → IDE dosyaları
# → Build context GB'lara çıkabilir

Özet

  • Multi-stage build: Builder (JDK + Maven) ve Runtime (JRE) aşamalarını ayırın — image boyutu 800 MB → 170 MB

  • Dependency caching: Önce pom.xml kopyala → dependency:go-offline → sonra src/ kopyala — build süresi dakikalar → saniyeler

  • COPY --from=builder: Sadece ihtiyacınız olan dosyayı (JAR) final image'a kopyalayın

  • jlink custom JRE: İleri optimizasyon — sadece kullanılan Java modülleri, image boyutu ~100 MB

  • BuildKit cache mount: --mount=type=cache ile .m2 cache build'ler arasında paylaşılır

  • Target stage: docker build --target test ile sadece test aşamasını çalıştırabilirsiniz