Distroless Containers in Java: Minimal, Secure Containers for JVM Applications

In the pursuit of secure, minimal container images for Java applications, distroless containers have emerged as a game-changing approach. Unlike traditional container images that include full operating system distributions, distroless containers contain only the application and its runtime dependencies—nothing more. For Java teams, this means significantly reduced attack surfaces, smaller images, and improved security compliance for production deployments.

What are Distroless Containers?

Distroless containers are Docker images that contain only your application and its runtime dependencies, without package managers, shells, or other standard OS utilities. Originally created by Google, these images provide just enough OS to run your application, dramatically reducing the potential attack surface and container size.

Why Distroless Containers are Revolutionary for Java

  1. Minimal Attack Surface: No shells (bash, sh), package managers (apt, yum), or unnecessary binaries for attackers to exploit.
  2. Reduced Image Size: Smaller images mean faster downloads, quicker deployments, and lower storage costs.
  3. Improved Security Compliance: Fewer components mean fewer CVEs to patch and audit.
  4. Deterministic Builds: Reproducible builds with fewer moving parts.
  5. Better Resource Utilization: Less OS overhead means more resources for your Java application.

Distroless Images for Java Applications

Google maintains several distroless bases specifically for Java:

  • gcr.io/distroless/java17-debian11: JRE 17 on Debian 11
  • gcr.io/distroless/java11-debian11: JRE 11 on Debian 11
  • gcr.io/distroless/java17: JRE 17 (latest Debian)
  • gcr.io/distroless/java11: JRE 11 (latest Debian)
  • gcr.io/distroless/java17-debian11:nonroot: Runs as non-root user
  • gcr.io/distroless/java11-debian11:nonroot: Runs as non-root user

Building Distroless Containers for Java Applications

1. Basic Distroless Dockerfile

# Multi-stage build: Build in full image, run in distroless
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
# Copy source and build
COPY . .
RUN ./mvnw clean package -DskipTests
# Runtime stage - Distroless
FROM gcr.io/distroless/java17-debian11:nonroot
# Copy application from builder stage
COPY --from=builder /app/target/myapp.jar /app/myapp.jar
# Run as non-root user (already set in base image)
USER nonroot:nonroot
# Set working directory
WORKDIR /app
# Application entry point
ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]
# Health check (HTTP check, not shell-based)
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]

2. Spring Boot with Distroless

# Build stage with JDK
FROM maven:3.8.4-eclipse-temurin-17 AS build
WORKDIR /workspace/app
# Cache dependencies
COPY pom.xml .
RUN mvn dependency:go-offline
# Copy source and build
COPY src src
RUN mvn clean package -DskipTests \
&& java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted
# Runtime stage - Distroless
FROM gcr.io/distroless/java17-debian11:nonroot
# Create app directory
WORKDIR /app
# Copy Spring Boot layers
COPY --from=build /workspace/app/target/extracted/dependencies/ ./
COPY --from=build /workspace/app/target/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/app/target/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/app/target/extracted/application/ ./
# Run as non-root (1000:1000)
USER 1000:1000
# Entry point
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
# Expose port
EXPOSE 8080

3. Advanced Multi-Module Java Application

# Multi-module Maven project with distroless
FROM maven:3.8.4-eclipse-temurin-17 AS builder
WORKDIR /workspace
# Copy parent pom
COPY pom.xml .
COPY api/pom.xml api/pom.xml
COPY service/pom.xml service/pom.xml
COPY web/pom.xml web/pom.xml
# Download dependencies
RUN mvn dependency:go-offline -B
# Copy source
COPY api/src api/src
COPY service/src service/src  
COPY web/src web/src
# Build application
RUN mvn clean package -DskipTests -pl web -am
# Extract layers for Spring Boot
RUN java -Djarmode=layertools -jar web/target/*.jar extract --destination /layers
# Runtime stage
FROM gcr.io/distroless/java17-debian11:nonroot
# Copy application layers
COPY --from=builder /layers/dependencies/ ./
COPY --from=builder /layers/spring-boot-loader/ ./
COPY --from=builder /layers/snapshot-dependencies/ ./
COPY --from=builder /layers/application/ ./
# Non-root user
USER 1000:1000
# Entry point
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Java-Specific Considerations for Distroless

1. Handling Temporary Files
Java applications often need /tmp directory. With distroless, you need to ensure it exists:

FROM gcr.io/distroless/java17-debian11:nonroot
# Create necessary directories
RUN mkdir -p /tmp /app/logs && \
chown -R nonroot:nonroot /tmp /app/logs
USER nonroot:nonroot
# Rest of Dockerfile...

2. Timezone and Locale Configuration

# Add timezone data if needed
FROM gcr.io/distroless/java17-debian11:nonroot
# Copy timezone file
COPY --from=debian:11-slim /usr/share/zoneinfo/UTC /etc/localtime
# Set Java timezone
ENV TZ=UTC
ENV JAVA_TOOL_OPTIONS="-Duser.timezone=UTC"

3. SSL Certificates

# Include CA certificates
FROM gcr.io/distroless/java17-debian11:nonroot
# Copy CA certificates from Debian slim
COPY --from=debian:11-slim /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

Security Enhancements with Distroless

1. No Shell Access

# Traditional container - Shell access available
docker exec -it traditional-container bash
# Distroless container - No shell available
docker exec -it distroless-container bash
# Error: OCI runtime exec failed: exec failed: container_linux.go:380: 
# starting container process caused: exec: "bash": executable file not found in $PATH

2. Read-Only Root Filesystem

FROM gcr.io/distroless/java17-debian11:nonroot
# Application runs with read-only root filesystem
# Mount writable volumes where needed
VOLUME ["/tmp", "/app/logs"]

Debugging Distroless Containers

1. Debug Images
Google provides debug variants with BusyBox for troubleshooting:

# For debugging, use debug variant
FROM gcr.io/distroless/java17-debian11:debug
# Has shell and basic utilities for debugging

2. Ephemeral Debug Containers (Kubernetes)

# Debug running distroless container in Kubernetes
kubectl debug -it pod/java-app-7d8f6c9d5-abc123 \
--image=busybox:latest \
--target=java-app \
-- sh

3. Custom Debugging Approach

// Add debug endpoint to Java application
@RestController
@RequestMapping("/debug")
@ConditionalOnProperty(name = "debug.enabled", havingValue = "true")
public class DebugController {
@GetMapping("/threads")
public String getThreadDump() {
return Thread.getAllStackTraces().entrySet().stream()
.map(e -> e.getKey().getName() + ":\n" + 
Arrays.stream(e.getValue())
.map(StackTraceElement::toString)
.collect(Collectors.joining("\n")))
.collect(Collectors.joining("\n\n"));
}
@GetMapping("/memory")
public Map<String, String> getMemoryInfo() {
Runtime runtime = Runtime.getRuntime();
return Map.of(
"freeMemory", formatBytes(runtime.freeMemory()),
"totalMemory", formatBytes(runtime.totalMemory()),
"maxMemory", formatBytes(runtime.maxMemory()),
"availableProcessors", String.valueOf(runtime.availableProcessors())
);
}
}

Production-Ready Distroless Configuration

1. Comprehensive Dockerfile

# syntax=docker/dockerfile:1.4
FROM eclipse-temurin:17-jdk AS build
# Build arguments
ARG BUILD_VERSION=1.0.0
ARG BUILD_TIMESTAMP
WORKDIR /workspace
# Cache dependencies
COPY pom.xml .
RUN mvn dependency:go-offline -B
# Copy source
COPY src src
# Build with reproducible timestamps
RUN mvn clean package -DskipTests \
-Dbuild.version=${BUILD_VERSION} \
-Dbuild.timestamp=${BUILD_TIMESTAMP} \
&& java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted
# Security scan stage (optional)
FROM aquasec/trivy:latest AS security
COPY --from=build /workspace/target/*.jar app.jar
RUN trivy filesystem --exit-code 1 --severity HIGH,CRITICAL /
# Runtime stage
FROM gcr.io/distroless/java17-debian11:nonroot
# Metadata
LABEL org.opencontainers.image.title="My Java App" \
org.opencontainers.image.version="${BUILD_VERSION}" \
org.opencontainers.image.created="${BUILD_TIMESTAMP}" \
org.opencontainers.image.source="https://github.com/company/my-java-app"
# Create necessary directories
RUN mkdir -p /tmp /app/logs && \
chmod -R 1777 /tmp && \
chown -R nonroot:nonroot /app/logs
# Copy application
COPY --from=build /workspace/target/extracted/dependencies/ ./
COPY --from=build /workspace/target/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/target/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/target/extracted/application/ ./
# Non-root user
USER 1000:1000
# Environment variables
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
ENV SPRING_PROFILES_ACTIVE="production"
# Health check (using wget from distroless)
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]
# Entry point
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
# Expose port
EXPOSE 8080

2. Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: java-distroless-app
spec:
replicas: 3
selector:
matchLabels:
app: java-distroless
template:
metadata:
labels:
app: java-distroless
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: app
image: company/java-app-distroless:1.0.0
securityContext:
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
ports:
- containerPort: 8080
env:
- name: JAVA_OPTS
value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "768Mi"
cpu: "500m"
volumeMounts:
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 100Mi
- name: logs
emptyDir:
sizeLimit: 1Gi

Monitoring and Observability in Distroless

1. Java Management Extensions (JMX)

FROM gcr.io/distroless/java17-debian11:nonroot
# Enable JMX for monitoring
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=9090 \
-Dcom.sun.management.jmxremote.authenticate=false \
-Dcom.sun.management.jmxremote.ssl=false \
-Djava.rmi.server.hostname=127.0.0.1"
EXPOSE 9090

2. Prometheus Metrics

// Spring Boot with Micrometer
@SpringBootApplication
public class DistrolessApplication {
public static void main(String[] args) {
SpringApplication.run(DistrolessApplication.class, args);
}
@Bean
MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags(
"application", "distroless-java-app",
"region", System.getenv("REGION")
);
}
}

3. Structured Logging

@Configuration
public class LoggingConfiguration {
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.BASIC;
}
@Bean
public ConsoleAppender consoleAppender() {
// JSON logging for better parsing in container environments
ConsoleAppender appender = new ConsoleAppender();
PatternLayout layout = new PatternLayout();
layout.setConversionPattern("%d{yyyy-MM-dd HH:mm:ss} - %msg%n");
appender.setLayout(layout);
return appender;
}
}

Migration Strategy from Traditional to Distroless

1. Phase 1: Assessment

# Analyze current container
docker run --rm -it my-java-app:current sh
# Check what binaries your app actually uses
which bash
which curl
which ps

2. Phase 2: Build Distroless Image

FROM gcr.io/distroless/java17-debian11 AS test
COPY app.jar /app.jar
# Test if it runs
CMD ["java", "-jar", "/app.jar"]

3. Phase 3: Validation

# Test functionality
docker run --rm my-java-app-distroless:test
# Security scan comparison
trivy image my-java-app:traditional
trivy image my-java-app-distroless:test

Benefits and Trade-offs

Benefits:

  • 90%+ reduction in CVEs
  • 60-80% smaller image sizes
  • Faster container startup
  • Improved compliance posture
  • Simplified patching (only JRE updates)

Trade-offs:

  • Debugging requires different approaches
  • Limited OS utilities
  • May need additional configuration for certificates/timezones
  • Learning curve for operations teams

Best Practices for Java Distroless Containers

  1. Use Multi-Stage Builds: Build in JDK image, run in JRE distroless.
  2. Test Thoroughly: Ensure all functionality works without shell utilities.
  3. Implement Health Checks: Use HTTP-based health checks instead of shell scripts.
  4. Monitor Resource Usage: Distroless containers have different resource patterns.
  5. Keep JRE Updated: Regularly update the distroless base image.
  6. Use Non-Root Variants: Always run as non-root user.
  7. Provide Debug Images: Maintain debug variants for troubleshooting.

Conclusion

Distroless containers represent the future of secure Java application deployment in containerized environments. By eliminating unnecessary operating system components, they provide a minimal, secure runtime that significantly reduces attack surfaces while maintaining full Java application functionality.

For Java development teams, adopting distroless containers requires some adjustment in debugging practices and build processes, but the security benefits far outweigh the initial investment. The combination of smaller images, fewer vulnerabilities, and improved compliance makes distroless containers an essential component of modern Java application security strategy.

As the Java ecosystem continues to evolve towards cloud-native architectures, distroless containers provide the secure foundation needed for production deployments in increasingly hostile network environments.

Advanced Java Container Security, Sandboxing & Trusted Runtime Environments

https://macronepal.com/blog/sandboxing-java-applications-implementing-landlock-lsm-for-enhanced-container-security/
Explains using Linux Landlock LSM to sandbox Java applications by restricting file system and resource access without root privileges, improving application-level isolation and reducing attack surface.

https://macronepal.com/blog/gvisor-sandbox-integration-in-java-complete-guide/
Explains integrating gVisor with Java to provide a user-space kernel sandbox that intercepts system calls and isolates applications from the host operating system for stronger security.

https://macronepal.com/blog/selinux-for-java-mandatory-access-control-for-jvm-applications/
Explains how SELinux enforces Mandatory Access Control (MAC) policies on Java applications, strictly limiting what files, processes, and network resources the JVM can access.

https://macronepal.com/java/a-comprehensive-guide-to-intel-sgx-sdk-integration-in-java/
Explains Intel SGX integration in Java, allowing sensitive code and data to run inside secure hardware enclaves that remain protected even if the OS is compromised.

https://macronepal.com/blog/building-a-microvm-runtime-with-aws-firecracker-in-java-a-comprehensive-guide/
Explains using AWS Firecracker microVMs with Java to run workloads in lightweight virtual machines that provide strong isolation with near-container performance efficiency.

https://macronepal.com/blog/enforcing-mandatory-access-control-implementing-apparmor-for-java-applications/
Explains AppArmor security profiles for Java applications, enforcing rules that restrict file access, execution rights, and system-level permissions.

https://macronepal.com/blog/rootless-containers-in-java-secure-container-operations-without-root/
Explains running Java applications in rootless containers using Linux user namespaces so containers operate securely without requiring root privileges.

https://macronepal.com/blog/unlocking-container-security-harnessing-user-namespaces-in-java/
Explains Linux user namespaces, which isolate user and group IDs inside containers to improve privilege separation and enhance container security for Java workloads.

https://macronepal.com/blog/secure-bootstrapping-in-java-comprehensive-trust-establishment-framework/
Explains secure bootstrapping in Java, focusing on how systems establish trust during startup using secure key management, identity verification, and trusted configuration loading.

https://macronepal.com/blog/securing-java-applications-with-chainguard-wolfi-a-comprehensive-guide-2/
Explains using Chainguard/Wolfi minimal container images to secure Java applications by reducing unnecessary packages, minimizing vulnerabilities, and providing a hardened runtime environment.


Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper