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:
| Komponen | Dibutuhkan saat build | Dibutuhkan saat berjalan |
|---|---|---|
javac, jar, jlink | ya | tidak |
| Cache dependensi Maven | ya | tidak |
Kode sumber src/ | ya | tidak |
| JRE | ya | ya |
toko-fat.jar | dihasilkan | ya |
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:
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:
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:
== 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:
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.FINAL: JRE (240 MB) plus satu-satunya artefak yang menyeberang lewatCOPY --from—toko-fat.jar(3 MB). Inilah seluruh isi image yang ditarik setiap deployment.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.record Berkas(...): data tahap dimodelkan immutable karena memang tidak berubah setelah didefinisikan — cermin sifat layer image yang kita bahas di chapter Docker Fundamentals.
Kombinasi dengan jlink #
Tahap final bisa dirampingkan lebih jauh dengan merakit runtime kustom di tahap builder — menggabungkan materi chapter 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 berkasimport 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);
}
}