Setelah memahami konsep dasar Docker, langkah berikutnya adalah mengemas aplikasi Kotlin spesifik. Ada beberapa strategi yang berbeda untuk aplikasi server (Ktor, Spring Boot), CLI tool, dan embedded. Pilihan base image, JVM args, dan strategi multi-stage memengaruhi ukuran image, startup time, dan keamanan. Untuk image produksi, gcr.io/distroless/java21-debian12 adalah pilihan populer karena tidak punya shell. Untuk image minimal (< 100 MB), jlink menghasilkan custom JRE dengan hanya modul yang diperlukan. Pada chapter ini kita akan membedah empat strategi containerization, dari Dockerfile minimal hingga jlink custom runtime, serta menjelaskan JVM args penting untuk container.
Strategi Containerization #
Empat pendekatan dengan trade-off berbeda:
| Strategi | Ukuran | Startup | Cocok untuk |
|---|---|---|---|
| Dockerfile minimal | ~250 MB | ~5 detik | Prototype, development |
| Multi-stage + tuned JVM | ~250 MB | ~3 detik | Server app standar |
| Distroless | ~200 MB | ~3 detik | Produksi (no shell) |
| jlink custom | ~80-120 MB | ~2 detik | CLI, embedded, serverless |
Pilihan tergantung kebutuhan: untuk server standar, multi-stage + tuned JVM cukup. Untuk serverless atau CLI, jlink menghasilkan image paling kecil. Distroless adalah pilihan aman untuk produksi karena tidak ada shell.
Demo Dockerfile Variants #
// containerizing_kotlin_applications.kt — Demo strategi container untuk Kotlin
fun main() {
println("=== Containerizing Kotlin Applications ===")
println()
println("Strategi container untuk Kotlin: JVM args, jlink, distroless.")
println()
// === 1. Dockerfile minimal ===
println("=== 1. Dockerfile Minimal ===")
val minimal = listOf(
"FROM eclipse-temurin:21-jre",
"WORKDIR /app",
"COPY build/libs/app.jar app.jar",
"EXPOSE 8080",
"ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]"
)
minimal.forEach { println(" " + it) }
println()
// === 2. Dengan JVM tuning ===
println("=== 2. Dengan JVM Tuning ===")
val tuned = listOf(
"FROM eclipse-temurin:21-jre",
"WORKDIR /app",
"COPY build/libs/app.jar app.jar",
"ENV JAVA_OPTS=\"-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError\"",
"ENV APP_PORT=8080",
"EXPOSE 8080",
"HEALTHCHECK --interval=10s --timeout=3s --retries=3 \\",
" CMD curl -f http://localhost:8080/health/live || exit 1",
"USER 1001:1001",
"""ENTRYPOINT ["sh", "-c", "java ${'$'}JAVA_OPTS -jar app.jar"]"""
)
tuned.forEach { println(" " + it) }
println()
// === 3. Multi-stage dengan Gradle ===
println("=== 3. Multi-stage dengan Gradle ===")
val multistage = listOf(
"FROM gradle:8.5-jdk21 AS builder",
"WORKDIR /build",
"COPY build.gradle.kts settings.gradle.kts ./",
"RUN gradle --no-daemon dependencies > /dev/null 2>&1 || true",
"COPY src ./src",
"RUN gradle --no-daemon shadowJar",
"",
"FROM eclipse-temurin:21-jre AS runtime",
"WORKDIR /app",
"COPY --from=builder /build/build/libs/app.jar app.jar",
"ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]"
)
multistage.forEach { println(" " + it) }
println()
// === 4. Distroless image ===
println("=== 4. Distroless Image ===")
val distroless = listOf(
"FROM gradle:8.5-jdk21 AS builder",
"WORKDIR /build",
"COPY . .",
"RUN gradle shadowJar --no-daemon",
"",
"FROM gcr.io/distroless/java21-debian12:nonroot",
"WORKDIR /app",
"COPY --from=builder /build/build/libs/app.jar app.jar",
"EXPOSE 8080",
"USER nonroot",
"ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]"
)
distroless.forEach { println(" " + it) }
println()
// === 5. jlink untuk runtime custom ===
println("=== 5. Custom jlink Runtime ===")
val jlink = listOf(
"# Build stage: buat custom JRE dengan jlink",
"FROM eclipse-temurin:21-jdk AS jre-builder",
"RUN jlink \\",
" --add-modules java.base,java.logging,java.sql,java.naming,java.management,java.net.http \\",
" --strip-debug --no-man-pages --no-header-files \\",
" --compress=2 --output /custom-jre",
"",
"# Final stage: Alpine + custom JRE + JAR",
"FROM alpine:3.19",
"COPY --from=jre-builder /custom-jre /opt/jre",
"COPY build/libs/app.jar /app/app.jar",
"ENV PATH=/opt/jre/bin:" + '$' + "PATH",
"ENTRYPOINT [\"java\", \"-jar\", \"/app/app.jar\"]"
)
jlink.forEach { println(" " + it) }
println()
// === 6. Perintah docker ===
println("=== 6. Perintah Docker ===")
val cmds = listOf(
Pair("Build image", "docker build -t my-app:1.0 ."),
Pair("Run dengan port mapping", "docker run -d -p 8080:8080 --name app my-app:1.0"),
Pair("Run dengan env var", "docker run -e APP_PORT=9090 -p 9090:9090 my-app:1.0"),
Pair("Run dengan memory limit", "docker run --memory=512m --cpus=1.0 my-app:1.0"),
Pair("Inspect container", "docker inspect app"),
Pair("Lihat logs", "docker logs -f app"),
Pair("Exec ke container", "docker exec -it app sh")
)
cmds.forEach { (cmd, desc) ->
println(" " + cmd.padEnd(28) + " | " + desc)
}
println()
// === 7. JVM args penting untuk container ===
println("=== 7. JVM Args untuk Container ===")
val jvmArgs = listOf(
Pair("-XX:MaxRAMPercentage=75.0", "Gunakan 75% memory container (bukan host)"),
Pair("-XX:+UseG1GC", "GC default Java 21; cocok container"),
Pair("-XX:+ExitOnOutOfMemoryError", "Exit saat OOM; restart via orchestrator"),
Pair("-XX:+UseContainerSupport", "Deteksi cgroup limit (default Java 10+)"),
Pair("-Djava.security.egd=file:/dev/./urandom", "Seed lebih cepat di container"),
Pair("-XX:InitialRAMPercentage=50.0", "Heap awal 50% memory container")
)
jvmArgs.forEach { (arg, desc) ->
println(" " + arg.padEnd(38) + " | " + desc)
}
println()
// === 8. Aturan praktis ===
println("=== 8. Aturan Praktis ===")
val rules = listOf(
"1. Multi-stage build: image final hanya runtime + JAR",
"2. USER non-root di image final (UID 1001)",
"3. HEALTHCHECK instruksi untuk Docker Compose / Swarm",
"4. -XX:MaxRAMPercentage alih-alih -Xmx hardcode",
"5. Distroless image untuk produksi (no shell, lebih aman)",
"6. jlink untuk image < 100 MB (CLI tool, embedded)",
"7. .dockerignore untuk exclude file build lokal"
)
rules.forEach { println(" " + it) }
}Output yang Dihasilkan:
=== Containerizing Kotlin Applications ===
Strategi container untuk Kotlin: JVM args, jlink, distroless.
=== 1. Dockerfile Minimal ===
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
=== 2. Dengan JVM Tuning ===
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/app.jar app.jar
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError"
ENV APP_PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health/live || exit 1
USER 1001:1001
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
=== 3. Multi-stage dengan Gradle ===
FROM gradle:8.5-jdk21 AS builder
WORKDIR /build
COPY build.gradle.kts settings.gradle.kts ./
RUN gradle --no-daemon dependencies > /dev/null 2>&1 || true
COPY src ./src
RUN gradle --no-daemon shadowJar
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY --from=builder /build/build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
=== 4. Distroless Image ===
FROM gradle:8.5-jdk21 AS builder
WORKDIR /build
COPY . .
RUN gradle shadowJar --no-daemon
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=builder /build/build/libs/app.jar app.jar
EXPOSE 8080
USER nonroot
ENTRYPOINT ["java", "-jar", "app.jar"]
=== 5. Custom jlink Runtime ===
# Build stage: buat custom JRE dengan jlink
FROM eclipse-temurin:21-jdk AS jre-builder
RUN jlink \
--add-modules java.base,java.logging,java.sql,java.naming,java.management,java.net.http \
--strip-debug --no-man-pages --no-header-files \
--compress=2 --output /custom-jre
# Final stage: Alpine + custom JRE + JAR
FROM alpine:3.19
COPY --from=jre-builder /custom-jre /opt/jre
COPY build/libs/app.jar /app/app.jar
ENV PATH=/opt/jre/bin:$PATH
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
=== 6. Perintah Docker ===
Build image | docker build -t my-app:1.0 .
Run dengan port mapping | docker run -d -p 8080:8080 --name app my-app:1.0
Run dengan env var | docker run -e APP_PORT=9090 -p 9090:9090 my-app:1.0
Run dengan memory limit | docker run --memory=512m --cpus=1.0 my-app:1.0
Inspect container | docker inspect app
Lihat logs | docker logs -f app
Exec ke container | docker exec -it app sh
=== 7. JVM Args untuk Container ===
-XX:MaxRAMPercentage=75.0 | Gunakan 75% memory container (bukan host)
-XX:+UseG1GC | GC default Java 21; cocok container
-XX:+ExitOnOutOfMemoryError | Exit saat OOM; restart via orchestrator
-XX:+UseContainerSupport | Deteksi cgroup limit (default Java 10+)
-Djava.security.egd=file:/dev/./urandom | Seed lebih cepat di container
-XX:InitialRAMPercentage=50.0 | Heap awal 50% memory container
=== 8. Aturan Praktis ===
1. Multi-stage build: image final hanya runtime + JAR
2. USER non-root di image final (UID 1001)
3. HEALTHCHECK instruksi untuk Docker Compose / Swarm
4. -XX:MaxRAMPercentage alih-alih -Xmx hardcode
5. Distroless image untuk produksi (no shell, lebih aman)
6. jlink untuk image < 100 MB (CLI tool, embedded)
7. .dockerignore untuk exclude file build lokalMari kita bedah empat strategi di atas:
- Dockerfile Minimal: 5 baris. Image akhir ~250 MB. Cocok untuk development atau prototype, tetapi tanpa tuning JVM dan tanpa HEALTHCHECK — Kubernetes tidak bisa detect deadlock.
- JVM Tuning: tambah
JAVA_OPTSenv denganMaxRAMPercentage=75.0(gunakan 75% memory container, bukan default 25% dari host),UseG1GC(GC default Java 21),ExitOnOutOfMemoryError(exit saat OOM agar orchestrator restart).HEALTHCHECKinstruksi membuat Docker Compose/Swarm cek kesehatan otomatis.USER 1001:1001menjalankan container sebagai non-root. - Multi-stage Gradle: stage builder menggunakan
gradle:8.5-jdk21dengan JDK full. Stage runtime menggunakaneclipse-temurin:21-jre(JRE only, lebih kecil).COPY --from=buildermenyalin artifact. Image final tidak menyertakan Gradle atau source code. - Distroless: gunakan
gcr.io/distroless/java21-debian12:nonroot— image tanpa shell, tanpa package manager. Attacker yang berhasil masuk tidak bisaexecshell. Trade-off: tidak bisadocker exec -it app shuntuk debugging; gunakan ephemeral debug container. - jlink: tool
jlinkmenghasilkan custom JRE hanya dengan modul yang diperlukan (java.base,java.logging,java.sql, dll). Image final ~80-120 MB. Cocok untuk CLI tool atau embedded di mana footprint kritis.
JVM Args untuk Container #
Container memiliki memory limit via cgroup. JVM versi 10+ otomatis mendeteksi limit ini via UseContainerSupport (default aktif). Beberapa argumen penting:
| Argumen | Tujuan | Default |
|---|---|---|
-XX:MaxRAMPercentage=75.0 | Heap = 75% memory container | 25% |
-XX:InitialRAMPercentage=50.0 | Heap awal = 50% | 25% |
-XX:+ExitOnOutOfMemoryError | Exit saat OOM | off |
-XX:+UseContainerSupport | Deteksi cgroup | on (Java 10+) |
-Djava.security.egd=file:/dev/./urandom | Seed RNG cepat | blocking |
Tanpa MaxRAMPercentage, JVM default mengira dia punya akses ke seluruh host memory — heap bisa lebih besar dari limit container, menyebabkan OOM kill oleh kernel.
Build Args untuk Multi-arch #
Untuk image yang berjalan di multi-arch (amd64, arm64), gunakan docker buildx:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t my-registry/app:1.0 \
--push .Buildx menghasilkan manifest list yang merutekan container ke image arch-spesifik. Berguna untuk Apple Silicon (arm64) dan server ARM (AWS Graviton, RPi).
.dockerignore #
.git
.gradle
build/
*.iml
.idea
node_modules
.env*
README.mdTanpa .dockerignore, build context mencakup .git (bisa ratusan MB), build/ (artefak lokal), dan IDE files. docker build mengirim seluruh context ke daemon — context besar memperlambat build.
Referensi Lanjutan #
- Docker — situs resmi Docker untuk containerization aplikasi Kotlin
- Docker documentation
— dokumentasi Docker untuk
Dockerfiledan multi-stage build
Selanjutnya #
Container siap, tetapi bagaimana membangun dan mendistribusikannya secara otomatis? Bab berikutnya membahas CI/CD fundamentals — pipeline lint → test → build → package → deploy — topik pada chapter CI/CD Fundamentals.