Masalah Cache Docker pada Fat JAR Konvensional

Secara default, Spring Boot mengemas aplikasi ke dalam satu file arsip besar (fat JAR atau uber JAR) yang memuat kode bisnis bersama dependensi eksternal (Spring Framework, database driver, utilitas pihak ketiga). Ukuran file ini umumnya berkisar antara 40 MB hingga lebih dari 150 MB.

Ketika file fat JAR disalin langsung ke container image melalui satu instruksi Dockerfile:

COPY target/myapp-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Docker memperlakukan file tersebut sebagai satu layer utuh berdasarkan checksum file. Perubahan satu baris kode di kontroler akan mengubah checksum seluruh file JAR. Akibatnya, Docker membatalkan layer cache pada instruksi COPY dan seluruh layer dependensi harus ditulis ulang, dikompresi, serta diunggah ulang ke container registry pada pipeline CI/CD.

Prinsip Kerja Layered JAR

Mulai Spring Boot 2.3, plugin Maven dan Gradle menyediakan fitur pemisahan JAR ke dalam beberapa layer terpisah. Spring Boot membagi isi JAR ke dalam empat tingkatan berurutan berdasarkan frekuensi perubahannya:

  1. dependencies: Pustaka pihak ketiga non-snapshot. Jarang berubah.
  2. spring-boot-loader: Kode internal peluncur executable JAR dari Spring Boot. Sangat jarang berubah.
  3. snapshot-dependencies: Pustaka pihak ketiga dengan versi SNAPSHOT. Frekuensi perubahan sedang.
  4. application: Kode sumber aplikasi, kelas internal, dan file konfigurasi lokal (application.yml). Berubah pada hampir setiap commit.

Dengan memetakan layer-layer ini ke instruksi COPY yang terpisah di Dockerfile, Docker dapat mempertahankan cache layer dependensi eksternal (90-95% ukuran total) dan hanya membangun serta mengunggah layer application yang berukuran kecil.

Konfigurasi Build Tool

1. Apache Maven

Pada Maven, fitur ini aktif secara bawaan (default) sejak Spring Boot 2.3+. Pastikan spring-boot-maven-plugin terpasang di 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>

2. Gradle

Pada Gradle (Kotlin DSL atau Groovy DSL), layered JAR juga aktif secara otomatis. Konfigurasi eksplisit dapat dituliskan sebagai berikut:

tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
    layered {
        enabled.set(true)
    }
}

Implementasi Multi-Stage Dockerfile

Gunakan sistem ekstraksi berbasis layertools dengan multi-stage build. Pola ini memisahkan proses ekstraksi layer dari image akhir sehingga runtime image tetap bersih dan ramping.

# Stage 1: Extractor
FROM eclipse-temurin:21-jre-alpine AS extractor
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

# Stage 2: Runtime Environment
FROM eclipse-temurin:21-jre-alpine
WORKDIR /application

# Susun layer dari yang paling jarang berubah ke yang paling sering berubah
COPY --from=extractor /builder/dependencies/ ./
COPY --from=extractor /builder/spring-boot-loader/ ./
COPY --from=extractor /builder/snapshot-dependencies/ ./
COPY --from=extractor /builder/application/ ./

# Spring Boot 3.2+ menggunakan package org.springframework.boot.loader.launch.JarLauncher
# Untuk Spring Boot < 3.2, gunakan org.springframework.boot.loader.JarLauncher
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Catatan Teknis: Jangan jalankan aplikasi menggunakan ENTRYPOINT ["java", "-jar", "application.jar"] pada tahap ini, karena file JAR telah dipecah menjadi direktori kelas dan pustaka individual. Peluncuran dilakukan langsung lewat class loader Spring Boot (JarLauncher).

Integrasi pada Pipeline GitHub Actions

Agar Docker layer cache tersimpan di antara proses build CI, gunakan docker/build-push-action bersama cache backend GitHub Actions (gha).

name: Build and Push Docker Image

on:
  push:
    branches: [ "main" ]

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

      - name: Setup Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Package Application
        run: ./mvnw clean package -DskipTests

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

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

Penggunaan mode=max memastikan seluruh layer perantara (termasuk layer dari multi-stage builder) disimpan ke dalam cache storage GitHub Actions.

Perbandingan: Fat JAR vs Layered JAR

Berikut simulasi beban transfer dan waktu build pada pipeline CI untuk aplikasi dengan ukuran total JAR 65 MB (dependensi: 63 MB, kode aplikasi: 2 MB):

  • Fat JAR Konvensional: Setiap perubahan kode sumber menghasilkan invalidasi cache total. CI harus membangun ulang image layer 65 MB dan melakukan docker push sebesar 65 MB. Waktu transfer registry berkisar antara 15–30 detik tergantung latensi jaringan.
  • Layered JAR: Docker memanfaatkan cache lokal/registry untuk layer dependencies dan spring-boot-loader. Hanya layer application (2 MB) yang di-build dan diunggah. Waktu transfer layer berkurang menjadi kurang dari 2 detik.

Troubleshooting dan Pertimbangan

  • Path JarLauncher pada Spring Boot 3.2+: Paket kelas loader berubah dari org.springframework.boot.loader.JarLauncher menjadi org.springframework.boot.loader.launch.JarLauncher. Jika container gagal menyala dengan error ClassNotFoundException, periksa versi Spring Boot yang digunakan pada proyek.
  • Layer Snapshot Dependencies: Jika proyek Anda banyak mengonsumsi library internal bertipe SNAPSHOT yang sering berubah, layer snapshot-dependencies akan sering invalid. Pastikan dependensi internal stabil sebelum masuk ke pipeline rilis utama untuk menjaga efisiensi cache.