Welcome to Chapter 17! In this pivotal chapter, we’re going to take our previously built Java application – specifically, let’s use the Word Counter application as our example – and containerize it using Docker. Containerization is a fundamental practice in modern software development, allowing us to package our application and all its dependencies into a single, isolated unit called a container. This ensures that our application runs consistently across different environments, from a developer’s machine to production servers.
The primary goal of this chapter is to create a Docker image for our Java application and then run it as a Docker container. We’ll explore why containerization is crucial for portability, scalability, and simplifying deployment. By the end of this chapter, you’ll have a production-ready Dockerfile, understand multi-stage builds, and be able to build and run your Java application inside a Docker container, laying the groundwork for more advanced deployment strategies.
Prerequisites:
Before we begin, ensure you have:
- A working Java application from a previous chapter, preferably the Word Counter application, packaged as an executable JAR file. We’ll assume the JAR is named
word-counter-app.jar. - Docker Desktop (or Docker Engine) installed and running on your system. You can download it from the official Docker website.
- Basic familiarity with the command line.
Planning & Design
Containerizing our application involves creating a Dockerfile, which is a script containing instructions for Docker to build an image. For Java applications, a robust Dockerfile typically employs a multi-stage build. This approach separates the build environment (where we compile and package our code) from the runtime environment (where the application actually runs). This significantly reduces the final image size and improves security by only including necessary components.
Key Design Considerations for our Dockerfile:
- Base Image Selection: We’ll choose a lean, secure Java Runtime Environment (JRE) image for the final stage to minimize size and attack surface. For the build stage, a Java Development Kit (JDK) image is necessary.
- Multi-Stage Build:
- Build Stage: Uses a JDK image to compile the Java source code and create the executable JAR.
- Runtime Stage: Uses a JRE image to run the previously built JAR.
- Application Structure within the Container: We’ll define a working directory and copy only the necessary JAR file into it.
- Entrypoint: Clearly define how the application starts when the container is run.
- Security: Run the application as a non-root user inside the container.
- Optimization: Tune JVM memory settings for the container environment.
Step-by-Step Implementation
Let’s start by preparing our application and then creating the Dockerfile.
a) Setup/Configuration: Prepare the Java Application and Project Structure
First, ensure your Word Counter application is built and produces an executable JAR file. For a typical Maven project, you would run mvn clean package. For Gradle, it would be gradle clean build.
Assuming your project structure looks something like this:
word-counter-app/
├── src/
│ └── main/
│ └── java/
│ └── com/
│ └── example/
│ └── wordcounter/
│ └── WordCounterApp.java
├── pom.xml (if Maven) or build.gradle (if Gradle)
└── target/ (after build)
└── word-counter-app.jar
Navigate to the root of your word-counter-app project. We’ll create two new files here: .dockerignore and Dockerfile.
1. Create .dockerignore
The .dockerignore file works similarly to .gitignore. It tells Docker which files and directories to ignore when building the image, preventing unnecessary files from being copied into the build context. This helps keep the build context small and speeds up image builds.
Create a file named .dockerignore in the root of your word-counter-app directory:
word-counter-app/.dockerignore
.git
.gitignore
.mvn
.gradle
.idea
target/
build/
*.iml
Dockerfile
docker-compose.yml
README.md
LICENSE
.DS_Store
**/*.log
tmp/
Explanation:
target/andbuild/: These directories often contain compiled classes and temporary files that are not needed for the Docker build stage itself (as we’ll compile inside the build stage) or for the final runtime image..git,.gitignore, etc.: Version control and IDE-specific files are irrelevant to the Docker image.Dockerfile,docker-compose.yml: These are build instructions, not part of the application itself.**/*.log,tmp/: Prevent any local log files or temporary data from being included.
b) Core Implementation: Create the Dockerfile
Now, let’s create the Dockerfile in the root of your word-counter-app directory. This file will contain all the instructions for Docker to build our application’s image.
word-counter-app/Dockerfile
# Stage 1: Build the application
FROM eclipse-temurin:24-jdk-jammy AS builder
# Set the working directory inside the container
WORKDIR /app
# Copy the Maven/Gradle project files
# We copy pom.xml/build.gradle separately to leverage Docker cache
# If only source files change, this layer won't be rebuilt.
COPY pom.xml .
COPY src ./src
# Build the application
# Use -Dmaven.test.skip=true to skip tests during Docker build for faster image creation
# For production builds, you might want to run tests here.
RUN --mount=type=cache,target=/root/.m2 mvn clean package -Dmaven.test.skip=true
# Stage 2: Create the runtime image
# Use a lean JRE image for the final application
FROM eclipse-temurin:24-jre-jammy
# Set argument for the application JAR name
ARG JAR_FILE=word-counter-app.jar
# Create a non-root user and group
# This is a security best practice to avoid running as root inside the container
RUN groupadd --system appuser && useradd --system --gid appuser appuser
USER appuser
# Set the working directory for the runtime
WORKDIR /app
# Copy the executable JAR from the builder stage
# We assume the JAR is in target/word-counter-app.jar if using Maven
# Adjust path if using Gradle (e.g., build/libs/word-counter-app.jar)
COPY --from=builder /app/target/${JAR_FILE} ./app.jar
# Expose any ports if it's a web application (e.g., EXPOSE 8080)
# Our Word Counter is a console app, so no ports are exposed.
# Define JVM options for production.
# -XX:+ExitOnOutOfMemoryError: Ensures the container exits if OOM occurs.
# -XX:+HeapDumpOnOutOfMemoryError: Generates a heap dump for debugging.
# -Djava.security.egd=file:/dev/urandom: Improves startup performance for applications
# that use SecureRandom, by using a non-blocking source of entropy.
# -Xms and -Xmx: Set initial and maximum heap size. Adjust based on your app's needs.
# For a simple app, 128m-256m is often sufficient.
ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -Djava.security.egd=file:/dev/urandom"
# Command to run the application
# Use 'exec' form for ENTRYPOINT to ensure proper signal handling (e.g., SIGTERM)
# This allows the JVM to gracefully shut down when Docker stops the container.
ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "app.jar"]
# For the Word Counter, you might want to pass input.
# If it reads from stdin, you'd run like: echo "hello world" | docker run <image>
# If it expects an argument (e.g., a file path), you'd define CMD:
# CMD ["input.txt"]
# Then ENTRYPOINT would be ["java", "$JAVA_OPTS", "-jar", "app.jar"]
# And the run command would be: docker run <image> input.txt
# For simplicity, we'll assume it processes input via stdin or has internal logic.
Explanation of the Dockerfile:
FROM eclipse-temurin:24-jdk-jammy AS builder:- Starts the first stage, named
builder, using theeclipse-temurinOpenJDK distribution. 24-jdk-jammyspecifies Java 24 JDK based on Ubuntu 22.04 LTS (Jammy Jellyfish). This is the latest stable version as of December 2025.- The
JDKis needed for compiling our Java code.
- Starts the first stage, named
WORKDIR /app: Sets/appas the current working directory for subsequent instructions.COPY pom.xml .andCOPY src ./src: Copies the Mavenpom.xmland thesrcdirectory into the container. Copyingpom.xmlseparately allows Docker to cache this layer. If only source code changes, Maven dependencies won’t be re-downloaded.RUN --mount=type=cache,target=/root/.m2 mvn clean package -Dmaven.test.skip=true:- Executes the Maven build command.
--mount=type=cache,target=/root/.m2: This is a BuildKit feature that caches the Maven local repository (.m2) between builds, significantly speeding up subsequent builds.clean package: Cleans the target directory and packages the application into a JAR.-Dmaven.test.skip=true: Skips running tests during the Docker build. While convenient for faster builds, for production-grade CI/CD, you might want to run tests in a separate stage or as part of your CI pipeline.
FROM eclipse-temurin:24-jre-jammy:- Starts the second, final stage. This time, we use
24-jre-jammy, which is the Java 24 Runtime Environment. It’s much smaller than the JDK and only contains what’s needed to run the application, improving security and reducing image size.
- Starts the second, final stage. This time, we use
ARG JAR_FILE=word-counter-app.jar: Defines a build-time argument for the JAR file name. This makes the Dockerfile more flexible.RUN groupadd --system appuser && useradd --system --gid appuser appuser:- Security Best Practice: Creates a dedicated non-root user and group (
appuser) within the container. Running applications as root in containers is a security risk. --system: Creates a system group/user, typically with lower UIDs/GIDs.
- Security Best Practice: Creates a dedicated non-root user and group (
USER appuser: Switches the user toappuserfor all subsequent instructions and for running the application.WORKDIR /app: Sets the working directory for the runtime stage.COPY --from=builder /app/target/${JAR_FILE} ./app.jar:- This is the core of the multi-stage build. It copies the
JAR_FILEfrom thebuilderstage’s/app/target/directory to the current stage’s/app/directory, renaming it toapp.jar. - Only the necessary artifact is copied, leaving behind all build tools and source code.
- This is the core of the multi-stage build. It copies the
ENV JAVA_OPTS="...": Sets environment variables for JVM options. These are critical for performance and stability in a containerized environment.-Xms128m -Xmx256m: Sets initial and maximum heap size. These values should be tuned based on your application’s actual memory requirements. Starting with a conservative range is good.-XX:+ExitOnOutOfMemoryError: Ensures the JVM exits immediately if anOutOfMemoryErroroccurs, allowing container orchestration systems to restart the container.-XX:+HeapDumpOnOutOfMemoryError: Generates a heap dump file in case of OOM, which is invaluable for post-mortem debugging.-Djava.security.egd=file:/dev/urandom: A common optimization for Java applications that useSecureRandom, preventing blocking on/dev/randomduring startup.
ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "app.jar"]:- Defines the command that will be executed when the container starts.
- The
execform (using JSON array) is preferred as it allows the JVM to receive signals (likeSIGTERM) from Docker, enabling graceful shutdowns. $JAVA_OPTSis expanded by the shell (which is invoked internally by theENTRYPOINTif it’s not theexecform, but here it’s implicitly handled by the container runtime expanding ENV vars before passing tojava).
c) Testing This Component: Build and Run the Docker Image
Now that we have our Dockerfile, let’s build the Docker image and run our application in a container.
1. Build the Docker Image
Open your terminal, navigate to the word-counter-app directory (where your Dockerfile and pom.xml/build.gradle are located), and execute the following command:
docker build -t word-counter-app:1.0.0 .
Explanation:
docker build: The command to build a Docker image.-t word-counter-app:1.0.0: Tags the image with a name (word-counter-app) and a version (1.0.0). It’s good practice to tag images with meaningful versions..: Specifies the build context – the current directory, which contains ourDockerfileand application source.
You should see output indicating Docker pulling base images, running the build stage (compiling, packaging), and then creating the final runtime image. This process might take a few minutes the first time.
2. Run the Docker Container
Once the image is built, you can run a container from it:
docker run --rm -it word-counter-app:1.0.0
Explanation:
docker run: The command to run a Docker container.--rm: Automatically removes the container and its file system when the container exits. This keeps your system clean.-it: Allocates a pseudo-TTY and keeps stdin open, allowing you to interact with the container (e.g., type input for the Word Counter app if it reads from stdin).word-counter-app:1.0.0: The name and tag of the image we just built.
If your Word Counter app reads from standard input, you should now be able to type text into your terminal, press Enter, and then Ctrl+D (to signal EOF) to see the word count output.
Example interaction:
docker run --rm -it word-counter-app:1.0.0
# You will now be inside the container's stdin. Type your text:
Hello world, this is a test.
Another line for the test.
# Press Ctrl+D to signal End-Of-File
# Output from your Word Counter app (example):
Total words: 11
If your application simply runs and exits (e.g., processes a predefined task), it will print its output and then the container will stop and be removed (--rm).
Debugging Tips:
- Image Build Errors: If
docker buildfails, carefully read the output. It usually points to the exact line in theDockerfileor the Maven/Gradle command that failed. - Container Runtime Errors: If
docker runfails or the container exits unexpectedly, checkdocker logs <container_id_or_name>(if you didn’t use--rm). For--rmcontainers, the output is directly in your terminal. Look for Java exceptions or application-specific error messages. - Missing Files: Ensure
COPYcommands in yourDockerfilecorrectly reference paths relative to the build context.
Production Considerations
Containerization is a powerful step towards production, but it comes with its own set of considerations.
Error Handling
- Application-level Errors: Java exceptions within your application will be printed to
stdoutorstderrinside the container. Docker captures these as container logs. Ensure your application’s logging framework is configured to output to console. - Container Exits: If your application crashes (e.g.,
OutOfMemoryError), the container will exit. TheENTRYPOINT’sexecform andJAVA_OPTSlike-XX:+ExitOnOutOfMemoryErrorare crucial here, allowing Docker to detect the crash and orchestration systems (like Kubernetes) to restart the container.
Performance Optimization
- JVM Memory Tuning: The
JAVA_OPTSwe added (-Xms,-Xmx) are vital. Java’s JVM traditionally tries to use a large percentage of available system memory. In a container, this can lead to the JVM requesting more memory than the container is allocated, causing anOutOfMemoryErroror the container being killed by the host OS (OOM Killer). Explicitly setting heap sizes (-Xmx) prevents this. - Smallest Base Image: Using
eclipse-temurin:24-jre-jammyinstead of24-jdk-jammyfor the runtime image significantly reduces its size, leading to faster pulls, less disk usage, and a smaller attack surface. Consider even smaller base images likeeclipse-temurin:24-jre-alpineif you need extreme size reduction, but be aware of potential glibc/musl libc compatibility issues with native libraries. - CPU Limits: Be aware that Docker and orchestrators can limit CPU. The JVM needs to be aware of these limits. Modern JVMs (Java 10+) are generally container-aware and respect CPU and memory limits set by the container runtime.
Security Considerations
- Non-Root User: We implemented
USER appuserin our Dockerfile. This is a critical security practice, as it prevents potential attackers from gaining root access to the host system if they manage to compromise the running application within the container. - Minimize Image Size: Smaller images mean fewer components, which translates to fewer potential vulnerabilities. Multi-stage builds are key here.
- Scan for Vulnerabilities: Use tools like
docker scan(built-in with Docker Desktop, powered by Snyk) or other vulnerability scanners (e.g., Trivy, Clair) to regularly check your base images and final application images for known security flaws. - Avoid Sensitive Information: Never hardcode secrets (API keys, database passwords) directly into your
Dockerfileor commit them to your image. Use environment variables, Docker Secrets, or Kubernetes Secrets for handling sensitive data at runtime. .dockerignore: Properly configured.dockerignoreprevents accidental inclusion of sensitive files or unnecessary build artifacts.
Logging and Monitoring
- Standard Output (stdout/stderr): In containerized environments, the best practice for logging is to write all application logs to
stdoutandstderr. Docker’s logging drivers then capture these streams. - Structured Logging: Configure your Java logging framework (e.g., Logback, Log4j2) to output logs in a structured format like JSON. This makes it much easier for external log aggregation systems (like ELK Stack, Splunk, Datadog) to parse, filter, and analyze your application logs.
- External Monitoring: For production, integrate your containers with a robust monitoring solution that can collect metrics (CPU, memory, network I/O) and logs from your Docker containers and host.
Code Review Checkpoint
At this point, you should have:
- A
word-counter-appdirectory containing your Java project. - A
.dockerignorefile at the root, excluding unnecessary files from the Docker build context. - A
Dockerfileat the root, implementing a multi-stage build:- A
builderstage for compiling the Java application into an executable JAR usingeclipse-temurin:24-jdk-jammy. - A final runtime stage for running the JAR using a lean
eclipse-temurin:24-jre-jammyimage. - Instructions to create and run the application as a non-root user (
appuser). - JVM tuning options (
JAVA_OPTS) for memory management and graceful exit. - An
ENTRYPOINTcommand to execute the JAR.
- A
This setup ensures our Java application is packaged efficiently, securely, and ready for consistent deployment across various environments.
Common Issues & Solutions
Issue:
docker buildfails with “No such file or directory” duringCOPY- Cause: The path specified in your
COPYinstruction does not exist relative to the build context (the directory where you randocker build). - Solution: Double-check the file paths. For example, if your
pom.xmlis in the root,COPY pom.xml .is correct. If your JAR is intarget/word-counter-app.jar, ensure theCOPY --from=builder /app/target/${JAR_FILE} ./app.jarpath is correct. Verify the casing and exact file names. - Prevention: Always run
docker buildfrom the root of your project where theDockerfileis located. Usels -Fordirto confirm file paths before writingCOPYcommands.
- Cause: The path specified in your
Issue: Container exits immediately after
docker runwith “Error: Unable to access jarfile app.jar”- Cause: The
java -jar app.jarcommand cannot find theapp.jarfile inside the container. This usually means theCOPYinstruction from the builder stage failed or copied the JAR to the wrong location, or theWORKDIRis incorrect. - Solution:
- Inspect the image layers:
docker history word-counter-app:1.0.0to see if theCOPYcommand forapp.jarwas successful. - Temporarily change the
ENTRYPOINTtols -l /appand run the container to see the contents of the/appdirectory. This will confirm ifapp.jaris present. - Verify the
JAR_FILEargument matches your actual JAR name. - Ensure the
target/orbuild/libs/path in theCOPY --from=builderinstruction is correct for your build tool (Maven/Gradle).
- Inspect the image layers:
- Prevention: Test each stage incrementally during Dockerfile development. Use
RUN ls -lcommands in temporary stages to verify file presence.
- Cause: The
Issue: Container runs but application throws
OutOfMemoryErroror is killed by the host.- Cause: The JVM inside the container is trying to allocate more memory than the container is allowed by Docker or the host system.
- Solution:
- Tune
JAVA_OPTS: Adjust-Xmsand-Xmxvalues inENV JAVA_OPTSin yourDockerfile. Start with conservative values and increase them based on actual application load testing. - Docker Memory Limits: When running the container, explicitly set Docker’s memory limits:
docker run --memory="512m" --memory-swap="1g" word-counter-app:1.0.0. Ensure your-Xmxis less than or equal to Docker’s--memorylimit. - Heap Dump Analysis: If you included
-XX:+HeapDumpOnOutOfMemoryError, locate the heap dump file (it will be in the container’sWORKDIRor specified path) and analyze it using tools like Eclipse Memory Analyzer to understand memory usage patterns.
- Tune
- Prevention: Profile your application’s memory usage locally before containerizing. Always set
XmxinJAVA_OPTSfor production containers.
Testing & Verification
Let’s perform a final verification of our containerized application.
Clean Up Previous Runs:
docker rm -f $(docker ps -aq --filter ancestor=word-counter-app:1.0.0)This command stops and removes any running or exited containers based on our image.
Rebuild the Image (if you made changes):
docker build -t word-counter-app:1.0.0 .Run with Full Verification:
docker run --rm -it word-counter-app:1.0.0- Type some text and verify the Word Counter output is correct.
- Observe the container start-up time.
- Check for any errors or warnings in the console output.
Inspect Image Size:
docker images | grep word-counter-appYou should see
word-counter-applisted, and itsSIZEshould be relatively small (e.g., 200-300MB for a JRE-based image plus your JAR), much smaller than a JDK-based image.Verify Non-Root User (Advanced - for peace of mind): You can temporarily add a
CMD ["id"]to your Dockerfile, rebuild, and run to see the user ID, or execute a shell inside the container (if you temporarily install one for debugging). For a production image, we don’t include a shell. OurUSER appuserinstruction ensures this.
By successfully completing these steps, you’ve confirmed that your Java Word Counter application is correctly packaged, built into a Docker image, and runs as expected within a container, adhering to best practices.
Summary & Next Steps
In this chapter, we successfully containerized our Java Word Counter application using Docker. We learned about the importance of containerization for portability, consistency, and efficient deployment. We designed and implemented a production-ready Dockerfile leveraging multi-stage builds, ensuring a minimal and secure runtime image. We also covered critical production considerations such as JVM memory tuning, running as a non-root user, and effective logging strategies.
You now have a robust Docker image for your Java application, a fundamental step for modern deployment pipelines. This containerized application is ready to be deployed to any environment that supports Docker, from local development to cloud platforms.
In the next chapter, Chapter 18: Orchestrating Services with Docker Compose, we will take this a step further by learning how to manage multiple related services (e.g., our application, a database, or other microservices) using Docker Compose, simplifying the setup and execution of multi-container applications.