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 MBLayered 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 -jaryerineorg.springframework.boot.loader.launch.JarLaunchersı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çinlaunch.JarLauncher, önceki versiyonlardaJarLauncher(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 MBLayered 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 rebaseile 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ünLayer İç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.MFCI/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=maxLayered JAR vs Alternatifler
| Kriter | Fat JAR | Layered JAR | Jib | Buildpack |
|---|---|---|---|---|
| Dockerfile | Gerekli | Gerekli | Gerekmez | Gerekmez |
| Docker daemon | Gerekli | Gerekli | Gerekmez | Gerekli |
| Layer optimizasyonu | ❌ Manuel | ✅ Yarı-otomatik | ✅ Otomatik | ✅ Otomatik |
| Build süresi (sonraki) | Yavaş | Hızlı | Çok hızlı | Orta |
| Özelleştirme | Yüksek | Yüksek | Orta | Düşük |
| Öğrenme eğrisi | Düşük | Düşük | Düşük | Düşü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şir3. 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.JarLauncherSB 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 -jardeğilJarLauncherile başlatınCustom layers: Şirket içi kütüphaneleri ayrı katmana koyarak cache'i daha da optimize edin
AI Asistan
Sorularını yanıtlamaya hazır