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:
| Konsep | Deskripsi | Contoh |
|---|---|---|
| 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 |
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 #
// 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:
=== 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:
- FROM: base image.
eclipse-temurin:21-jremenyediakan JRE 21 di atas Ubuntu. ASbasememberi nama stage untuk multi-stage build. - WORKDIR: direktori kerja. Setara dengan
cd /app && ...di setiap instruksi berikutnya. - COPY: menyalin file dari build context (host) ke image.
COPY build/libs/app.jar app.jarmenyalin JAR hasil build ke/app/app.jar. - EXPOSE: dokumentasi port yang didengar container. Tidak publish port ke host — itu butuh flag
-pdidocker run. - ENV: set env var.
JAVA_OPTS="-Xmx512m"bisa dipakai di ENTRYPOINT. - USER: jalankan container sebagai user non-root (UID 1001). Default Docker adalah root — tidak aman untuk production.
- ENTRYPOINT: command yang dijalankan saat container start. Format exec
["java", "-jar", "app.jar"]lebih aman daripada shelljava -jar app.jarkarena 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):
# 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:
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 berubahBila 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:
| Image | Ukuran | Cocok untuk |
|---|---|---|
eclipse-temurin:21-jdk | ~400 MB | Build stage |
eclipse-temurin:21-jre | ~250 MB | Runtime standar |
eclipse-temurin:21-jre-alpine | ~180 MB | Image kecil |
amazoncorretto:21-alpine | ~190 MB | AWS ecosystem |
gcr.io/distroless/java21-debian12 | ~200 MB | No shell, lebih aman |
Custom jlink | ~80-120 MB | Custom runtime minimal |
.dockerignore #
File .dockerignore di root proyek mencegah file tidak perlu masuk ke build context:
.git
.gradle
build/
*.iml
.idea
node_modules
.env*
README.mdTanpa .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.