Dockerfile di chapter sebelumnya punya asumsi tersembunyi: berkas toko-fat.jar sudah tersedia di target/ sebelum docker build dijalankan. Pendekatan itu memindahkan ketergantungan build ke mesin pembuat image — mesin itu harus punya JDK dan Maven versi tepat, persis masalah yang ingin kita atasi dengan container. Solusi yang lebih bersih: melakukan build di dalam Dockerfile itu sendiri. Sekaligus muncul masalah kedua — image yang ikut menyimpan compiler, cache Maven, dan kode sumber ikut terkirim ke produksi. Multi-stage build menyelesaikan keduanya: beberapa tahap dalam satu Dockerfile, tahap builder memasak, tahap final hanya membawa hasil masakannya.

Dapur Jangan Ikut ke Ruang Saji #

Dockerfile satu tahap yang sekaligus build dan run memaksa kita memilih base image jdk — bukan jre — karena tahap build membutuhkan javac. Akibatnya image produksi membawa:

KomponenDibutuhkan saat buildDibutuhkan saat berjalan
javac, jar, jlinkyatidak
Cache dependensi Mavenyatidak
Kode sumber src/yatidak
JREyaya
toko-fat.jardihasilkanya

Empat dari enam baris di kolom kiri tidak dibutuhkan kolom kanan — sekitar separuh ukuran image terbuang untuk peralatan yang tak pernah dipakai lagi setelah container menyala.

Dua Tahap dalam Satu Dockerfile #

Instruksi FROM boleh muncul lebih dari sekali; setiap kemunculan memulai tahap baru yang diberi nama lewat AS. Peralatan antar tahap berpindah lewat COPY --from:

dockerfile Dockerfile multi-stage: build dengan Maven, run dengan JRE
FROM maven:3.9-eclipse-temurin-25 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests=false

FROM eclipse-temurin:25-jre AS final
WORKDIR /app
COPY --from=builder /build/target/toko-fat.jar app.jar
USER 1000
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=60.0", "-jar", "/app/app.jar"]

Tahap pertama berjalan penuh di image Maven berisi JDK: menyalin pom.xml, mengunduh dependensi (dependency:go-offline), menyalin sumber, lalu mvn package. Tahap kedua memulai dari image JRE yang jauh lebih kecil dan hanya menyalin satu artefak: JAR hasil tahap pertama. Builder dan seluruh isinya dibuang begitu build selesai — image akhir tidak mengenal javac.

Apa yang Ikut ke Image Akhir #

Program berikut membandingkan inventaris kedua tahap seperti pembacaan docker image history pada kedua pendekatan:

java TahapBuild.java
import java.util.List;

/** Membandingkan isi image akhir: satu tahap vs multi-stage build. */
public class TahapBuild {

    /** Satu berkas dalam tahap build: nama dan ukuran (MB). */
    record Berkas(String nama, int mb) {}

    /** Isi tahap builder: alat build + sumber + hasil build. */
    static final List<Berkas> BUILDER = List.of(
            new Berkas("jdk-25 lengkap (javac, jlink, jcmd)", 460),
            new Berkas("cache dependensi Maven", 85),
            new Berkas("src/main/java (kode sumber)", 1),
            new Berkas("target/toko-fat.jar (hasil build)", 3));

    /** Isi tahap final: hanya yang dibutuhkan untuk berjalan. */
    static final List<Berkas> FINAL = List.of(
            new Berkas("jre-25 (runtime saja)", 240),
            new Berkas("toko-fat.jar (disalin dari builder)", 3));

    static int total(List<Berkas> berkas) {
        return berkas.stream().mapToInt(Berkas::mb).sum();
    }

    static void tampilkan(String judul, List<Berkas> berkas) {
        System.out.println("== " + judul + " ==");
        for (Berkas b : berkas) {
            System.out.printf("  %-42s %5d MB%n", b.nama(), b.mb());
        }
        System.out.printf("  %-42s %5d MB%n", "TOTAL (" + berkas.size() + " berkas)",
                total(berkas));
        System.out.println();
    }

    public static void main(String[] args) {
        tampilkan("Tahap builder (tidak ikut image akhir)", BUILDER);
        tampilkan("Tahap final (menjadi image akhir)", FINAL);

        int single = total(BUILDER);
        int multi = total(FINAL);
        System.out.println("== Perbandingan image akhir ==");
        System.out.printf("  satu tahap (semua ikut)  : %d MB%n", single);
        System.out.printf("  multi-stage               : %d MB%n", multi);
        System.out.printf("  yang tidak ikut terbawa   : %d MB%n", single - multi);
        System.out.printf("  penghematan               : %.1f%%%n", 100.0 * (single - multi) / single);
    }
}

Output yang Dihasilkan:

