Aplikasi production jarang berjalan di mesin bare metal — sebagian besar berjalan di container Docker. Container memberikan isolasi, reproducibility, dan portabilitas: image yang sama dapat berjalan di laptop developer, server staging, dan cluster Kubernetes tanpa perubahan. Docker membangun image berlapis — setiap instruksi di Dockerfile menjadi satu layer yang dapat di-cache. Strategi caching yang tepat mempercepat build dari menit menjadi detik. Pada chapter ini kita akan membedah konsep dasar Docker, mendemokan anatomi Dockerfile, menjelaskan multi-stage build untuk image kecil, serta membandingkan base image JVM populer — dari eclipse-temurin:21-jre (250 MB) hingga custom jlink (80 MB).

Konsep Dasar Docker #

Docker memiliki beberapa konsep inti yang harus dipahami sebelum menulis Dockerfile:

KonsepDeskripsiContoh
ImageTemplate read-only untuk containereclipse-temurin:21-jre
ContainerInstance berjalan dari imagedocker run -p 8080:8080 my-app
LayerSatu unit perubahan di imageSetiap RUN/COPY = 1 layer
VolumePenyimpanan persisten/var/lib/postgresql/data
NetworkKomunikasi antar containerbridge, host, overlay
RegistryRepo image (Docker Hub, ECR)docker push my-registry/app:1.0
DockerfileResep membangun imageFROM, RUN, COPY, CMD

Image adalah template read-only; container adalah instance yang berjalan. Layer adalah unit perubahan — Docker menyusun image sebagai stack layer, dan cache layer yang tidak berubah antar build.

Anatomi Dockerfile #

kotlin
// docker_fundamentals.kt — Demo konsep Docker: image, layer, container lifecycle

fun main() {
    println("=== Docker Fundamentals ===")
    println()
    println("Image, layer, container lifecycle, multi-stage build, layer caching.")
    println()

    // === 1. Konsep dasar ===
    println("=== 1. Konsep Dasar Docker ===")
    val concepts = listOf(
        Triple("Image", "Template read-only untuk container", "eclipse-temurin:21-jre"),
        Triple("Container", "Instance berjalan dari image", "docker run -p 8080:8080 my-app"),
        Triple("Layer", "Satu unit perubahan di image", "Setiap RUN/COPY = 1 layer"),
        Triple("Volume", "Penyimpanan persisten", "/var/lib/postgresql/data"),
        Triple("Network", "Komunikasi antar container", "bridge, host, overlay"),
        Triple("Registry", "Repo image (Docker Hub, ECR)", "docker push my-registry/app:1.0"),
        Triple("Dockerfile", "Resep membangun image", "FROM, RUN, COPY, CMD")
    )
    concepts.forEach { (konsep, deskripsi, contoh) ->
        println("  " + konsep.padEnd(15) + " | " + deskripsi.padEnd(38) + " | " + contoh)
    }
    println()

    // === 2. Anatomi Dockerfile ===
    println("=== 2. Anatomi Dockerfile ===")
    val dockerfile = listOf(
        "FROM eclipse-temurin:21-jre AS base",
        "WORKDIR /app",
        "COPY build/libs/app.jar app.jar",
        "EXPOSE 8080",
        "ENV JAVA_OPTS=\"-Xmx512m\"",
        "USER 1001",
        "ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]"
    )
    dockerfile.forEach { println("  " + it) }
    println()

    // === 3. Lifecycle container ===
    println("=== 3. Container Lifecycle ===")
    val lifecycle = listOf(
        Pair("Pull", "docker pull eclipse-temurin:21-jre"),
        Pair("Build", "docker build -t my-app:1.0 ."),
        Pair("Run", "docker run -d --name app -p 8080:8080 my-app:1.0"),
        Pair("Stop", "docker stop app"),
        Pair("Start", "docker start app"),
        Pair("Remove", "docker rm app"),
        Pair("RMI", "docker rmi my-app:1.0")
    )
    lifecycle.forEach { (tahap, cmd) ->
        println("  " + tahap.padEnd(10) + " | " + cmd)
    }
    println()

    // === 4. Multi-stage build ===
    println("=== 4. Multi-stage Build (Build + Runtime) ===")
    val multistage = listOf(
        "# Stage 1: Build",
        "FROM gradle:8.5-jdk21 AS builder",
        "WORKDIR /build",
        "COPY . .",
        "RUN gradle shadowJar --no-daemon",
        "",
        "# Stage 2: Runtime (image kecil)",
        "FROM eclipse-temurin:21-jre AS runtime",
        "WORKDIR /app",
        "COPY --from=builder /build/build/libs/app.jar app.jar",
        "EXPOSE 8080",
        "ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]"
    )
    multistage.forEach { println("  " + it) }
    println()

    // === 5. Layer caching ===
    println("=== 5. Layer Caching Strategy ===")
    val cache = listOf(
        "1. COPY build.gradle.kts + settings.gradle.kts SEBELUM source",
        "2. RUN gradle dependencies (cache deps)",
        "3. COPY src/ (sering berubah)",
        "4. RUN gradle build (rebuild hanya bila source berubah)",
        "5. Layer dependensi tetap dari cache bila gradle file tidak berubah"
    )
    cache.forEach { println("  " + it) }
    println()

    // === 6. Ukuran image populer untuk JVM ===
    println("=== 6. Ukuran Image Populer ===")
    val images = listOf(
        Pair("eclipse-temurin:21-jdk", "~400 MB (full JDK)"),
        Pair("eclipse-temurin:21-jre", "~250 MB (JRE only)"),
        Pair("eclipse-temurin:21-jre-alpine", "~180 MB (Alpine Linux)"),
        Pair("amazoncorretto:21-alpine", "~190 MB"),
        Pair("gcr.io/distroless/java21-debian12", "~200 MB (no shell)"),
        Pair("Custom jlink image", "~80-120 MB (custom runtime)")
    )
    images.forEach { (image, size) ->
        println("  " + image.padEnd(38) + " | " + size)
    }
    println()

    // === 7. Jebakan umum ===
    println("=== 7. Jebakan Umum ===")
    val gotchas = listOf(
        "1. Run sebagai root -> buat USER non-root",
        "2. EXPOSE tidak publish port -> butuh -p",
        "3. Build tanpa .dockerignore -> image bengkak",
        "4. Layer terlalu banyak -> gabungkan RUN",
        "5. Pakai :latest tag -> tidak reproducible",
        "6. Hardcode secrets di Dockerfile -> gunakan build args + env"
    )
    gotchas.forEach { println("  " + it) }
    println()

    // === 8. Aturan praktis ===
    println("=== 8. Aturan Praktis ===")
    val rules = listOf(
        "1. Multi-stage build: image final hanya runtime",
        "2. Pakai base image versi spesifik (eclipse-temurin:21.0.4_7-jre)",
        "3. .dockerignore untuk exclude build artifacts",
        "4. USER non-root di image final",
        "5. HEALTHCHECK instruksi di Dockerfile",
        "6. Gabungkan RUN dengan && untuk kurangi layer",
        "7. Scan image untuk CVE (trivy, grype)"
    )
    rules.forEach { println("  " + it) }
}

