Container Images with Buildpacks
Turn the application into a production-ready OCI image with one build command and no Dockerfile, using Cloud Native Buildpacks.
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=repo/name:tag
./gradlew bootBuildImage --imageName=repo/name:tag
# build time: BP_* launch time: BPL_*, JAVA_TOOL_OPTIONSSpring Boot's build plugins can build a container image directly. ./mvnw spring-boot:build-image or ./gradlew bootBuildImage hands your application to Cloud Native Buildpacks (Paketo by default) and produces an OCI image in your local Docker daemon. There is no Dockerfile to write or keep patched.
What the buildpacks do for you
- Pick a Java runtime that matches the version you ask for with
BP_JVM_VERSION. - Split the application into layers (dependencies, loader, application classes), so a code change pushes a small layer instead of the whole image.
- Run the process as a non-root user.
- Add a start-up step that reads the container's memory limit and calculates heap, metaspace and thread stack sizes so the JVM fits inside it.
The result follows practice that many hand-written Dockerfiles get wrong. That is the main reason to start here.
When to use it, and when not
Buildpacks suit teams that want a sound image without owning a base image. The build needs a Docker-compatible daemon and downloads the builder image on first use, so a CI runner without Docker cannot use it. If you must use a company base image, install extra operating system packages or control every layer, write a Dockerfile instead.
Configuration
Build-time settings start with BP_ and go in the plugin's image.env; they change what goes into the image. Launch-time settings start with BPL_ and are passed as environment variables when the container runs. JAVA_TOOL_OPTIONS adds JVM flags at runtime. The image name defaults to docker.io/library/<artifactId>:<version>; set it to your registry path, and set publish with registry credentials to push from the build.
Mistakes people make
- Running without a memory limit. The calculator then sizes the JVM from the host's memory, and under load the process grows past what the node can spare until the kernel or the kubelet kills it.
- Expecting a shell. Paketo run images are minimal;
docker exec -it ... shoften fails. Debug with logs and actuator endpoints. - Tagging only
latest. Tag with the version or commit so a rollback is a tag change. - Rebuilding rarely. Base layers get security fixes; rebuild on a schedule even when the code has not changed.
Class Data Sharing and the AOT cache can cut start-up time and are switched on with buildpack environment variables; the faster start-up lesson covers the trade-offs.
Example
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>registry.example.com/shop/bookings:${project.version}</name>
<env>
<!-- Java version for the runtime layer -->
<BP_JVM_VERSION>25</BP_JVM_VERSION>
</env>
<publish>true</publish>
</image>
<docker>
<publishRegistry>
<username>${env.REGISTRY_USER}</username>
<password>${env.REGISTRY_TOKEN}</password>
</publishRegistry>
</docker>
</configuration>
</plugin>
When to use it
- Containerise a team's REST API without anyone needing to maintain a Dockerfile or base image.
- Ship small image updates where only the application layer changes between releases.
- Give a service a heap size that fits its Kubernetes memory limit without hand-tuning -Xmx.
More examples
Build and run the image
The command line overrides the image name for a one-off build; the pom setting is what CI uses. <code>BPL_JVM_THREAD_COUNT</code> tells the memory calculator how many threads to reserve stack space for; lower it for a small service and the heap gets the difference. Passing JVM options through <code>JAVA_TOOL_OPTIONS</code> means the image never has to be rebuilt to change them.
# Needs a running Docker daemon; no Dockerfile in the project
./mvnw -B spring-boot:build-image -Dspring-boot.build-image.imageName=bookings:1.4.0
# Gradle equivalent
./gradlew bootBuildImage --imageName=bookings:1.4.0
# Run it with a memory limit; the start-up script sizes the heap from it
docker run --rm -p 8080:8080 --memory=768m bookings:1.4.0
# Runtime tuning is done with environment variables, not a rebuild
docker run --rm -p 8080:8080 --memory=768m \
-e BPL_JVM_THREAD_COUNT=100 \
-e JAVA_TOOL_OPTIONS="-XX:ActiveProcessorCount=2" \
bookings:1.4.0
Log what the JVM received
Logging heap size and processor count once at start-up turns a guess into a fact when a container behaves differently from a laptop. If the max heap equals most of the host's memory, the container has no limit. If <code>availableProcessors</code> is 1, thread pools sized from it will be small, which matters for anything that defaults to the CPU count.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class RuntimeReport {
private static final Logger log = LoggerFactory.getLogger(RuntimeReport.class);
@EventListener(ApplicationReadyEvent.class)
void report() {
Runtime rt = Runtime.getRuntime();
// Shows what the JVM actually got inside the container, not what the host has.
log.info("Java {} | max heap {} MiB | processors {}",
Runtime.version(),
rt.maxMemory() / (1024 * 1024),
rt.availableProcessors());
}
}
Publish from CI with credentials from the environment
The pom reads <code>${env.REGISTRY_USER}</code> and <code>${env.REGISTRY_TOKEN}</code>, so no secret lives in version control. Tests are skipped here only because an earlier pipeline step ran them on the same commit; never skip them in the only step that builds the artefact.
export REGISTRY_USER=ci-bot
export REGISTRY_TOKEN="$CI_REGISTRY_TOKEN"
./mvnw -B spring-boot:build-image -DskipTests
# image pushed as registry.example.com/shop/bookings:1.4.0
Discussion