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:

StrategiUkuranStartupCocok untuk
Dockerfile minimal~250 MB~5 detikPrototype, development
Multi-stage + tuned JVM~250 MB~3 detikServer app standar
Distroless~200 MB~3 detikProduksi (no shell)
jlink custom~80-120 MB~2 detikCLI, 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 #

kotlin
// 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:

text
=== 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 lokal

Mari kita bedah empat strategi di atas:

  1. 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.
  2. JVM Tuning: tambah JAVA_OPTS env dengan MaxRAMPercentage=75.0 (gunakan 75% memory container, bukan default 25% dari host), UseG1GC (GC default Java 21), ExitOnOutOfMemoryError (exit saat OOM agar orchestrator restart). HEALTHCHECK instruksi membuat Docker Compose/Swarm cek kesehatan otomatis. USER 1001:1001 menjalankan container sebagai non-root.
  3. Multi-stage Gradle: stage builder menggunakan gradle:8.5-jdk21 dengan JDK full. Stage runtime menggunakan eclipse-temurin:21-jre (JRE only, lebih kecil). COPY --from=builder menyalin artifact. Image final tidak menyertakan Gradle atau source code.
  4. Distroless: gunakan gcr.io/distroless/java21-debian12:nonroot — image tanpa shell, tanpa package manager. Attacker yang berhasil masuk tidak bisa exec shell. Trade-off: tidak bisa docker exec -it app sh untuk debugging; gunakan ephemeral debug container.
  5. jlink: tool jlink menghasilkan 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:

ArgumenTujuanDefault
-XX:MaxRAMPercentage=75.0Heap = 75% memory container25%
-XX:InitialRAMPercentage=50.0Heap awal = 50%25%
-XX:+ExitOnOutOfMemoryErrorExit saat OOMoff
-XX:+UseContainerSupportDeteksi cgroupon (Java 10+)
-Djava.security.egd=file:/dev/./urandomSeed RNG cepatblocking

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:

bash Multi-arch build
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 #

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

Tanpa .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 Dockerfile dan 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.