Output yang Dihasilkan:

text
=== Docker Fundamentals ===

Image, layer, container lifecycle, multi-stage build, layer caching.

=== 1. Konsep Dasar Docker ===
  Image           | Template read-only untuk container     | eclipse-temurin:21-jre
  Container       | Instance berjalan dari image           | docker run -p 8080:8080 my-app
  Layer           | Satu unit perubahan di image           | Setiap RUN/COPY = 1 layer
  Volume          | Penyimpanan persisten                  | /var/lib/postgresql/data
  Network         | Komunikasi antar container             | bridge, host, overlay
  Registry        | Repo image (Docker Hub, ECR)           | docker push my-registry/app:1.0
  Dockerfile      | Resep membangun image                  | FROM, RUN, COPY, CMD

=== 2. Anatomi Dockerfile ===
  FROM eclipse-temurin:21-jre AS base
  WORKDIR /app
  COPY build/libs/app.jar app.jar
  EXPOSE 8080
  ENV JAVA_OPTS="-Xmx512m"
  USER 1001
  ENTRYPOINT ["java", "-jar", "app.jar"]

=== 3. Container Lifecycle ===
  Pull       | docker pull eclipse-temurin:21-jre
  Build      | docker build -t my-app:1.0 .
  Run        | docker run -d --name app -p 8080:8080 my-app:1.0
  Stop       | docker stop app
  Start      | docker start app
  Remove     | docker rm app
  RMI        | docker rmi my-app:1.0

=== 4. Multi-stage Build (Build + Runtime) ===
  # Stage 1: Build
  FROM gradle:8.5-jdk21 AS builder
  WORKDIR /build
  COPY . .
  RUN gradle shadowJar --no-daemon
  
  # Stage 2: Runtime (image kecil)
  FROM eclipse-temurin:21-jre AS runtime
  WORKDIR /app
  COPY --from=builder /build/build/libs/app.jar app.jar
  EXPOSE 8080
  ENTRYPOINT ["java", "-jar", "app.jar"]

=== 5. Layer Caching Strategy ===
  1. COPY build.gradle.kts + settings.gradle.kts SEBELUM source
  2. RUN gradle dependencies (cache deps)
  3. COPY src/ (sering berubah)
  4. RUN gradle build (rebuild hanya bila source berubah)
  5. Layer dependensi tetap dari cache bila gradle file tidak berubah

=== 6. Ukuran Image Populer ===
  eclipse-temurin:21-jdk                 | ~400 MB (full JDK)
  eclipse-temurin:21-jre                 | ~250 MB (JRE only)
  eclipse-temurin:21-jre-alpine          | ~180 MB (Alpine Linux)
  amazoncorretto:21-alpine               | ~190 MB
  gcr.io/distroless/java21-debian12      | ~200 MB (no shell)
  Custom jlink image                     | ~80-120 MB (custom runtime)

