Docker and Kotlin: Advanced-Level Application Containerization
Containerization has become an integral part of modern development. If you have already mastered the basic principles of Docker and written a few simple images in Kotlin, it's time to move to the next level. In this article, we will cover advanced techniques: multi-stage builds, optimizing the final image size, working with popular frameworks (Ktor, Spring Boot), and efficient coroutine management in a containerized environment.
1. Multi-stage builds
One of the main problems when containerizing JVM applications is the image size. A base JDK image can weigh hundreds of megabytes. Multi-stage builds allow you to separate the compilation and execution stages, leaving only the JRE and the built JAR in the final image.
Consider an example for a Kotlin application built with Gradle:
# Build stageFROM gradle:7.6-jdk17 AS builderWORKDIR /appCOPY build.gradle.kts settings.gradle.kts ./COPY src ./srcRUN gradle build --no-daemon -x test
# Execution stageFROM eclipse-temurin:17-jre-alpineWORKDIR /appCOPY --from=builder /app/build/libs/*.jar app.jarEXPOSE 8080CMD ["java", "-jar", "app.jar"]Why is this effective?
- The build stage uses the full JDK and Gradle, but they do not end up in the final image.
- The final image contains only the JRE (Java Runtime Environment), reducing the size by 3-5 times.
- Alpine Linux further reduces the size to ~150-200 MB instead of 600+ MB.
2. Layer optimization and caching
Docker caches layers, but only if they haven't changed. To speed up rebuilds, separate dependencies and source code:
FROM gradle:7.6-jdk17 AS builderWORKDIR /app
# First, copy only dependency filesCOPY build.gradle.kts settings.gradle.kts ./RUN gradle dependencies --no-daemon
# Then source code (changes more often)COPY src ./srcRUN gradle build --no-daemon -x testThis approach ensures that the dependency layer is rebuilt only when build.gradle.kts changes. Otherwise, Docker uses the cache, saving minutes on each iteration.
Tip: For Kotlin projects with many dependencies (e.g., Spring Boot), this technique is especially relevant — loading the Gradle cache can take up to 2-3 minutes.
3. Containerizing Ktor applications
Ktor is a lightweight asynchronous framework in Kotlin, ideal for microservices. Containerizing a Ktor application requires minimal configuration, but there are nuances related to coroutines.
Example Dockerfile for Ktor:
FROM gradle:7.6-jdk17 AS builderWORKDIR /appCOPY build.gradle.kts settings.gradle.kts ./RUN gradle dependencies --no-daemonCOPY src ./srcRUN gradle build --no-daemon -x test
FROM eclipse-temurin:17-jre-alpineWORKDIR /appCOPY --from=builder /app/build/libs/ktor-app-*.jar app.jarEXPOSE 8080
# Important: JVM configuration for coroutinesCMD ["java", "-XX:+UseZGC", "-Xms256m", "-Xmx512m", "-jar", "app.jar"]Key points:
- Use ZGC (Z Garbage Collector) — it minimizes pauses, which is critical for asynchronous applications with a large number of coroutines.
- Limit memory via
-Xmsand-Xmx. In containers, this helps avoid OOM errors. - Ensure the Ktor server does not block the main thread — coroutines should be configured via
Dispatchers.IOor a custom pool.
Example of configuring Ktor to work in a container:
fun main() { embeddedServer(Netty, port = 8080, host = "0.0.0.0") { routing { get("/") {