Java (Spring Boot) #
Java + Spring Boot adalah stack paling umum di enterprise backend. Tapi kombinasi ini juga terkenal menghasilkan Docker image yang sangat besar — 300-500 MB adalah angka yang lumrah di production. Banyak tim yang menerima ukuran ini sebagai “kenyataan Java”, padahal image Spring Boot bisa 70-130 MB dengan strategi yang benar.
Artikel ini membahas secara mendetail dan realistis bagaimana membangun Docker image Java (Spring Boot) yang ramping, aman, dan production-grade. Kita akan lihat kenapa image Java mudah membengkak, strategi yang benar untuk mengatasinya (multi-stage + distroless), dan trade-off yang harus dipahami.
1. Realita Ukuran Image Spring Boot #
Mari kita lihat angka realistis di production, dari yang paling bermasalah hingga optimal.
| Setup | Ukuran Image |
|---|---|
openjdk:17 (JDK + Debian) |
300-450 MB |
eclipse-temurin:17-jre (JRE only) |
180-220 MB |
eclipse-temurin:17-jre-alpine |
110-150 MB |
| Multi-stage + distroless JRE | 70-130 MB |
Insight: Selisih antara 450 MB dan 70 MB adalah 6x lipat. Image Java yang optimal bukan mustahil — hanya butuh disiplin Dockerfile yang berbeda dari kebiasaan umum.
Cara audit cepat:
docker history myapp:latest
Lihat layer mana yang terbesar. Biasanya culprit-nya adalah JDK (bukan JRE) di runtime stage, atau dependency Spring Boot yang tidak di-audit.
2. Kenapa Image Java Mudah Membengkak? #
JDK vs JRE #
Image openjdk:* atau eclipse-temurin:* secara default membawa JDK (Java Development Kit) lengkap, termasuk javac, jdeps, jlink, dan tool development lainnya. Untuk runtime, kamu hanya butuh JRE (Java Runtime Environment) — subset dari JDK yang berisi JVM dan library standard.
JDK di runtime image = ~200 MB yang tidak terpakai. Ini pemborosan pertama yang harus di-eliminasi.
Spring Boot Dependency Bloat #
Spring Boot terkenal karena “batteries included” — dependency yang memudahkan development tapi membengkakkan image. Beberapa starter yang sering jadi biang kerok:
spring-boot-starter-data-jpa— menarik Hibernate + driver JDBC + connection pool.spring-boot-starter-security— Spring Security + OAuth + cryptography library.spring-boot-starter-web— Spring MVC + Tomcat embedded + Jackson.spring-boot-starter-actuator— endpoint monitoring.
Kombinasi starter ini bisa menambah 50-100 MB ke image. Belum termasuk transitive dependency yang ikut tertarik.
Fat JAR yang Tidak Layered #
Default Spring Boot build menghasilkan “fat JAR” yang berisi semua dependency dalam satu file. Masalahnya: perubahan pada satu baris kode akan menghasilkan fat JAR baru, dan Docker akan rebuild semua layer — tidak ada granularitas cache.
Solusi: Spring Boot 2.3+ punya fitur layered JAR yang memisahkan fat JAR menjadi beberapa layer (dependencies, spring-boot-loader, snapshot-dependencies, application). Ini memungkinkan Docker cache bekerja optimal.
Logging Library #
Spring Boot default pakai Logback dengan encoder yang kadang menambah dependency besar. Logging JSON (untuk production) biasanya pakai logstash-logback-encoder yang menambah ~5 MB.
Native Library #
Beberapa library Java punya native component:
- Netty native (
netty-tcnative). - Database driver native.
- Image processing library (ImageIO dengan native codec).
Native library menambah ukuran dan komplikasi di multi-stage build.
3. Prinsip Utama: JRE Saja, Distroless, Layered #
Runtime image Java idealnya hanya berisi: JRE + fat JAR Spring Boot + konfigurasi runtime.
Titik. Tidak ada JDK, tidak ada Maven/Gradle, tidak ada source code, tidak ada test JAR, tidak ada build cache.
Tiga pilar untuk mencapainya:
- Multi-stage build — pisahkan build (butuh JDK + Maven) dari runtime (butuh JRE saja).
- Distroless JRE — runtime image yang sangat ramping, tanpa shell atau package manager.
- Layered JAR — pisahkan fat JAR menjadi layer untuk optimasi cache.
4. Strategi Multi-Stage dengan Distroless #
4.1 Setup Project #
Untuk memaksimalkan layered JAR, tambahkan konfigurasi di pom.xml atau build.gradle:
Maven (pom.xml):
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>
Gradle (build.gradle):
bootJar {
layered {
enabled = true
}
}
Setelah build, Spring Boot akan menghasilkan app.jar yang bisa di-extract menjadi beberapa layer.
4.2 Dockerfile Production-Grade #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Extract Layered JAR ====
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
# Copy dan extract layered JAR
COPY --chmod=0755 mvnw /build/mvnw
COPY .mvn /build/.mvn
COPY pom.xml /build/pom.xml
# Cache dependency download
RUN ./mvnw dependency:go-offline -B
COPY src /build/src
RUN ./mvnw package -DskipTests -B
# Extract layered JAR
RUN mkdir -p /build/extracted
RUN java -Djarmode=layertools -jar /build/target/*.jar extract --destination /build/extracted
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
# Copy layer dalam urutan yang tepat (paling jarang berubah di bawah)
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./
USER nonroot:nonroot
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="\
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+ExitOnOutOfMemoryError \
-Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Penjelasan penting:
Stage 1: Build & Extract
eclipse-temurin:21-jdk-jammy— JDK 21 di Ubuntu Jammy. Image besar tapi berisi tool yang dibutuhkan untuk build.dependency:go-offline— download semua dependency di awal. Ini memaksimalkan cache: selamapom.xmltidak berubah, layer ini di-reuse.java -Djarmode=layertools -jar ... extract— extract fat JAR menjadi layer terpisah.
Stage 2: Runtime
gcr.io/distroless/java21-debian12:nonroot— image JRE distroless + usernonroot. Tanpa shell, tanpa package manager. Ukuran ~70 MB.- Layer copy order sangat penting:
dependencies(paling jarang berubah) di-copy pertamaspring-boot-loader(jarang berubah)snapshot-dependencies(jarang berubah)application(paling sering berubah) di-copy terakhir
- Java tool options — set container-aware JVM flags.
Ukuran tipikal: 70-130 MB (tergantung dependency Spring Boot).
4.3 Versi Lebih Simpel (Tanpa Layered JAR) #
Jika tidak mau repot dengan layered JAR, versi lebih simpel:
# ==== Stage 1: Build ====
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Ukuran: 90-150 MB — sedikit lebih besar dari versi layered karena satu layer application berisi semua.
5. JVM Tuning untuk Container #
Java 10+ punya container awareness yang memungkinkan JVM memahami limit memory container. Tanpa ini, JVM akan melihat total memory host dan alokasi default bisa terlalu besar atau terlalu kecil.
Flag Container-Aware #
# Aktifkan container support (Java 10+)
-XX:+UseContainerSupport
# Set heap sebagai persentase dari container memory limit
-XX:MaxRAMPercentage=75.0
# Initial heap juga pakai persentase
-XX:InitialRAMPercentage=50.0
MaxRAMPercentage=75.0 artinya JVM heap akan maksimal 75% dari container memory limit. Sisa 25% untuk off-heap (metaspace, thread stacks, native memory).
Pilih JVM Runtime yang Tepat #
| JVM | Ukuran Image | Performa | Catatan |
|---|---|---|---|
| HotSpot | Standar | Standar | Default di OpenJDK |
| OpenJ9 | Lebih kecil | Lebih cepat startup | Eclipse Adoptium alternative |
| GraalVM Native | Sangat kecil | Sangat cepat startup | Tapi perlu native compilation |
Untuk kebanyakan kasus, HotSpot sudah cukup. OpenJ9 menarik jika startup time penting. GraalVM native image menarik untuk serverless, tapi mengubah cara aplikasi Spring Boot ditulis (AOT compilation, reflection limits).
Pilih Base Image JRE yang Tepat #
# Eclipse Temurin (Adoptium) — paling umum
FROM eclipse-temurin:21-jre-jammy
# Distroless — paling ramping
FROM gcr.io/distroless/java21-debian12:nonroot
# Amazon Corretto — optimized untuk AWS
FROM amazoncorretto:21-alpine
# Alibaba Dragonwell — optimized untuk CPU arsitektur China
FROM dragonwell8:21-alpine
Untuk production, eclipse-temurin atau distroless adalah pilihan paling aman. amazoncorretto bagus jika deploy di AWS.
6. Dependency Audit #
Spring Boot menarik banyak transitive dependency. Audit rutin penting untuk menjaga image ramping.
Identifikasi Dependency Besar #
# Setelah build, cek ukuran tiap dependency
mvn dependency:tree | sort -k 4 -n -r | head -20
Atau dengan Gradle:
./gradlew dependencies --configuration runtimeClasspath
Strategi Audit #
Hapus starter yang tidak terpakai. Jangan import spring-boot-starter-data-jpa kalau tidak pakai database. Spring Boot modular — pakai hanya yang kamu butuhkan.
Audit transitive dependency. Kadang satu starter menarik banyak transitive dep yang tidak kamu pakai langsung. Cek dengan mvn dependency:tree.
Pilih alternative yang lebih ramping. Beberapa library Java terkenal boros:
Jackson— JSON serialization, tapi bisa diganti denganGsonatauMoshi(lebih kecil).Hibernate— ORM lengkap, tapijOOQatauJdbilebih ramping untuk query-heavy apps.Logback— default, tapilog4j2denganAsyncLoggerbisa lebih efisien.
Exclude Dependency #
Untuk dependency yang tidak terpakai tapi tertarik transitively:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
7. Production Logging #
Spring Boot logging default ke STDOUT via Logback. Untuk production, konfigurasi ini perlu diperkuat.
logback-spring.xml:
<configuration>
<appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<customFields>{"service":"myapp","env":"production"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT_JSON"/>
</root>
</configuration>
Prinsip penting:
- JSON structured log untuk production (bukan plain text).
- Include trace ID untuk distributed tracing.
- Log level via env var (
LOG_LEVEL). - Tidak ada log ke file — biarkan orchestrator yang mengumpulkan dari STDOUT.
8. Healthcheck #
Spring Boot Actuator menyediakan endpoint /actuator/health yang ideal untuk healthcheck container.
Aktifkan di application.yml:
management:
endpoint:
health:
show-details: when_authorized
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
endpoints:
web:
exposure:
include: health,info,metrics
Di Dockerfile (untuk image dengan shell, bukan distroless):
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1
Untuk distroless (tidak ada wget/curl):
- Pindahkan healthcheck ke orchestrator (Kubernetes liveness/readiness probe).
- Atau gunakan Spring Boot Actuator yang expose endpoint, dan biarkan orchestrator yang probe.
Spring Boot membedakan dua probe:
- Liveness (
/actuator/health/liveness) — apakah container perlu di-restart? - Readiness (
/actuator/health/readiness) — apakah container siap terima traffic?
Gunakan keduanya di Kubernetes agar rolling update dan scaling bekerja dengan benar.
9. Signal Handling #
JVM sudah handle SIGTERM dengan benar secara default — saat signal diterima, JVM akan shutdown dengan graceful. Tapi ada beberapa hal yang harus dipastikan:
- Exec form di
ENTRYPOINT(bukan shell form), agar signal sampai langsung ke JVM. - Graceful shutdown timeout diset di Spring Boot properties:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Ini memberi waktu 30 detik untuk in-flight request selesai sebelum JVM exit.
Jika pakai docker stop dengan default 10 detik grace period, pastikan request bisa selesai dalam waktu itu. Jika tidak, tambahkan docker stop -t 60 ... atau set di Compose/Kubernetes.
10. Security Hardening #
10.1 Non-Root User #
Wajib:
# Untuk distroless, sudah ada user nonroot
FROM gcr.io/distroless/java21-debian12:nonroot
USER nonroot:nonroot
# Untuk base image biasa, buat user eksplisit
RUN groupadd -r spring && useradd -r -g spring -d /home/spring spring
USER spring
10.2 JVM Security Flags #
Beberapa flag yang direkomendasikan untuk production:
# Disable insecure features
-Djava.security.properties=/path/to/java.security
# Enable TLS only (disable SSL)
-Djdk.tls.disabledAlgorithms=SSLv3,RC4,MD5withRSA
# Restrict reflective access (Java 9+)
--add-opens=java.base/java.lang=ALL-UNNAMED
10.3 Container Security #
Read-only root filesystem (jika aplikasi support):
docker run --read-only --tmpfs /tmp myapp
Spring Boot akan bekerja dengan read-only root selama tidak ada file write ke filesystem (semua lewat temp dir atau log ke STDOUT).
Drop capabilities:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
AppArmor atau SELinux profile sesuai standar security organisasi.
10.4 Vulnerability Scanning #
Integrasikan scanning di CI:
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Scan
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
Java image terkenal punya banyak transitive dependency, dan CVE muncul secara berkala. Rebuild image secara berkala untuk menarik base image yang sudah di-patch.
11. Anti-Pattern yang Harus Dihindari #
✗ Pakai JDK di Runtime Stage #
// ✗ Image 300+ MB karena bawa JDK
FROM openjdk:21
COPY app.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
Solusi: Pakai JRE atau distroless di runtime stage.
✗ Tidak Pakai Multi-Stage #
// ✗ Build tool ada di image final
FROM maven:3.9
WORKDIR /build
COPY . .
RUN mvn package
CMD ["java", "-jar", "target/app.jar"]
Solusi: Multi-stage, runtime stage tanpa Maven.
✗ Copy Source Code ke Runtime Image #
// ✗ Source code Java ada di runtime image
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY src ./src
COPY target/app.jar .
CMD ["java", "-jar", "app.jar"]
Solusi: Hanya copy app.jar dari build stage.
✗ Tidak Pakai Layered JAR #
// ✗ Setiap code change = rebuild semua layer
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY . .
RUN mvn package
FROM eclipse-temurin:21-jre
COPY --from=builder /build/target/app.jar app.jar
Solusi: Extract layered JAR dan copy per layer.
✗ Hardcode Memory Settings #
// ✗ Xmx tidak adaptif terhadap container limit
ENTRYPOINT ["java", "-Xmx512m", "-jar", "app.jar"]
Solusi: Pakai MaxRAMPercentage agar JVM adaptif.
✗ Tag Image Tanpa Strategi #
# ✗ Tidak bisa rollback
docker build -t myapp:latest .
# ✓ Bisa di-rollback
docker build -t myapp:1.4.0 .
docker build -t myapp:1.4.0-$BUILD_NUMBER .
12. Contoh Dockerfile Spring Boot Production-Grade #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build dengan Layered JAR ====
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
# Cache Maven dependencies
COPY pom.xml ./
COPY .mvn .mvn
COPY mvnw ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
# Build aplikasi
COPY src ./src
RUN ./mvnw package -DskipTests -B
# Extract layered JAR
RUN java -Djarmode=layertools \
-jar /build/target/*.jar extract \
--destination /build/extracted
# ==== Stage 2: Runtime Distroless ====
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
# Copy layer dalam urutan yang memaksimalkan cache
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./
# JVM tuning
ENV JAVA_TOOL_OPTIONS="\
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+ExitOnOutOfMemoryError \
-Djava.security.egd=file:/dev/./urandom"
# Actuator port
EXPOSE 8080
# Run sebagai non-root
USER nonroot:nonroot
# Spring Boot launcher
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Karakteristik image ini:
- Ukuran akhir: 70-130 MB (tergantung dependency).
- Hanya JRE di runtime stage.
- Layered cache: perubahan kode hanya rebuild layer
application. - Container-aware JVM.
- Non-root user.
- Tidak ada shell, tidak ada package manager.
13. Kapan Pakai Strategi Mana #
| Kondisi | Pilihan | Alasan |
|---|---|---|
| Spring Boot API standar | Distroless + layered JAR | Ukuran kecil, aman, cache optimal |
| Microservice banyak dep | Distroless + audit | Wajib audit dependency |
| Serverless / Fargate | Distroless | Cold start time penting |
| Aplikasi legacy (JDK 8/11) | Temurin JRE | Belum bisa ke JDK 21 |
| Butuh native performance | GraalVM Native Image | Startup sangat cepat, memory kecil |
| High-security enterprise | Distroless + custom base | Minimal attack surface |
14. Distroless vs Alpine: Jangan Salah Kaprah #
Banyak engineer mengira alpine selalu lebih kecil. Untuk Java, ini sering tidak benar.
| Aspek | Alpine | Distroless |
|---|---|---|
| libc | musl | glibc |
| Java compatibility | Kadang bermasalah | Sangat stabil |
| Debugging | Mudah (ada shell) | Sulit |
| Ukuran image Java | Kadang lebih besar | Konsisten kecil |
| Official support | Komunitas | Google + komunitas |
Kenapa Alpine kadang lebih besar untuk Java?
- musl libc kadang menarik library kompatibilitas yang tidak efisien.
- JVM di Alpine kadang di-compile dengan musl-specific patch yang menambah ukuran.
- Library native (Netty, Bouncy Castle, dll) bisa lebih besar di musl.
Distroless dengan glibc biasanya lebih kecil dan lebih stabil untuk Java modern. Gunakan Alpine hanya kalau kamu sudah test aplikasimu di Alpine.
15. Checklist Review Dockerfile Java #
BASE IMAGE:
□ Tag eksplisit (eclipse-temurin:21.0.4-jre-jammy, bukan latest)
□ Runtime stage JRE only, bukan JDK
□ Distroless atau JRE slim, bukan JDK
□ Bukan openjdk:* (terlalu besar)
BUILD:
□ Multi-stage build
□ Layered JAR diaktifkan di pom.xml/build.gradle
□ Cache dependency (mvn dependency:go-offline)
□ Source code copy terpisah dari pom.xml
□ Build tool (Maven/Gradle) tidak masuk runtime image
LAYER:
□ dependencies layer (paling jarang berubah)
□ spring-boot-loader layer
□ snapshot-dependencies layer
□ application layer (paling sering berubah)
RUNTIME:
□ USER nonroot
□ UseContainerSupport aktif
□ MaxRAMPercentage diset
□ Healthcheck (Actuator)
□ Log ke STDOUT (Logback JSON)
□ ENTRYPOINT dalam exec form
UKURAN:
□ < 150 MB untuk distroless runtime
□ < 250 MB untuk JRE slim runtime
□ docker history tidak ada layer aneh
KEAMANAN:
□ Tidak ada secret di image
□ .dockerignore tegas
□ Image di-scan trivy/grype
□ Non-root user
□ JVM security flags (TLS only, no SSL)
□ Base image up-to-date
DEPENDENCY:
□ Audit dependency berkala
□ Hapus starter yang tidak terpakai
□ Exclude transitive dep yang tidak dipakai
□ Library logging dipilih yang efisien
Ringkasan #
- Image Java ramping sangat mungkin — bukan takdir. Yang membedakan image 450 MB dengan image 70 MB adalah pilihan JRE only, distroless, dan layered JAR.
- Realita ukuran: 70-130 MB (distroless + layered), 110-150 MB (alpine JRE), 180-220 MB (jre default), 300-450 MB (JDK — anti-pattern).
- Multi-stage build adalah wajib. Build stage punya JDK + Maven; runtime stage hanya JRE + fat JAR.
- Layered JAR wajib diaktifkan di Spring Boot 2.3+ agar cache Docker bekerja optimal per dependency vs per code.
- Distroless adalah default production —
gcr.io/distroless/java21-debian12:nonroot. Alpine tidak selalu lebih kecil untuk Java.- JVM container-aware —
-XX:+UseContainerSupportdan-XX:MaxRAMPercentage=75.0agar heap adaptif terhadap container memory limit.- JRE only, bukan JDK —
eclipse-temurin:21-jredi runtime stage,eclipse-temurin:21-jdkdi build stage.- Audit dependency — Spring Boot menarik banyak transitive dep. Hapus starter yang tidak dipakai, exclude library yang tidak relevan.
- Log ke STDOUT, bukan file — Logback dengan
LogstashEncoderuntuk JSON structured log.- Healthcheck via Actuator —
/actuator/health/livenessdan/actuator/health/readinessuntuk orchestrator probe.- Image ramping butuh observability yang solid — log JSON, metrics, healthcheck, dan graceful shutdown. Distroless memaksa disiplin ini.
- Tag eksplisit, bukan
latest—eclipse-temurin:21.0.4-jre-jammy. Rebuild berkala untuk menarik base image yang sudah di-patch.