Containerizing a Microservice
Containerizing a Microservice
Shipping a Spring Boot service as a Docker image gives you a self-contained, reproducible artifact that runs identically on a developer laptop, a CI runner, and a Kubernetes cluster. In this lesson you will learn how to write a production-grade Dockerfile, build and tag the image, run it locally, and manage the configuration and secrets a containerised service needs — without baking credentials into the image layer.
Why Containers for Microservices?
A microservice is only as portable as the environment it depends on. Without containers you need every host to have the correct JDK version, the right application.properties values, and the exact same OS libraries. Containers solve all three problems by bundling the JRE and the fat JAR into a single immutable unit. The key benefits are:
- Reproducibility: the image built in CI is byte-for-byte what you promote to production.
- Isolation: each service has its own classpath, port, and environment variables — no shared JVM or port conflicts on the same host.
- Fast horizontal scaling: an orchestrator (Kubernetes, ECS) can spin up ten more instances of an image in seconds.
- Immutable deployments: you never patch a running container; you build a new image tag and roll it out.
Writing a Production-Grade Dockerfile
A naive Dockerfile copies the fat JAR and runs it. That works, but it defeats Docker's layer-caching mechanism: every code change rebuilds a 60 MB layer. The standard solution is the multi-layer (or exploded JAR) pattern, which Spring Boot's build plugins support natively.
First, build the exploded-jar layout. With Maven:
This produces four sub-directories under target/extracted/: dependencies/, spring-boot-loader/, snapshot-dependencies/, and application/. Spring Boot orders them by change frequency: application code changes most often, third-party JARs almost never.
Now write the Dockerfile in the project root:
javac, no build tools, and no extra binaries that a compromised container process could leverage.
Running as a Non-Root User
By default Docker containers run as root inside the container namespace. If your service has a remote-code-execution vulnerability, an attacker running as root inside the container can — in some configurations — escape to the host. The fix is simple: always create a dedicated system user and switch to it with USER. The --chown flag on each COPY ensures the application files are owned by that user so Spring Boot can read the classpath at runtime.
Building and Tagging the Image
From the project root (where Dockerfile lives):
latest tag in production manifests. latest is mutable — a new push overwrites it and you lose traceability. Tag every release with a version (1.0.0) or the Git commit SHA (git rev-parse --short HEAD), then reference that immutable tag in your Kubernetes or Compose files.
Passing Configuration and Secrets
A container image must be environment-agnostic. Hardcoding spring.datasource.url=jdbc:mysql://prod-db:3306/orders inside the image ties it to a single deployment target and leaks the hostname into the image registry. The correct approach is to inject all environment-specific values at runtime via environment variables, which Spring Boot maps to properties with a well-defined convention:
Run the container locally with environment variables:
Use host.docker.internal on macOS/Windows to reach a service running on your host machine. On Linux use the bridge network gateway IP (172.17.0.1 by default) or start a shared Docker network.
-e flags for secrets in CI/CD or production. Environment variables are visible in process listings (ps aux) and in docker inspect output. Use a secrets manager (Kubernetes Secrets with encryption at rest, AWS Secrets Manager, HashiCorp Vault) and mount secrets as files or inject them via a secrets-aware sidecar.
Health Checks
Docker can poll your service's health endpoint and restart unhealthy containers automatically. Spring Boot Actuator exposes /actuator/health out of the box. Add a HEALTHCHECK instruction to your Dockerfile:
--start-period=40s gives the JVM time to start before Docker starts counting retries. A container that fails three consecutive checks is marked unhealthy and an orchestrator replaces it.
Spring Boot Buildpacks (Alternative to Dockerfile)
Spring Boot 3 ships with built-in Cloud Native Buildpack support. No Dockerfile needed:
Buildpacks automatically apply a non-root user, layer the JAR, add a memory-calculator JVM agent, and follow best-practice defaults. The trade-off: less control over the exact base image and a slower first build (it downloads the buildpack stack). For teams that want convention over configuration, buildpacks are excellent. For teams with custom base image requirements (corporate CA certificates, hardened OS images), a hand-written Dockerfile gives more control.
JVM Memory Tuning Inside a Container
Without explicit limits the JVM defaults to sizing its heap relative to the host's total RAM, which can be several hundred GBs in a cloud VM. This causes your container to be killed by the OOM-killer when it exceeds its cgroup memory limit. Always set heap bounds explicitly:
UseContainerSupport (on by default since JDK 10) makes the JVM read the cgroup memory limit instead of the host total. MaxRAMPercentage=75.0 caps the heap at 75% of the container limit, leaving headroom for Metaspace, thread stacks, and native memory.
Summary
Containerising a Spring Boot microservice means writing a multi-stage Dockerfile that separates build tools from the runtime image, exploiting Spring Boot's layer-extraction to maximise Docker cache hits, running as a non-root user, and injecting all environment-specific configuration at runtime rather than baking it in. Add a HEALTHCHECK so orchestrators can detect and replace broken instances automatically. Once your service is an immutable, tagged image you have the artifact you need for the final lesson: wiring two services together into a working system.