Selama ini kita menyiapkan mesin target sebelum aplikasi tiba: pasang JDK versi tepat, atur environment variable, salin runtime jlink. Setiap mesin adalah kemungkinan baru untuk perbedaan kecil yang berakibat besar — masalah klasik “di mesin saya jalan”. Container menyelesaikannya dengan membalik arah: aplikasi membawa lingkungannya sendiri dalam bentuk paket standar yang bisa dijalankan identik di laptop, server fisik, maupun cluster cloud. Docker adalah implementasi container yang paling umum, dan bagi deployment Java modern memahaminya bukan lagi opsional. Chapter ini membahas tiga konsep intinya — image, container, dan Dockerfile — lalu membongkar mekanisme cache yang membuat build image menjadi cepat.

Image, Container, dan Registry #

Tiga istilah ini saling bertaut dan sering tertukar:

IstilahAnalogiSifat
Imagecetakan/program terpasangimmutable, berlapis, diberi tag
Containerproses yang berjalan dari imagebisa dinyalakan/dimatikan, ephemeral
Registrygudang distribusi imageDocker Hub, registry privat

Satu image bisa menurunkan banyak container yang berjalan serentak — seperti satu berkas toko-fat.jar dijalankan di sepuluh mesin. Image dibangun dari Dockerfile: resep berisi instruksi yang dieksekusi berurutan dari atas ke bawah. Untuk aplikasi Java kita, resep termuda biasanya hanya beberapa baris:

dockerfile Dockerfile pertama untuk aplikasi kasir
FROM eclipse-temurin:25-jre
COPY target/toko-fat.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Baris FROM mengambil image dasar berisi JRE 25 resmi Temurin, COPY memasukkan artefak aplikasi, EXPOSE mendokumentasikan port yang dipakai, dan ENTRYPOINT menentukan perintah yang dijalankan setiap container dinyalakan. Membangun dan menjalankannya memakai dua perintah inti:

bash Bangun image lalu jalankan container
docker build -t kasirapp:1.0.0 .
docker run --rm -p 8080:8080 kasirapp:1.0.0

Flag -t memberi nama dan tag pada image, --rm membuang container setelah berhenti, dan -p menjembatani port mesin ke port container.

Lapisan Image dan Cache #

Setiap instruksi Dockerfile menghasilkan satu layer yang ditumpuk di atas layer sebelumnya. Builder Docker menyimpan cache layer ini — dan aturan mainnya menentukan cepat atau lambatnya workflow harian: begitu satu layer berubah, seluruh layer di atasnya harus dibangun ulang. Program berikut memodelkan mekanisme itu dengan hash berantai:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** Model lapisan image: layer ID berantai + cache seperti builder Docker. */
public class LapisanImage {

    /** Satu instruksi Dockerfile beserta "konten" berkas yang dibawanya. */
    record Instruksi(String teks, String konten) {}

    /** ID lapisan = hash(parent + instruksi + konten), seperti cache Docker. */
    static String idLapisan(String idOrtu, Instruksi i) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            String bahan = (idOrtu == null ? "-" : idOrtu) + "|" + i.teks() + "|" + i.konten();
            byte[] cerna = md.digest(bahan.getBytes(StandardCharsets.UTF_8));
            StringBuilder hex = new StringBuilder();
            for (int j = 0; j < 6; j++) {
                hex.append(String.format("%02x", cerna[j]));
            }
            return hex.toString();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    /** Jalankan "docker build": HIT bila ID lapisan pernah dibangun sebelumnya. */
    static void build(String judul, List<Instruksi> dockerfile, Map<String, Boolean> cache) {
        System.out.println("== " + judul + " ==");
        String idOrtu = null;
        int hit = 0;
        for (Instruksi i : dockerfile) {
            String id = idLapisan(idOrtu, i);
            boolean tercache = cache.containsKey(id);
            if (tercache) {
                hit++;
            } else {
                cache.put(id, true);
            }
            System.out.printf("  %-44s %s  %s%n", i.teks(), id, tercache ? "HIT" : "MISS");
            idOrtu = id;
        }
        System.out.printf("  lapisan terpakai ulang: %d/%d%n%n", hit, dockerfile.size());
    }

