Containerizing Java / Kotlin Applications: A Dual Approach with Dockerfile and Jib
As software architecture evolved and more applications embraced microservices architecture, the challenge of developing, deploying, and maintaining them quickly and consistently grew. Docker emerged as a game-changer, streamlining the development process and making it easier to build, deploy, and scale applications in a microservices-driven world.
In this post, I want to highlight how to dockerize a Spring Boot application using both approaches via Dockerfile and Jib, also explaining their pros and cons. Also, we run our containers at the end.
As software architecture evolved and more applications embraced microservices architecture, the challenge of developing, deploying, and maintaining them quickly and consistently grew. Docker emerged as a game-changer, streamlining the development process and making it easier to build, deploy, and scale applications in a microservices-driven world.
Docker emerged as a game-changer for several key reasons:
- It simplifies development and deployment processes, making it easier to build, package, and distribute applications.
- Docker enables the launching of multiple components in separate containers, allowing for faster local development, debugging, and testing.
- It eliminates the need to install complex software on local machines; with just a few commands, developers can have a ready-to-use application running in a separate, isolated environment.
- It eliminates the need to install complex software or multiple versions of programming languages and dependencies on local machines. With just a few commands, developers can have a ready-to-use application running in a separate, isolated environment, effectively avoiding the notorious dependency hell and version conflicts.
- No more “it works on my machine” problem.
- The emergence of Testcontainers has further enhanced the testing process, allowing developers to seamlessly integrate Docker containers into their test environments for more accurate and efficient testing.
The Application to Dockerize
The application we’ll be Dockerizing is a RESTful API written in Kotlin using Spring Boot and Gradle build tool. There’s no difference in Dockerizing a Spring Boot application written in Kotlin versus Java, so this tutorial is equally suitable for developers familiar with either language.
Application Architecture
The application runs on a configurable port (default: 8080
) and handles client requests via HTTP. It provides the following endpoints:
GET /users
: Retrieves all users.GET /users/{id}
: Retrieves a user by the given ID.POST /users
: Adds a new user to the database.
In addition, the application uses Flyway for database migration, ensuring the schema is validated and managed automatically, and PostgreSQL as a database storage .
You can find the source code via the link.
Application Demo
GET /users
— returns a list of users
GET /users/{id}
— returns a specific user by id
POST /users
— add a new user to the database
Why do we need Docker actually?
Well, let’s say that we have finished our application and we are ready to share with our colleagues so that they can also check, use or test. What developers used to do before the emergence of Docker?
First of all, to run the application, a developer needs to install the programming language and its dependencies. Even if a developer already has the programming language installed, there’s no guarantee that the application will run correctly. This can happen for several reasons:
- Version Conflicts: The application may rely on specific versions of dependencies that differ from those installed.
- Dependency Variability: The application might use different versions of libraries or frameworks than what is currently available in the developer’s environment.
- Operating System Differences: Variations in operating systems can lead to incompatibilities, as certain features or behaviors may differ across platforms.
All these issues lead to problem known as “It work on my machine” — common frustration in software development. It happens when when the app is deployed in different environments.
Docker solves this issue by providing isolated environments that include the underlying operating system, as well as specified versions of programming languages and dependencies.
Dockerizing Application
To dockerize applications written in Java or Kotlin, we can use two approaches — Dockerfile or Jib. We will explore both approaches and discuss what is a better option.
Dockerizing refers to building an image of the application. When we speak about the containers, you have to know the difference between an image and container.
Docker image — is a lightweight, standalone, executable package that includes everything needed for an application to run. This encompasses the underlying operating system, the application source code, libraries, and all dependencies required for the application to function.
Docker Container — is a running instance of a Docker image. It is an isolated environment where the application executes, sharing the host system’s kernel while maintaining its own filesystem, processes, and network configurations.
Dockerfile
To run our application, we first need an executable file; in Java, this is a .jar
file. To generate execute the following command in the root folder — ./gradlew clean build
The generated .jar
file resides in the build/libs
directory of the project. This JAR file is executable and contains all the necessary classes and dependencies required to run the application. We can run the application from the terminal by executing the following command:
java -jar dockerize-user-service-0.0.1-SNAPSHOT.jar
Here, dockerize-user-service-0.0.1-SNAPSHOT.jar
is the name of the executable JAR file.
When the executable file is ready, we can write Dockerfile. Dockerfile generates the image of the application.
To generate Docker images using Dockerfile, you need a Docker daemon (docker server).
# Use a base image that has OpenJDK 19 installed
FROM openjdk:19-jdk-slim
# Set the working directory inside the container
WORKDIR /app
# Copy the jar file to the container
COPY build/libs/dockerize-user-service-0.0.1-SNAPSHOT.jar app.jar
# Expose the port your Spring Boot app runs on
EXPOSE 8080
# Specify the command to run the jar file
ENTRYPOINT ["java", "-jar", "app.jar"]
Explanation of Dockerfile:
- FROM openjdk:19-jdk-slim — it specifies the base image for the container and slim version of the OpenJDK 19 image, which is lightweight. It uses a minimal version of Debian Linux as its underlying operating system
- WORKDIR /app — This sets the working directory inside the container /app. All the subsequent commands will be executed inside this directory
- COPY build/libs/dockerize-user-service-0.0.1-SNAPSHOT.jar app.jar — copies the executable file from the local machine into the container and sets its name to app.jar
- EXPOSE 8080 — it specifies which port the container listens to, this is usually the port that the application listens.
- ENTRYPOINT [“java”, “-jar”, “app.jar”] — it specifies that this command is executed when the container starts. This command starts the application.
Creating Image of the Application
To create an image of our application, we have to execute the following command.
docker build -t user-service:1.0.0 .
Since the last argument — “.”, the command executes in the current directory and the directory should contain Dockerfile, otherwise you need to specify the path to the Dockerfile.
The command will generate an image with name=user-service and tag=1.0.0
To list all images, execute:
docker images
Now, we can run this image across different environments, share with colleagues and publish to registries to download remotely.
Starting Docker Container
To run the Docker image, execute the following command:
# hostPort:ContainerPort #imageName : #imageTag
docker run -p 8080:8080 user-service:1.0.0
Since our application needs to communicate with the database, we should pass an environment variable to specify the database host. In this case, we use: DB_HOST=database
.
The DB_HOST
is set to database
, which is the name of the PostgreSQL container, allowing the application to locate and connect to it.
Additionally, I’ve specified a network parameter in the Docker configuration to enable communication between containers. This is necessary in this case because the PostgreSQL instance runs inside a container. For containers to communicate with each other, they must be part of the same Docker network. By ensuring that both the Spring Boot application and PostgreSQL container are on the same network, they can resolve each other by container names and communicate seamlessly.
docker run -e DB_HOST=database -p 8080:8080 --network user-service-network user-service:1.0.0
We have successfully dockerized our application that can be run across different environments ✅.
Dockerfile Overview
Pros:
- Full control of defining Dockerfile, defining every aspect of the image, dependencies, env variables and commands.
- Familiarity — adopted by many developers and widely supported by many CI/CD platforms
- Dockerfiles support multi-staged builds, meaning combining multiple builds to create smaller final images
Cons:
- Complexity — writing and maintaining Dockerfiles is complex
- Requires the running instance of Docker daemon, that executes instructions to create images. In some cases, you wish to create Docker images without the need to have a running Docker server.
Jib
Jib is a set of plugins for Maven and Gradle for building optimized OCI-compliant container images for Java applications without a Docker daemon.
For Java and Kotlin applications using Maven or Gradle, containerization is straightforward as it can be seamlessly integrated into the build process. This allows developers to create and manage Docker images without needing to handle separate Docker configurations manually.
To do so, we need to add Jib plugin into our build.gradle.kts
id("com.google.cloud.tools.jib") version "3.4.4"
Plugin section in the build.gradle.kts
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.3.4"
id("io.spring.dependency-management") version "1.1.6"
kotlin("plugin.jpa") version "1.9.25"
id("com.google.cloud.tools.jib") version "3.4.4"
}
Jib Configuration
jib {
from {
image = "openjdk:19-jdk-slim"
platforms {
platform {
architecture= "amd64"
os = "linux"
}
platform {
architecture = "arm64"
os = "linux"
}
}
}
to {
image = "baggio1103/user-service-app"
tags = setOf("latest", "$version")
auth {
username = System.getenv("DOCKER_USERNAME")
password = System.getenv("DOCKER_PASSWORD")
}
}
container {
// Set the main class for your application
mainClass = "com.atomic.coding.UserApplicationKt"
ports = listOf("8080")
environment = mapOf(
"SOME_KEY" to "some_value",
"KEY" to "Value"
)
jvmFlags = listOf("-Xms512m", "-Xmx1024m") // JVM options for the container
creationTime = "USE_CURRENT_TIMESTAMP"
extraClasspath
}
}
Jib Configuration Explanation
from {
image = "openjdk:19-jdk-slim"
platforms {
platform {
architecture= "amd64"
os = "linux"
}
platform {
architecture = "arm64"
os = "linux"
}
}
}
This block is responsible for base image for the container:
- image = “openjdk:19-jdk-slim" — specifies the slimmed version of JDK and operating system.
- platforms {
platform { architecture = “arm64”, os = “linux” }
} — this block enables building architecture specific images. We can specify architecture and operating system. It provides cross-platform compatibility — ability to deploy across different environments and architecture requirements.
One of the advantages of Jib that it allows to configure building images targeting different architectures to achieve higher optimization.
Also, this block is optional, but it is preferred to specify this block to avoid different issues related to operating system and architecture of the hardware.
Important to note, I bumped into strange issues while running the container without specifying the platform { } block, e.g. the container could not locate the required dependencies, or .jar files. As best practice, it is highly recommended to specify the platform.
I spent several hours trying to figure out why I faced some weird issues 😅. All of them arose due to the absence of platform specicifaction.
to {
// image = "docker.io/my-dockerhub-username/my-app:tag"
image = "baggio1103/user-service-app"
tags = setOf("latest", "$version")
auth {
username = System.getenv("DOCKER_USERNAME")
password = System.getenv("DOCKER_PASSWORD")
}
}
This block specifies image details:
- image = “baggio1103/user-service-app” — it specifies the name for the image. baggio1103 — is the username of the registry where the image is pushed. By default the image is pushed to Dockerhub — docker.io.
- tags = setOf(“latest”, “$version”) — used for tagging the image, you can specify several tags for the image.
auth {
username = System.getenv("DOCKER_USERNAME")
password = System.getenv("DOCKER_PASSWORD")
}
This section is used to specify the authentication details to login into the registry where the image is pushed. By default, the image is pushed into the Docker Hub.
container {
// Set the main class for your application
mainClass = "com.atomic.coding.UserApplicationKt"
ports = listOf("8080")
environment = mapOf(
"SPRING_PROFILES_ACTIVE" to "prod",
"KEY" to "Value"
)
jvmFlags = listOf("-Xms512m", "-Xmx1024m") // JVM options for the container
creationTime = "USE_CURRENT_TIMESTAMP"
}
This section describes the container details of the image — such as ports, env variables, jvmFlags, etc:
- mainClass = “com.atomic.coding.UserApplication” — sets the main for the application. This property is optional, it is better to omit this mainClass specification and let Jib detect it automatically.
- creationTime = “USE_CURRENT_TIMESTAMP” — is used to specify the image creation time.
With this setup, we are ready to go and build our image using Jib.
No longer need to generate the executable .jar file, write Dockerfile manually. All we need to is just to execute a gradle command.
./gradlew jibDockerBuild
Listing images
As we can see, there is an Docker image with name and tags specified in the Jib config.
Starting Docker Container
Now we are ready to launch our app in the container. Similar to previous section, we execute the same commands.
docker run -e DB_HOST=database -p 8080:8080 --network user-service-network baggio1103/user-service-app:1.0.0
Testing the application
To test the application, we can simply execute an Http Get Request via curl to see the response.
curl localhost:8080/users
Jib Overview
Pros:
- Simplicity — no Dockerfile needed, it uses other instructions from build.gradle.kts to create images
- Optimized Builds — it uses layering based on the projects classes, dependencies and resources, thus results in smaller image sizes.
- Default Best Practices — jib follows best practices for container images out of the box, such as using non-root users and minimizing image sizes.
- Versioning — jib automatically tags images based on the project metadata, making it easier to manage versions.
Cons:
- Java / Kotlin Specific — if you have non Java / Kotlin application, then Jib is not applicable, it is primarily designed create Docker Images for Java Applications.
- Learing Curve — altough it simplifies containerization, jib configuration is harder in the beginning for developers, thus configuring will take time until a developer feels confident with it.
- Less Customizable — It is great for Java / Kotlin applications, but it is less suitable for cases when it is necessary for specific configurations.
This was the topic I wanted to share for a long time, I have done it at last 🚀.
In this post, we explored the advantages of Dockerizing applications using both Dockerfiles and Jib, emphasizing the flexibility and simplicity each method offers. Now that you understand these benefits, I encourage you to try Dockerizing your own application.
What has been your experience with containerization? Do you prefer using Dockerfiles or Jib? Drop your comments below!