=== 7. Jebakan Umum ===
  1. Run sebagai root -> buat USER non-root
  2. EXPOSE tidak publish port -> butuh -p
  3. Build tanpa .dockerignore -> image bengkak
  4. Layer terlalu banyak -> gabungkan RUN
  5. Pakai :latest tag -> tidak reproducible
  6. Hardcode secrets di Dockerfile -> gunakan build args + env

=== 8. Aturan Praktis ===
  1. Multi-stage build: image final hanya runtime
  2. Pakai base image versi spesifik (eclipse-temurin:21.0.4_7-jre)
  3. .dockerignore untuk exclude build artifacts
  4. USER non-root di image final
  5. HEALTHCHECK instruksi di Dockerfile
  6. Gabungkan RUN dengan && untuk kurangi layer
  7. Scan image untuk CVE (trivy, grype)

Mari kita bedah elemen Dockerfile di atas:

  1. FROM: base image. eclipse-temurin:21-jre menyediakan JRE 21 di atas Ubuntu. AS base memberi nama stage untuk multi-stage build.
  2. WORKDIR: direktori kerja. Setara dengan cd /app && ... di setiap instruksi berikutnya.
  3. COPY: menyalin file dari build context (host) ke image. COPY build/libs/app.jar app.jar menyalin JAR hasil build ke /app/app.jar.
  4. EXPOSE: dokumentasi port yang didengar container. Tidak publish port ke host — itu butuh flag -p di docker run.
  5. ENV: set env var. JAVA_OPTS="-Xmx512m" bisa dipakai di ENTRYPOINT.
  6. USER: jalankan container sebagai user non-root (UID 1001). Default Docker adalah root — tidak aman untuk production.
  7. ENTRYPOINT: command yang dijalankan saat container start. Format exec ["java", "-jar", "app.jar"] lebih aman daripada shell java -jar app.jar karena tidak melibatkan shell parsing.

Multi-stage Build #

Single-stage build menghasilkan image besar karena menyertakan Gradle, JDK, dan source code — semua tidak diperlukan saat runtime. Multi-stage build memecah Dockerfile menjadi stage build (dengan tool lengkap) dan stage runtime (hanya JRE + JAR):

dockerfile Dockerfile multi-stage
# Stage 1: Build
FROM gradle:8.5-jdk21 AS builder
WORKDIR /build
COPY . .
RUN gradle shadowJar --no-daemon

# Stage 2: Runtime (image kecil)
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY --from=builder /build/build/libs/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

COPY --from=builder menyalin artifact dari stage builder ke stage runtime. Image final hanya berisi JRE + JAR — ukuran ~250 MB bukan 1+ GB.

Layer Caching #

Docker menyimpan layer cache. Bila layer tidak berubah, build menggunakan cache dan skip eksekusi. Strategi penting: copy file yang jarang berubah lebih dulu:

dockerfile Layer caching optimal
COPY build.gradle.kts settings.gradle.kts ./
RUN gradle dependencies --no-daemon     # cache deps

COPY src/ ./src/                         # source sering berubah
RUN gradle shadowJar --no-daemon         # rebuild only bila source berubah

Bila src/ berubah, layer COPY src/ baru, tetapi layer gradle dependencies tetap dari cache karena build.gradle.kts tidak berubah. Hemat puluhan detik per build.

Base Image JVM Populer #

Pilihan base image memengaruhi ukuran, keamanan, dan kompatibilitas:

ImageUkuranCocok untuk
eclipse-temurin:21-jdk~400 MBBuild stage
eclipse-temurin:21-jre~250 MBRuntime standar
eclipse-temurin:21-jre-alpine~180 MBImage kecil
amazoncorretto:21-alpine~190 MBAWS ecosystem
gcr.io/distroless/java21-debian12~200 MBNo shell, lebih aman
Custom jlink~80-120 MBCustom runtime minimal

.dockerignore #

File .dockerignore di root proyek mencegah file tidak perlu masuk ke build context:

text .dockerignore
.git
.gradle
build/
*.iml
.idea
node_modules
.env*
README.md

Tanpa .dockerignore, build context bisa berisi ratusan MB file tidak perlu — memperlambat build dan membengkak image.

Referensi Lanjutan #

  • Docker — situs resmi Docker untuk pengenalan container
  • Docker documentation — dokumentasi resmi Docker untuk perintah, image, dan container

Selanjutnya #

Setelah memahami konsep dasar Docker, langkah berikutnya adalah mengemas aplikasi Kotlin spesifik ke dalam container — termasuk JVM tuning di Docker, jlink untuk runtime minimal, dan distroless image. Materi tersebut dibahas pada chapter Containerizing Kotlin Applications.