    public static void main(String[] args) {
        List<Instruksi> dockerfile = List.of(
                new Instruksi("FROM eclipse-temurin:25-jre", "image dasar"),
                new Instruksi("COPY libs/util.jar /app/libs/util.jar", "util.jar v1.0.0"),
                new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v1.0.0"),
                new Instruksi("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", "-"));

        Map<String, Boolean> cache = new HashMap<>();
        build("Build 1: cache masih kosong", dockerfile, cache);
        build("Build 2: tanpa perubahan apa pun", dockerfile, cache);

        List<Instruksi> kodeBaru = new ArrayList<>(dockerfile);
        kodeBaru.set(2, new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v1.0.1"));
        build("Build 3: hanya kode aplikasi berubah", kodeBaru, cache);

        // Urutan instruksi memengaruhi jumlah lapisan yang bisa dipakai ulang.
        List<Instruksi> srcDulu = List.of(
                new Instruksi("FROM eclipse-temurin:25-jre", "image dasar"),
                new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v2.0.0"),
                new Instruksi("COPY libs/util.jar /app/libs/util.jar", "util.jar v1.0.0"),
                new Instruksi("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", "-"));
        build("Build 4: kode disalin lebih dulu (urutan kurang tepat)", srcDulu, cache);

        List<Instruksi> depDulu = List.of(
                new Instruksi("FROM eclipse-temurin:25-jre", "image dasar"),
                new Instruksi("COPY libs/util.jar /app/libs/util.jar", "util.jar v1.0.0"),
                new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v2.0.0"),
                new Instruksi("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", "-"));
        build("Build 5: dependency dulu, kode paling akhir (urutan tepat)", depDulu, cache);
    }
}

Output yang Dihasilkan:

text
== Build 1: cache masih kosong ==
  FROM eclipse-temurin:25-jre                  774a00eead9f  MISS
  COPY libs/util.jar /app/libs/util.jar        63af376ebaf9  MISS
  COPY target/toko-app.jar /app/app.jar        ba47c22b194d  MISS
  ENTRYPOINT ["java","-jar","/app/app.jar"]    c84f17fd19ec  MISS
  lapisan terpakai ulang: 0/4

== Build 2: tanpa perubahan apa pun ==
  FROM eclipse-temurin:25-jre                  774a00eead9f  HIT
  COPY libs/util.jar /app/libs/util.jar        63af376ebaf9  HIT
  COPY target/toko-app.jar /app/app.jar        ba47c22b194d  HIT
  ENTRYPOINT ["java","-jar","/app/app.jar"]    c84f17fd19ec  HIT
  lapisan terpakai ulang: 4/4

== Build 3: hanya kode aplikasi berubah ==
  FROM eclipse-temurin:25-jre                  774a00eead9f  HIT
  COPY libs/util.jar /app/libs/util.jar        63af376ebaf9  HIT
  COPY target/toko-app.jar /app/app.jar        5137bf832e75  MISS
  ENTRYPOINT ["java","-jar","/app/app.jar"]    3248684a0eef  MISS
  lapisan terpakai ulang: 2/4

== Build 4: kode disalin lebih dulu (urutan kurang tepat) ==
  FROM eclipse-temurin:25-jre                  774a00eead9f  HIT
  COPY target/toko-app.jar /app/app.jar        e5e2bdf07aca  MISS
  COPY libs/util.jar /app/libs/util.jar        0be865d04acf  MISS
  ENTRYPOINT ["java","-jar","/app/app.jar"]    cb40800cac80  MISS
  lapisan terpakai ulang: 1/4

== Build 5: dependency dulu, kode paling akhir (urutan tepat) ==
  FROM eclipse-temurin:25-jre                  774a00eead9f  HIT
  COPY libs/util.jar /app/libs/util.jar        63af376ebaf9  HIT
  COPY target/toko-app.jar /app/app.jar        5b41d316b957  MISS
  ENTRYPOINT ["java","-jar","/app/app.jar"]    cbb7ddac0c04  MISS
  lapisan terpakai ulang: 2/4

Mari kita baca hasilnya per skenario:

