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:

  1. Multi-stage build — pisahkan build (butuh JDK + Maven) dari runtime (butuh JRE saja).
  2. Distroless JRE — runtime image yang sangat ramping, tanpa shell atau package manager.
  3. 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: selama pom.xml tidak 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 + user nonroot. Tanpa shell, tanpa package manager. Ukuran ~70 MB.
  • Layer copy order sangat penting:
    • dependencies (paling jarang berubah) di-copy pertama
    • spring-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 dengan Gson atau Moshi (lebih kecil).
  • Hibernate — ORM lengkap, tapi jOOQ atau Jdbi lebih ramping untuk query-heavy apps.
  • Logback — default, tapi log4j2 dengan AsyncLogger bisa 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 productiongcr.io/distroless/java21-debian12:nonroot. Alpine tidak selalu lebih kecil untuk Java.
  • JVM container-aware-XX:+UseContainerSupport dan -XX:MaxRAMPercentage=75.0 agar heap adaptif terhadap container memory limit.
  • JRE only, bukan JDKeclipse-temurin:21-jre di runtime stage, eclipse-temurin:21-jdk di 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 LogstashEncoder untuk JSON structured log.
  • Healthcheck via Actuator/actuator/health/liveness dan /actuator/health/readiness untuk orchestrator probe.
  • Image ramping butuh observability yang solid — log JSON, metrics, healthcheck, dan graceful shutdown. Distroless memaksa disiplin ini.
  • Tag eksplisit, bukan latesteclipse-temurin:21.0.4-jre-jammy. Rebuild berkala untuk menarik base image yang sudah di-patch.

← Sebelumnya: Python   Berikutnya: PHP →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact