Containerizing a Spring Boot Application¶
Overview¶
This tutorial walks through the process of containerizing a Spring Boot application using Docker. We'll cover best practices for creating efficient, secure Docker images for Java applications and how to optimize them for production environments.
Prerequisites¶
- Basic understanding of Docker fundamentals
- Java development experience
- Spring Boot knowledge
- Docker installed on your machine
- Git (for source code management)
Learning Objectives¶
- Create a Docker container for a Spring Boot application
- Implement best practices for Java containerization
- Optimize container size and startup time
- Configure container for production readiness
- Implement multi-stage builds
- Test and debug containerized Spring applications
Getting Started with Spring Boot Containerization¶
Step 1: Creating a Sample Spring Boot Application¶
First, let's create a simple Spring Boot application or use an existing one. Here's a basic Spring Boot REST API:
// src/main/java/com/example/demo/DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
// src/main/java/com/example/demo/controller/HelloController.java
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class HelloController {
@GetMapping("/")
public Map<String, Object> hello() {
Map<String, Object> response = new HashMap<>();
response.put("message", "Hello from Docker container!");
response.put("timestamp", System.currentTimeMillis());
response.put("java.version", System.getProperty("java.version"));
return response;
}
@GetMapping("/health")
public Map<String, String> health() {
Map<String, String> status = new HashMap<>();
status.put("status", "UP");
return status;
}
}
Make sure you have a proper pom.xml
file for a Spring Boot application:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>spring-docker-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-docker-demo</name>
<description>Demo project for Spring Boot with Docker</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>
</project>
Configure Spring Boot Actuator in application.properties
:
# src/main/resources/application.properties
server.port=8080
# Actuator configuration
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true
# Application info
spring.application.name=spring-docker-demo
Step 2: Creating a Basic Dockerfile¶
Create a Dockerfile
in the root of your project:
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Step 3: Building and Running the Container¶
First, build your Spring Boot application:
./mvnw clean package
Then build the Docker image:
docker build -t spring-docker-demo:latest .
Run the container:
docker run -p 8080:8080 spring-docker-demo:latest
Test your application:
curl http://localhost:8080
You should see a JSON response with the message "Hello from Docker container!" along with timestamp and Java version information.
Optimizing Your Docker Image¶
Step 4: Implementing a Multi-stage Build¶
Modify your Dockerfile to use multi-stage builds, which keep the final image smaller by building in one container and copying only the necessary artifacts to the final image:
# Build stage
FROM maven:3.9.2-eclipse-temurin-17-alpine AS build
WORKDIR /app
COPY pom.xml .
# Download dependencies separately to leverage Docker cache
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
# Run stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# Create a non-root user to run the application
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
# Copy the JAR file from the build stage
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
# Configure JVM options for containerized environments
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
Build the optimized image:
docker build -t spring-docker-demo:optimized .
Step 5: Using Spring Boot Layer Tools for Faster Builds¶
Spring Boot 2.3.0+ includes layer tools that allow Docker to cache dependencies separately from application code. This speeds up rebuilds.
We already enabled layers in our pom.xml
with:
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
Now, let's update our Dockerfile to use these layers:
# Build stage
FROM maven:3.9.2-eclipse-temurin-17-alpine AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
# Extract layers stage
FROM eclipse-temurin:17-jre-alpine AS extract
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Run stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# Create a non-root user
RUN addgroup -S spring && adduser -S spring -G spring
# Set permissions
RUN mkdir -p /app/spring && chown -R spring:spring /app
USER spring:spring
# Copy layers in order from least frequently changed to most frequently changed
COPY --from=extract --chown=spring:spring /app/dependencies/ ./
COPY --from=extract --chown=spring:spring /app/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /app/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /app/application/ ./
EXPOSE 8080
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]
Build the layered image:
docker build -t spring-docker-demo:layered .
Configuring for Production Readiness¶
Step 6: Adding Health Checks¶
Docker health checks ensure that your container is running properly:
# Final layer of the optimized Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget -q -O- http://localhost:8080/actuator/health | grep UP || exit 1
Step 7: Environment Variables and Externalized Configuration¶
Update your Dockerfile to better support environment-specific configuration:
# Final stage of optimized Dockerfile with env vars
ENV SPRING_PROFILES_ACTIVE=prod
ENV SERVER_PORT=8080
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Dspring.profiles.active=$SPRING_PROFILES_ACTIVE -Dserver.port=$SERVER_PORT org.springframework.boot.loader.JarLauncher"]
Run with environment-specific configuration:
docker run -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=dev \
-e SERVER_PORT=8080 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/mydatabase \
-e SPRING_DATASOURCE_USERNAME=postgres \
-e SPRING_DATASOURCE_PASSWORD=secret \
spring-docker-demo:layered
Step 8: Creating a Docker Compose Configuration¶
Create a docker-compose.yml
file for running your application with dependencies:
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=dev
- SERVER_PORT=8080
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/mydatabase
- SPRING_DATASOURCE_USERNAME=postgres
- SPRING_DATASOURCE_PASSWORD=secret
depends_on:
- postgres
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 30s
postgres:
image: postgres:14-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_DB=mydatabase
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=secret
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
Start your application with Docker Compose:
docker-compose up -d
Security Best Practices¶
Step 9: Securing Your Container¶
- Use Specific Versions: Always use specific versions of base images, not
latest
. - Scan for Vulnerabilities: Regularly scan your images.
- Minimize Image Size: Smaller images have fewer vulnerabilities.
- Use Non-Root Users: Run as a non-privileged user (we already did this).
- Remove Build Tools: Don't include compilers and build tools in the final image.
Example of running a vulnerability scan:
# Using Trivy scanner
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image spring-docker-demo:layered
Debugging Containerized Spring Boot Applications¶
Step 10: Setting Up Remote Debugging¶
Modify your Dockerfile to enable remote debugging when needed:
# Add this to your ENTRYPOINT in development mode
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.JarLauncher"]
Run the container with debug port exposed:
docker run -p 8080:8080 -p 5005:5005 \
-e JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/urandom" \
--name spring-debug \
spring-docker-demo:layered
Then connect your IDE to port 5005 for remote debugging.
Step 11: Accessing Container Logs¶
View container logs:
docker logs -f spring-debug
Enter a running container for troubleshooting:
docker exec -it spring-debug /bin/sh
Performance Tuning¶
Step 12: JVM Tuning for Containers¶
Fine-tune JVM settings for optimal container performance:
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+UseStringDeduplication \
-Djava.security.egd=file:/dev/urandom \
-Dserver.tomcat.max-threads=50 \
-Dspring.main.lazy-initialization=true"
Step 13: Application Startup Optimization¶
- Add Spring Native support for faster startup (if applicable).
- Enable lazy initialization for non-critical beans:
# application.properties
spring.main.lazy-initialization=true
spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false
Production Deployment Considerations¶
Step 14: Tagging and Versioning¶
Use proper tagging for your Docker images:
# Tag with version
docker tag spring-docker-demo:layered myrepo/spring-docker-demo:1.0.0
# Tag with environment
docker tag spring-docker-demo:layered myrepo/spring-docker-demo:production-1.0.0
# Push to a registry
docker push myrepo/spring-docker-demo:1.0.0
docker push myrepo/spring-docker-demo:production-1.0.0
Step 15: Container Orchestration Readiness¶
Prepare your application for Kubernetes with proper application.yaml
configuration:
# src/main/resources/application.yaml
spring:
application:
name: spring-docker-demo
lifecycle:
timeout-per-shutdown-phase: 30s
server:
port: 8080
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
probes:
enabled: true
show-details: always
group:
readiness:
include: db, diskSpace
health:
livenessState:
enabled: true
readinessState:
enabled: true
Conclusion¶
In this tutorial, we've covered: 1. Creating a basic Docker image for a Spring Boot application 2. Optimizing the image using multi-stage builds and layer tools 3. Configuring the container for production readiness 4. Implementing security best practices 5. Setting up debugging and monitoring 6. Tuning the container for optimal performance
Following these practices will help you create efficient, secure, and production-ready containers for your Spring Boot applications, making them easier to deploy and manage in various environments.