← Kursa Dön
📄 Text · 25 min

Docker Layered JAR

Giriş

Spring Boot 2.3+ ile gelen layered JAR özelliği, Docker image build süresini ve image boyutunu dramatik şekilde optimize eder. Geleneksel yaklaşımda tüm JAR tek bir Docker katmanı olarak kopyalanır — kodunuzda tek satır değişse bile tüm bağımlılıklar (50-70 MB) yeniden oluşturulur ve push/pull edilir.

Bunu bir bavul analojisiyle düşünün: her seyahatte çamaşırlarınızı değiştirirsiniz ama ayakkabılarınız, şarj aletiniz ve tuvalet çantanız hep aynıdır. Geleneksel yaklaşımda her seyahatte tüm bavulu yeniden paketliyorsunuz. Layered JAR yaklaşımında ise sadece değişen kısmı (çamaşırlar) değiştiriyorsunuz — geri kalan zaten hazır.

Bu derste fat JAR'ın neden verimsiz olduğunu, Spring Boot'un layered JAR yapısını, layer'ları ayırarak Docker cache'lemesinden maksimum fayda sağlamayı ve custom layer yapılandırmasını öğreneceğiz.

Problem: Fat JAR = Tek Katman

Standart Spring Boot JAR dosyası (fat/uber JAR), tüm bağımlılıkları, uygulama sınıflarını ve kaynakları tek bir dosyada barındırır:

myapp.jar (75 MB)
├── BOOT-INF/
│   ├── classes/        (2 MB — sizin kodunuz)
│   │   ├── com/example/MyApp.class
│   │   ├── com/example/controller/
│   │   ├── com/example/service/
│   │   └── application.yml
│   ├── lib/            (70 MB — tüm bağımlılıklar)
│   │   ├── spring-boot-3.3.0.jar
│   │   ├── spring-web-6.1.0.jar
│   │   ├── hibernate-core-6.5.0.jar
│   │   ├── postgresql-42.7.0.jar
│   │   └── ... (100+ jar dosyası)
│   └── classpath.idx
├── META-INF/
│   ├── MANIFEST.MF
│   └── maven/
└── org/springframework/boot/loader/

Fat JAR ile Dockerfile

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/myapp.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Bu COPY komutu 75 MB'lık tek bir Docker layer oluşturur. Sorunu anlamak için Docker'ın layer cache mekanizmasını bilmek gerekir:

İlk build:
  Layer 1: FROM eclipse-temurin:21-jre-alpine  → PULLED (120 MB)
  Layer 2: COPY target/myapp.jar app.jar       → CREATED (75 MB)
  → Image: 195 MB, push: 195 MB

İkinci build (kodda 1 satır değişiklik):
  Layer 1: FROM eclipse-temurin:21-jre-alpine  → CACHED ✓
  Layer 2: COPY target/myapp.jar app.jar       → REBUILT! (75 MB)
  → Push: 75 MB (ama sadece 2 MB değişmişti!)

Bağımlılıklar (70 MB) hiç değişmemiş olsa bile, JAR tek bir dosya olduğu için Docker tüm layer'ı yeniden oluşturur. CI/CD pipeline'ında her commit'te 75 MB push/pull — bu çok verimsizdir.

Layered JAR Yapısı

Spring Boot, JAR içeriğini dört mantıksal katmana ayırır:

┌─────────────────────────────────────────────┐
│  Layer 4: application                       │ ← Sizin kodunuz
│  (classes/, application.yml)                │   EN SIK değişir
│  ~2 MB                                      │
├─────────────────────────────────────────────┤
│  Layer 3: snapshot-dependencies             │ ← SNAPSHOT bağımlılıklar
│  (com.myorg:common:1.0-SNAPSHOT)            │   Ara sıra değişir
│  ~5 MB                                      │
├─────────────────────────────────────────────┤
│  Layer 2: spring-boot-loader                │ ← Spring Boot yükleyici
│  (org.springframework.boot.loader)          │   NADİREN değişir
│  ~0.5 MB                                    │
├─────────────────────────────────────────────┤
│  Layer 1: dependencies                      │ ← Release bağımlılıklar
│  (spring-boot, hibernate, postgresql...)    │   ÇOK NADİR değişir
│  ~70 MB                                     │
└─────────────────────────────────────────────┘

Alt katmanlar en az değişen, üst katmanlar en çok değişen içeriği barındırır. Docker layer cache'lemesi sayesinde, sadece kodunuz değiştiğinde yalnızca application katmanı yeniden oluşturulur — diğer katmanlar cache'ten gelir.

Layered JAR Oluşturma

Spring Boot 2.3+ için layered JAR varsayılan olarak aktiftir. Doğrulamak veya açıkça belirtmek için:

Maven

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <layers>
            <enabled>true</enabled>
        </layers>
    </configuration>
