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 execile okunabilirCache:
.m2klasö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 -BSenaryo: 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 → saniyelerSenaryo: 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-daemonflag'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 .jlink ile Custom JRE (İleri Seviye)
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
ClassNotFoundExceptionalırsınız. Spring Boot'un ihtiyaç duyduğu modülleri doğru tespit etmek önemlidir.jdepsaracı 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 MBRUN 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:latestBuild 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 package2. 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ın3. .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.xmlkopyala →dependency:go-offline→ sonrasrc/kopyala — build süresi dakikalar → saniyelerCOPY --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=cacheile.m2cache build'ler arasında paylaşılırTarget stage:
docker build --target testile sadece test aşamasını çalıştırabilirsiniz
AI Asistan
Sorularını yanıtlamaya hazır