text
== Tahap builder (tidak ikut image akhir) ==
  jdk-25 lengkap (javac, jlink, jcmd)          460 MB
  cache dependensi Maven                        85 MB
  src/main/java (kode sumber)                    1 MB
  target/toko-fat.jar (hasil build)              3 MB
  TOTAL (4 berkas)                             549 MB

== Tahap final (menjadi image akhir) ==
  jre-25 (runtime saja)                        240 MB
  toko-fat.jar (disalin dari builder)            3 MB
  TOTAL (2 berkas)                             243 MB

== Perbandingan image akhir ==
  satu tahap (semua ikut)  : 549 MB
  multi-stage               : 243 MB
  yang tidak ikut terbawa   : 306 MB
  penghematan               : 55.7%

Mari kita bedah angkanya:

  1. BUILDER: empat berkas dengan ukuran model realistis — JDK penuh (460 MB) mendominasi, disusul cache dependensi Maven. Ukuran ini tidak akan pernah menyentuh registry bila multi-stage dipakai.
  2. FINAL: JRE (240 MB) plus satu-satunya artefak yang menyeberang lewat COPY --from — toko-fat.jar (3 MB). Inilah seluruh isi image yang ditarik setiap deployment.
  3. total(...) dan perbandingan akhir: 549 MB vs 243 MB — penghematan 55,7%. Dalam pipeline CI yang membangun puluhan kali sehari, selisih ini menggandakan kecepatan push/pull image dan memangkas tag registry.
  4. record Berkas(...): data tahap dimodelkan immutable karena memang tidak berubah setelah didefinisikan — cermin sifat layer image yang kita bahas di chapter Docker Fundamentals.

Tahap final bisa dirampingkan lebih jauh dengan merakit runtime kustom di tahap builder — menggabungkan materi chapter jlink:

dockerfile Tahap final berbasis runtime jlink
FROM maven:3.9-eclipse-temurin-25 AS builder
WORKDIR /build
COPY . .
RUN mvn package
RUN jlink --add-modules java.base,java.sql,java.logging \
    --strip-debug --output /build/runtime

FROM debian:bookworm-slim AS final
COPY --from=builder /build/runtime /opt/runtime
COPY --from=builder /build/target/toko-fat.jar /app/app.jar
ENTRYPOINT ["/opt/runtime/bin/java", "-XX:MaxRAMPercentage=60.0", "-jar", "/app/app.jar"]

Runtime hasil jlink hanya memuat modul yang dihitung jdeps — tipikal belasan hingga puluhan MB lebih kecil dari JRE penuh, dengan base image OS minimal sebagai penampung libc dan utilitas dasar.

Image akhir yang ramping adalah artefak siap distribusi — tetapi proses menuju ke sana masih manual: build, test, package, tag, push. Chapter berikutnya mengotomatiskan seluruh rantai tersebut dengan pipeline CI/CD.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

TahapBuild.java

Unduh berkas
java
import java.util.List;

/** Membandingkan isi image akhir: satu tahap vs multi-stage build. */
public class TahapBuild {

    /** Satu berkas dalam tahap build: nama dan ukuran (MB). */
    record Berkas(String nama, int mb) {}

    /** Isi tahap builder: alat build + sumber + hasil build. */
    static final List<Berkas> BUILDER = List.of(
            new Berkas("jdk-25 lengkap (javac, jlink, jcmd)", 460),
            new Berkas("cache dependensi Maven", 85),
            new Berkas("src/main/java (kode sumber)", 1),
            new Berkas("target/toko-fat.jar (hasil build)", 3));

    /** Isi tahap final: hanya yang dibutuhkan untuk berjalan. */
    static final List<Berkas> FINAL = List.of(
            new Berkas("jre-25 (runtime saja)", 240),
            new Berkas("toko-fat.jar (disalin dari builder)", 3));

    static int total(List<Berkas> berkas) {
        return berkas.stream().mapToInt(Berkas::mb).sum();
    }

    static void tampilkan(String judul, List<Berkas> berkas) {
        System.out.println("== " + judul + " ==");
        for (Berkas b : berkas) {
            System.out.printf("  %-42s %5d MB%n", b.nama(), b.mb());
        }
        System.out.printf("  %-42s %5d MB%n", "TOTAL (" + berkas.size() + " berkas)",
                total(berkas));
        System.out.println();
    }

    public static void main(String[] args) {
        tampilkan("Tahap builder (tidak ikut image akhir)", BUILDER);
        tampilkan("Tahap final (menjadi image akhir)", FINAL);

        int single = total(BUILDER);
        int multi = total(FINAL);
        System.out.println("== Perbandingan image akhir ==");
        System.out.printf("  satu tahap (semua ikut)  : %d MB%n", single);
        System.out.printf("  multi-stage               : %d MB%n", multi);
        System.out.printf("  yang tidak ikut terbawa   : %d MB%n", single - multi);
        System.out.printf("  penghematan               : %.1f%%%n", 100.0 * (single - multi) / single);
    }
}