</plugin>

Gradle

tasks.named('bootJar') {
    layered {
        enabled = true
    }
}

Layer'ları İnceleme

# JAR oluştur
./mvnw package -DskipTests

# Layer'ları listele
java -Djarmode=layertools -jar target/myapp.jar list

# Çıktı:
# dependencies
# spring-boot-loader
# snapshot-dependencies
# application

# Layer'ları çıkart (extract)
java -Djarmode=layertools -jar target/myapp.jar extract

# 4 klasör oluşur:
# dependencies/
# spring-boot-loader/
# snapshot-dependencies/
# application/
# Her layer'ın boyutunu kontrol
du -sh dependencies/          # ~70 MB
du -sh spring-boot-loader/    # ~0.5 MB
du -sh snapshot-dependencies/ # ~5 MB (varsa)
du -sh application/           # ~2 MB

Layered Dockerfile

# ===== Stage 1: JAR'ı katmanlara ayır =====
FROM eclipse-temurin:21-jre-alpine AS extractor

WORKDIR /app
COPY target/*.jar app.jar

# JAR'ı layer'lara çıkart
RUN java -Djarmode=layertools -jar app.jar extract

# ===== Stage 2: Katmanları sırayla kopyala =====
FROM eclipse-temurin:21-jre-alpine

# Non-root kullanıcı
RUN addgroup -S spring && adduser -S spring -G spring

WORKDIR /app

# ===== Layer'ları SIRASINA DİKKAT ederek kopyala =====
# En az değişen katmanlar ÖNCE → Docker cache optimizasyonu

# Layer 1: Dependencies (~70 MB) — çok nadiren değişir
COPY --from=extractor /app/dependencies/ ./

# Layer 2: Spring Boot Loader (~0.5 MB) — nadiren değişir
COPY --from=extractor /app/spring-boot-loader/ ./

# Layer 3: Snapshot Dependencies (~5 MB) — ara sıra değişir
COPY --from=extractor /app/snapshot-dependencies/ ./

# Layer 4: Application (~2 MB) — sık değişir
COPY --from=extractor /app/application/ ./

# Sahiplik ve kullanıcı
RUN chown -R spring:spring /app
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

# JarLauncher ile başlat (java -jar DEĞİL!)
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:+UseG1GC", \
    "org.springframework.boot.loader.launch.JarLauncher"]

⚠️ Dikkat: Layered JAR çıkartıldığında java -jar yerine org.springframework.boot.loader.launch.JarLauncher sınıfıyla başlatılır. Bu, Spring Boot'un exploded layer yapısını doğru şekilde yüklemesini sağlar. Spring Boot 3.2+ için launch.JarLauncher, önceki versiyonlarda JarLauncher (launch paketi olmadan).

Cache Etkisi — Sayılarla

Fat JAR (Geleneksel)

İlk build:
  Layer 1: FROM jre-alpine     → 120 MB (pull)
  Layer 2: COPY app.jar        →  75 MB (create)
  Total push:                    195 MB

Her sonraki build (kod değişikliği):
  Layer 1: FROM jre-alpine     → CACHED
  Layer 2: COPY app.jar        →  75 MB (REBUILT)
  Total push:                     75 MB

Layered JAR

İlk build:
  Layer 1: FROM jre-alpine     → 120 MB (pull)
  Layer 2: dependencies        →  70 MB (create)
  Layer 3: spring-boot-loader  →  0.5 MB (create)
  Layer 4: snapshot-deps       →   5 MB (create)
  Layer 5: application         →   2 MB (create)
  Total push:                   197.5 MB

Her sonraki build (SADECE kod değişikliği):
  Layer 1: FROM jre-alpine     → CACHED ✓
  Layer 2: dependencies        → CACHED ✓ (70 MB atlandı!)
  Layer 3: spring-boot-loader  → CACHED ✓
  Layer 4: snapshot-deps       → CACHED ✓
  Layer 5: application         →   2 MB (REBUILT)
  Total push:                      2 MB (75 MB yerine!)

Sonuç: Her commit'te 75 MB yerine 2 MB push/pull — %97 tasarruf. CI/CD pipeline'larında bu fark büyük önem taşır.

Bağımlılık Değişikliği Senaryosu

pom.xml'e yeni bağımlılık eklendi:

Layer 1: FROM jre-alpine     → CACHED ✓
Layer 2: dependencies        →  72 MB (REBUILT — pom.xml değişti)
Layer 3: spring-boot-loader  →  0.5 MB (REBUILT — üstündeki değişti)
Layer 4: snapshot-deps       →   5 MB (REBUILT)
Layer 5: application         →   2 MB (REBUILT)
Total push:                    79.5 MB

Bu beklenen davranıştır — bağımlılıklar değiştiğinde tüm üst
layer'lar yeniden oluşturulur. Ama bu nadiren olur.

Custom Layer Yapılandırması

Varsayılan katmanlar yetmezse özelleştirebilirsiniz. Örneğin, şirket içi kütüphaneleri ayrı bir katmana koymak isteyebilirsiniz:

<!-- src/main/resources/layers.xml -->
<layers xmlns="http://www.springframework.org/schema/boot/layers"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
        https://www.springframework.org/schema/boot/layers/layers-4.0.xsd">

    <application>
        <into layer="spring-boot-loader">
            <include>org/springframework/boot/loader/**</include>
        </into>
        <into layer="application" />
    </application>

    <dependencies>
        <!-- Şirket içi kütüphaneler — ayrı katman -->
        <into layer="company-dependencies">
            <include>com.mycompany:*</include>
            <include>com.mycompany.*:*</include>
        </into>
        <!-- SNAPSHOT bağımlılıklar -->
        <into layer="snapshot-dependencies">
            <include>*:*:*SNAPSHOT</include>
        </into>
        <!-- Diğer tüm bağımlılıklar -->
        <into layer="dependencies" />
    </dependencies>
</layers>

Bu durumda Dockerfile'da 5 layer olur:

COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/company-dependencies/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./

Neden custom layer? Şirket içi kütüphaneler, üçüncü parti bağımlılıklara göre daha sık değişir. Onları ayrı katmana koyarak, şirket kütüphanesi güncellendiğinde sadece o katman yeniden oluşturulur — 70 MB'lık üçüncü parti bağımlılıklar cache'ten gelir.

Spring Boot Maven Plugin ile Doğrudan Image Build

Spring Boot 2.3+ ile spring-boot:build-image goal'ü, Dockerfile olmadan Cloud Native Buildpack kullanarak image oluşturur:

# Dockerfile OLMADAN image oluştur
./mvnw spring-boot:build-image \
    -Dspring-boot.build-image.imageName=myapp:1.0.0
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>registry.example.com/myapp:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
                <BPE_DELIM_JAVA_TOOL_OPTIONS> </BPE_DELIM_JAVA_TOOL_OPTIONS>
                <BPE_APPEND_JAVA_TOOL_OPTIONS>
                    -XX:MaxRAMPercentage=75.0
                </BPE_APPEND_JAVA_TOOL_OPTIONS>
            </env>
        </image>
    </configuration>
</plugin>

Buildpack avantajları:

  • Dockerfile yazmak gerekmez

  • Security best practice'ler otomatik uygulanır (non-root, memory calculator)

  • Otomatik layer optimizasyonu

  • OS-level security patch'leri pack rebase ile anında uygulanır

Dezavantajları:

  • Özelleştirme imkanı sınırlı

  • Build süresi daha uzun

  • Troubleshooting zor

Registry'deki Etkisi

Layer optimizasyonunun gerçek etkisini container registry'de görürsünüz. Docker registry, her layer'ı ayrı bir blob olarak saklar ve layer'lar image'lar arasında paylaşılır:

Registry'de saklanan layer'lar:

Image v1.0.0:
├── base-image-layer    sha256:aaa... (120 MB) ← paylaşılır
├── dependencies-layer  sha256:bbb... (70 MB)  ← paylaşılır
├── loader-layer        sha256:ccc... (0.5 MB) ← paylaşılır
├── snapshot-layer      sha256:ddd... (5 MB)
└── application-layer   sha256:eee... (2 MB)

Image v1.0.1 (sadece kod değişti):
├── base-image-layer    sha256:aaa... → ZATEN VAR, paylaşılır ✓
├── dependencies-layer  sha256:bbb... → ZATEN VAR, paylaşılır ✓
├── loader-layer        sha256:ccc... → ZATEN VAR, paylaşılır ✓
├── snapshot-layer      sha256:ddd... → ZATEN VAR, paylaşılır ✓
└── application-layer   sha256:fff... → SADECE BU YENİ (2 MB)

Registry disk kullanımı:
  v1.0.0: 197.5 MB
  v1.0.1: +2 MB (toplam 199.5 MB)
  Fat JAR ile: +75 MB (toplam 272.5 MB)

100 versiyon deploy ettiğinizde fark çarpıcıdır:

  • Layered JAR: 197.5 + (99 × 2) = ~395 MB

  • Fat JAR: 195 + (99 × 75) = ~7.6 GB

Pull Süresi Karşılaştırması

Yeni bir sunucuda image pull (ilk kez):
  Layered JAR: Tüm layer'lar indirilir → ~197 MB
  Fat JAR: Tüm layer'lar indirilir → ~195 MB
  → İlk pull'da fark yok

Güncelleme (v1.0.1 → v1.0.2):
  Layered JAR: Sadece application layer → 2 MB (~1 saniye)
  Fat JAR: Tüm JAR layer'ı → 75 MB (~30 saniye)
  → Güncelleme 37x DAHA HIZLI

Rolling update (3 pod × 100 deploy/gün):
  Layered JAR: 3 × 100 × 2 MB = 600 MB/gün
  Fat JAR: 3 × 100 × 75 MB = 22.5 GB/gün

Layer İçeriğini Doğrulama

Her layer'da hangi dosyaların olduğunu kontrol edebilirsiniz:

# JAR'ı extract et
java -Djarmode=layertools -jar target/myapp.jar extract

# dependencies layer'ında ne var?
ls dependencies/BOOT-INF/lib/ | head -20
# hibernate-core-6.5.0.jar
# jackson-databind-2.17.0.jar
# postgresql-42.7.0.jar
# spring-boot-3.3.0.jar
# spring-web-6.1.0.jar
# ...

# application layer'ında ne var?
find application/ -type f
# application/BOOT-INF/classes/com/example/MyApp.class
# application/BOOT-INF/classes/com/example/controller/OrderController.class
# application/BOOT-INF/classes/com/example/service/OrderService.class
# application/BOOT-INF/classes/application.yml
# application/META-INF/MANIFEST.MF

CI/CD Pipeline'da Layered JAR

# GitHub Actions — layered JAR ile optimized build
name: Build and Push

on:
  push:
    branches: [main]

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

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Build JAR
        run: ./mvnw package -DskipTests -B

      - name: Verify layered JAR
        run: |
          java -Djarmode=layertools -jar target/*.jar list
          # dependencies, spring-boot-loader,
          # snapshot-dependencies, application

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/myorg/myapp:${{ github.sha }}
            ghcr.io/myorg/myapp:latest
          cache-from: type=registry,ref=ghcr.io/myorg/myapp:cache
          cache-to: type=registry,ref=ghcr.io/myorg/myapp:cache,mode=max

Layered JAR vs Alternatifler

KriterFat JARLayered JARJibBuildpack
DockerfileGerekliGerekliGerekmezGerekmez
Docker daemonGerekliGerekliGerekmezGerekli
Layer optimizasyonu❌ Manuel✅ Yarı-otomatik✅ Otomatik✅ Otomatik
Build süresi (sonraki)YavaşHızlıÇok hızlıOrta
ÖzelleştirmeYüksekYüksekOrtaDüşük
Öğrenme eğrisiDüşükDüşükDüşükDüşük

Layered JAR ile Multi-Stage Build

En iyi sonuç için layered JAR'ı multi-stage build ile birleştirin:

# 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: Extract layers
FROM eclipse-temurin:21-jre-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 3: Runtime
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./

RUN chown -R spring:spring /app
USER spring:spring
EXPOSE 8080

ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "org.springframework.boot.loader.launch.JarLauncher"]

Bu yaklaşım hem kaynak kodu derlemeyi Docker içinde yapar (tutarlılık) hem de layered JAR ile cache optimizasyonu sağlar.

Yaygın Hatalar

1. java -jar Kullanmak

# ❌ YANLIŞ — layered JAR extract edildiyse java -jar çalışmaz
ENTRYPOINT ["java", "-jar", "app.jar"]

# ✅ DOĞRU — JarLauncher kullanın
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

2. Layer Sırasını Yanlış Yapmak

# ❌ YANLIŞ — sık değişen önce, nadiren değişen sonra
COPY --from=extractor /app/application/ ./
COPY --from=extractor /app/dependencies/ ./

# ✅ DOĞRU — nadiren değişen önce, sık değişen sonra
COPY --from=extractor /app/dependencies/ ./        # Çok nadiren
COPY --from=extractor /app/spring-boot-loader/ ./  # Nadiren
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./         # Sık değişir

3. Spring Boot Versiyonunu Kontrol Etmemek

Spring Boot 3.2+ ile JarLauncher class path'i değişti:

  • SB 3.2+: org.springframework.boot.loader.launch.JarLauncher

  • SB 2.x-3.1: org.springframework.boot.loader.JarLauncher

Özet

  • Fat JAR sorunu: 75 MB JAR tek layer — 1 satır değişiklikte 75 MB transfer

  • Layered JAR: JAR'ı 4 mantıksal katmana ayırır — dependencies (70 MB, nadiren değişir) → application (2 MB, sık değişir)

  • Cache etkisi: Kod değişikliğinde sadece 2 MB transfer (%97 tasarruf)

  • Dockerfile: Layer'ları sırayla COPY edin — en az değişen önce, en çok değişen sona

  • JarLauncher: Extract edilmiş layer yapısını java -jar değil JarLauncher ile başlatın

  • Custom layers: Şirket içi kütüphaneleri ayrı katmana koyarak cache'i daha da optimize edin