  1. idLapisan(idOrtu, i): ID sebuah layer dihitung dari hash gabungan ID layer ortu + teks instruksi + konten berkas. Karena ID ortu ikut dihitung, rantai ini menular: satu layer yang berubah membuat seluruh keturunannya berganti ID.
  2. Build 1 dan 2: build pertama mengisi cache (semua MISS), build kedua tanpa perubahan menemukan keempat ID yang sama — empat HIT, tidak ada yang diunduh ulang.
  3. Build 3: hanya toko-app.jar yang berubah (v1.0.0 → v1.0.1). Dua layer teratas MISS meski util.jar tidak berubah — itulah penularan ID ortu di baris idOrtu = id.
  4. Build 4 vs 5: dua urutan salinan yang sama isinya, hasilnya berbeda. Menyalin kode aplikasi lebih dulu membuat util.jar ikut dibangun ulang (1/4 terpakai); menempatkan dependency di atas dan kode paling bawah menjaga dua layer pertama tetap HIT (2/4). Prinsip praktisnya: yang paling sering berubah ditaruh paling akhir.

Perintah Pokok docker CLI #

PerintahFungsi
docker buildmembangun image dari Dockerfile
docker runmenyalakan container dari image
docker psmendaftar container yang berjalan
docker logsmembaca keluaran stdout/stderr container
docker execmenjalankan perintah di dalam container yang hidup
docker stop / docker rmmenghentikan / membuang container

Karena container bersifat ephemeral, log aplikasi Java dibaca lewat docker logs, bukan berkas di dalam container — implikasi besar bagi cara kita mendesain logging yang akan kita bahas di chapter tersendiri.

Konsep dasar ini cukup untuk memahami peta. Chapter berikutnya masuk ke wilayah praktik khusus Java: menulis Dockerfile yang ramping, memahami perilaku JVM di dalam batas memori container, dan memilih base image yang tepat.

Referensi #

Berkas Sumber

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

LapisanImage.java

Unduh berkas
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** Model lapisan image: layer ID berantai + cache seperti builder Docker. */
public class LapisanImage {

    /** Satu instruksi Dockerfile beserta "konten" berkas yang dibawanya. */
    record Instruksi(String teks, String konten) {}

    /** ID lapisan = hash(parent + instruksi + konten), seperti cache Docker. */
    static String idLapisan(String idOrtu, Instruksi i) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            String bahan = (idOrtu == null ? "-" : idOrtu) + "|" + i.teks() + "|" + i.konten();
            byte[] cerna = md.digest(bahan.getBytes(StandardCharsets.UTF_8));
            StringBuilder hex = new StringBuilder();
            for (int j = 0; j < 6; j++) {
                hex.append(String.format("%02x", cerna[j]));
            }
            return hex.toString();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    /** Jalankan "docker build": HIT bila ID lapisan pernah dibangun sebelumnya. */
    static void build(String judul, List<Instruksi> dockerfile, Map<String, Boolean> cache) {
        System.out.println("== " + judul + " ==");
        String idOrtu = null;
        int hit = 0;
        for (Instruksi i : dockerfile) {
            String id = idLapisan(idOrtu, i);
            boolean tercache = cache.containsKey(id);
            if (tercache) {
                hit++;
            } else {
                cache.put(id, true);
            }
            System.out.printf("  %-44s %s  %s%n", i.teks(), id, tercache ? "HIT" : "MISS");
            idOrtu = id;
        }
        System.out.printf("  lapisan terpakai ulang: %d/%d%n%n", hit, dockerfile.size());
    }

    public static void main(String[] args) {
        List<Instruksi> dockerfile = List.of(
                new Instruksi("FROM eclipse-temurin:25-jre", "image dasar"),
                new Instruksi("COPY libs/util.jar /app/libs/util.jar", "util.jar v1.0.0"),
                new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v1.0.0"),
                new Instruksi("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", "-"));

        Map<String, Boolean> cache = new HashMap<>();
        build("Build 1: cache masih kosong", dockerfile, cache);
        build("Build 2: tanpa perubahan apa pun", dockerfile, cache);

        List<Instruksi> kodeBaru = new ArrayList<>(dockerfile);
        kodeBaru.set(2, new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v1.0.1"));
        build("Build 3: hanya kode aplikasi berubah", kodeBaru, cache);

        // Urutan instruksi memengaruhi jumlah lapisan yang bisa dipakai ulang.
        List<Instruksi> srcDulu = List.of(
                new Instruksi("FROM eclipse-temurin:25-jre", "image dasar"),
                new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v2.0.0"),
                new Instruksi("COPY libs/util.jar /app/libs/util.jar", "util.jar v1.0.0"),
                new Instruksi("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", "-"));
        build("Build 4: kode disalin lebih dulu (urutan kurang tepat)", srcDulu, cache);

        List<Instruksi> depDulu = List.of(
                new Instruksi("FROM eclipse-temurin:25-jre", "image dasar"),
                new Instruksi("COPY libs/util.jar /app/libs/util.jar", "util.jar v1.0.0"),
                new Instruksi("COPY target/toko-app.jar /app/app.jar", "toko-app.jar v2.0.0"),
                new Instruksi("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", "-"));
        build("Build 5: dependency dulu, kode paling akhir (urutan tepat)", depDulu, cache);
    }
}