Our previous guide explained what buildpacks are and how they function. Buildpacks eliminate the common issues related to Dockerfiles: writing a right Dockerfile is difficult and error-prone, and maintaining Dockerfiles is no easy task! So, Buildpacks are a great way to containerize an application without having to write a Dockerfile.
In addition, the container image can be published straight to the container registry, such as Docker Hub. In cases when you are satisfied with the results provided by the default settings, one short command would suffice! But you can also configure buildpacks for a more fine-grained control of the build.
In this article, I will show you how to use buildpacks with your Spring Boot app and how to configure them if necessary.
Table of Contents
Prerequisites:
- A Spring Boot application. You can take your own app, pull the reference Spring Boot Petclinic app from GitHub, or create a demo project with Spring Initializr;
- JDK 21. You can download Liberica JDK recommended by Spring and used by default in Paketo buildpacks for Spring;
- Your favorite IDE;
- Docker.
Set up the project
I’m going to use a simple demo application generated with Spring Initializr. I added only one dependency, Spring Web, and created a @RestController
class with one method:
@RestController
public class WelcomeController {
@GetMapping("/")
public String showGreeting() {
return "Hello! We love Paketo buildpacks!";
}
}
Create a Docker image using buildpacks
If you created a new Spring Boot project or took an existing one, it already contains a Maven / Gradle plugin responsible for implementing buildpacks.
Maven:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
Gradle:
plugins {
java
id("org.springframework.boot") version "3.3.4"
id("io.spring.dependency-management") version "1.1.6"
}
To containerize the application with default settings, you don’t need to configure anything. Therefore, make sure that JAVA_HOME is set to the Home directory of your installed Java 21 distribution and run the following command.
For Maven:
mvn spring-boot:build-image
For Gradle:
gradle bootBuildImage
It will take about two minutes to generate the image; meanwhile, you can study the terminal output to understand what is going on under the hood. For instance, you can see the Ubuntu Jammy base is used as a base OS image:
[INFO] > Pulling builder image 'docker.io/paketobuildpacks/builder-jammy-base:latest' 100%
...
[INFO] > Pulling run image 'docker.io/paketobuildpacks/run-jammy-base:latest' 100%
Then, you can see which buildpacks are participating in the build:
[INFO] [creator] 6 of 26 buildpacks participating
[INFO] [creator] paketo-buildpacks/ca-certificates 3.8.6
[INFO] [creator] paketo-buildpacks/bellsoft-liberica 10.8.4
[INFO] [creator] paketo-buildpacks/syft 2.1.0
[INFO] [creator] paketo-buildpacks/executable-jar 6.11.2
[INFO] [creator] paketo-buildpacks/dist-zip 5.8.4
[INFO] [creator] paketo-buildpacks/spring-boot 5.31.2
Buildpacks use a build-time base image for creating a build environment, and a runtime image for creating the runtime environment for the application. A Java runtime is used at both stages. By default, Paketo buildpacks use Liberica JDK that provides the following configurations (I will show you how to work with them later in this tutorial):
[INFO] [creator] Paketo Buildpack for BellSoft Liberica 10.8.4
[INFO] [creator] https://github.com/paketo-buildpacks/bellsoft-liberica
[INFO] [creator] Build Configuration:
[INFO] [creator] $BP_JVM_JLINK_ARGS --no-man-pages --no-header-files --strip-debug --compress=1 configure custom link arguments (--output must be omitted)
[INFO] [creator] $BP_JVM_JLINK_ENABLED false enables running jlink tool to generate custom JRE
[INFO] [creator] $BP_JVM_TYPE JRE the JVM type - JDK or JRE
[INFO] [creator] $BP_JVM_VERSION 17 the Java version
[INFO] [creator] Launch Configuration:
[INFO] [creator] $BPL_DEBUG_ENABLED false enables Java remote debugging support
[INFO] [creator] $BPL_DEBUG_PORT 8000 configure the remote debugging port
[INFO] [creator] $BPL_DEBUG_SUSPEND false configure whether to suspend execution until a debugger has attached
[INFO] [creator] $BPL_HEAP_DUMP_PATH write heap dumps on error to this path
[INFO] [creator] $BPL_JAVA_NMT_ENABLED true enables Java Native Memory Tracking (NMT)
[INFO] [creator] $BPL_JAVA_NMT_LEVEL summary configure level of NMT, summary or detail
[INFO] [creator] $BPL_JFR_ARGS configure custom Java Flight Recording (JFR) arguments
[INFO] [creator] $BPL_JFR_ENABLED false enables Java Flight Recording (JFR)
[INFO] [creator] $BPL_JMX_ENABLED false enables Java Management Extensions (JMX)
[INFO] [creator] $BPL_JMX_PORT 5000 configure the JMX port
[INFO] [creator] $BPL_JVM_HEAD_ROOM 0 the headroom in memory calculation
[INFO] [creator] $BPL_JVM_LOADED_CLASS_COUNT 35% of classes the number of loaded classes in memory calculation
[INFO] [creator] $BPL_JVM_THREAD_COUNT 250 the number of threads in memory calculation
[INFO] [creator] $JAVA_TOOL_OPTIONS the JVM launch flags
The Spring Boot buildpack creates a layered image, which is beneficial if you want to introduce updates to your application because in this case, only the required layers will be updated, and the others are pulled from the cache:
[INFO] [creator] Paketo Buildpack for Spring Boot 5.31.2
[INFO] [creator] https://github.com/paketo-buildpacks/spring-boot
...
[INFO] [creator] Creating slices from layers index
[INFO] [creator] dependencies (18.9 MB)
[INFO] [creator] spring-boot-loader (457.2 KB)
[INFO] [creator] snapshot-dependencies (0.0 B)
[INFO] [creator] application (41.0 KB)
Finally, you can see the layers added to the final image:
[INFO] [creator] ===> EXPORTING
[INFO] [creator] Adding layer 'paketo-buildpacks/ca-certificates:helper'
[INFO] [creator] Adding layer 'paketo-buildpacks/bellsoft-liberica:helper'
[INFO] [creator] Adding layer 'paketo-buildpacks/bellsoft-liberica:java-security-properties'
[INFO] [creator] Adding layer 'paketo-buildpacks/bellsoft-liberica:jre'
[INFO] [creator] Adding layer 'paketo-buildpacks/executable-jar:classpath'
[INFO] [creator] Adding layer 'paketo-buildpacks/spring-boot:helper'
[INFO] [creator] Adding layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
[INFO] [creator] Adding layer 'paketo-buildpacks/spring-boot:web-application-type'
[INFO] [creator] Adding layer 'buildpacksio/lifecycle:launch.sbom'
[INFO] [creator] Added 5/5 app layer(s)
[INFO] [creator] Adding layer 'buildpacksio/lifecycle:launcher'
[INFO] [creator] Adding layer 'buildpacksio/lifecycle:config'
[INFO] [creator] Adding layer 'buildpacksio/lifecycle:process-types'
Let’s check the image:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
buildpacks-demo 0.0.1-SNAPSHOT 159ae953af03 1 minute ago 346MB
Isn’t that amazing? We didn’t write a Dockerfile or use third-party tools: just one simple command, and you get a ready container image with your application!
If you run docker history, you will get the information about image layers and their sizes:
docker history buildpacks-demo:0.0.1-SNAPSHOT
IMAGE CREATED CREATED BY SIZE COMMENT
34262f088371 N/A Buildpacks Process Types 69B
<missing> N/A Buildpacks Launcher Config 1.95kB
<missing> N/A Buildpacks Application Launcher 2.56MB
<missing> N/A Application Slice: 5 0B
<missing> N/A Application Slice: 4 5.33kB
<missing> N/A Application Slice: 3 0B
<missing> N/A Application Slice: 2 399kB
<missing> N/A Application Slice: 1 19.8MB
<missing> N/A Software Bill-of-Materials 311kB
<missing> N/A Layer: 'web-application-type', Created by bu… 3B
<missing> N/A Layer: 'spring-cloud-bindings', Created by b… 76.9kB
<missing> N/A Layer: 'helper', Created by buildpack: paket… 2.9MB
<missing> N/A Layer: 'classpath', Created by buildpack: pa… 11B
<missing> N/A Layer: 'jre', Created by buildpack: paketo-b… 207MB
<missing> N/A Layer: 'java-security-properties', Created b… 214B
<missing> N/A Layer: 'helper', Created by buildpack: paket… 4.5MB
<missing> N/A Layer: 'helper', Created by buildpack: paket… 3.79MB
<missing> N/A 505B
<missing> N/A 1.42kB
<missing> N/A 26MB
<missing> N/A 77.9MB
As you can see, the top layer is extremely small, which means that the updates in Kubernetes will be fast.
Run the Docker image
Let’s run our containerized application to verify that it is functioning correctly.
When we run our application in a container, the JVM flags are automatically set based on the container image size and application properties that we specified. Upon stopping the application, you can get statistics on memory usage, which will enable you to adjust the JVM setting.
Back to our app! As our image is published to the Docker registry, we can access it with the following command. The -m
flag sets the memory limit for the container; in the case of this app, 1 GB should be enough:
docker run --rm -p 8080:8080 -m 1g docker.io/library/buildpacks-demo:0.0.1-SNAPSHOT
...
2024-09-26T08:08:55.838Z INFO 1 --- [buildpacks-demo] [ main] c.e.b.BuildpacksDemoApplication : Started BuildpacksDemoApplication in 3.242 seconds (process running for 4.2)
If you visit localhost:8080, you should see our message “Hello! We love Paketo buildpacks!”
Note the time it took the application to start: I will show you how to optimize it by leveraging Spring Boot support for CDS and AOT.
After you stop the container, you will see a similar output in your console:
Native Memory Tracking:
Total: reserved=910041934, committed=103297870
malloc: 21488462 #128396
mmap: reserved=888553472, committed=81809408
- Java Heap (reserved=469762048, committed=26886144)
(mmap: reserved=469762048, committed=26886144, at peak)
- Class (reserved=67897516, committed=4720812)
(classes #7152)
( instance classes #6641, array classes #511)
(malloc=788652 #20245) (peak=789684 #20249)
(mmap: reserved=67108864, committed=3932160, at peak)
( Metadata: )
( reserved=67108864, committed=25559040)
( used=25208784)
( waste=350256 =1.37%)
( Class space:)
( reserved=67108864, committed=3932160)
( used=3657536)
( waste=274624 =6.98%)
- Thread (reserved=12624976, committed=918608)
(threads #12)
(stack: reserved=12582912, committed=876544, peak=876544)
(malloc=28432 #83) (peak=74352 #189)
(arena=13632 #24) (peak=307416 #24)
- Code (reserved=254561352, committed=13020232)
(malloc=928840 #4931) (at peak)
(mmap: reserved=253632512, committed=12091392, at peak)
(arena=0 #0) (peak=34696 #2)
- GC (reserved=1559722, committed=122026)
(malloc=23722 #80) (peak=108362 #103)
(mmap: reserved=1536000, committed=98304, at peak)
- Compiler (reserved=5668968, committed=5668968)
(malloc=29392 #309) (peak=68960 #187)
(arena=5639576 #10) (peak=45249272 #29)
- Internal (reserved=494638, committed=494638)
(malloc=457774 #11402) (peak=473623 #11333)
(mmap: reserved=36864, committed=36864, at peak)
- Other (reserved=0, committed=0)
(malloc=0) (peak=22528 #3)
- Symbol (reserved=10486636, committed=10486636)
(malloc=9201612 #81641) (at peak)
(arena=1285024 #1) (at peak)
- Native Memory Tracking (reserved=2086944, committed=2086944)
(malloc=32608 #568) (peak=34776 #597)
(tracking overhead=2054336)
- Shared class space (reserved=16777216, committed=12320768, readonly=0)
(mmap: reserved=16777216, committed=12320768, peak=12558336)
- Arena Chunk (reserved=24624, committed=24624)
(malloc=24624 #263) (peak=46177496 #1046)
- Tracing (reserved=257, committed=257)
(malloc=257 #5) (at peak)
- Statistics (reserved=128, committed=128)
(malloc=128 #2) (at peak)
- Arguments (reserved=165, committed=165)
(malloc=165 #5) (at peak)
- Module (reserved=65592, committed=65592)
(malloc=65592 #1654) (at peak)
- Safepoint (reserved=8192, committed=8192)
(mmap: reserved=8192, committed=8192, at peak)
- Synchronization (reserved=736600, committed=736600)
(malloc=736600 #7072) (peak=736808 #7074)
- Serviceability (reserved=17152, committed=17152)
(malloc=17152 #9) (peak=17296 #11)
- Metaspace (reserved=67267488, committed=25717664)
(malloc=158624 #114) (at peak)
(mmap: reserved=67108864, committed=25559040, at peak)
- String Deduplication (reserved=680, committed=680)
(malloc=680 #8) (at peak)
- Object Monitors (reserved=1040, committed=1040)
(malloc=1040 #5) (peak=5824 #28)
- Unknown (reserved=0, committed=0)
(mmap: reserved=0, committed=0, peak=20480)
Based on this data, you can further tune JVM memory settings as I mentioned above.
Create a native image using buildpacks
Spring Boot buildpacks also enable you to turn your application into a containerized native executable using the GraalVM Native Image compiler. You can read more about using Native Images with Spring Boot here, but in short, the key benefits of the technology is that native images start faster and at peak performance. In addition, there’s no memory overhead associated with the JVM warmup, which helps to avoid allocating more memory to your instances than needed.
To create a containerized native image with buildpacks, you need to add a GraalVM plugin to your configuration file.
Maven:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
Gradle:
plugins {
java
id("org.graalvm.buildtools.native") version "0.10.3"
}
After that, run the following command.
Maven:
mvn spring-boot:build-image -Pnative
Gradle:
gradle bootBuildImage
Note that building a native image is a resource-demanding process that requires several gigabytes of memory. In addition, the build usually takes several minutes, but trust me, it’s worth the wait!
Under the hood, Spring Boot buildpacks use Liberica Native Image Kit, a GraalVM CE-based native-image compiler, and Ubuntu Jammy tiny that has a smaller memory footprint.
Check the images:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
buildpacks-demo 0.0.1-SNAPSHOT 5929c51935eb 44 years ago 111MB
The resulting image is 3 times smaller than the standard one! This is because the native executable doesn’t contain the full library of JVM classes. But note that this will not always be the case, everything depends on the complexity of your project.
Let’s see how fast it starts. Run the image with:
docker run --rm -p 8080:8080 docker.io/library/buildpacks-demo:0.0.1-SNAPSHOT
...
2024-09-26T09:07:39.935Z INFO 1 --- [buildpacks-demo] [ main] c.e.b.BuildpacksDemoApplication : Started BuildpacksDemoApplication in 0.055 seconds (process running for 0.064)
As you can see, the application started in just 0.055 seconds, almost instantly!
Customize a buildpack
Buildpacks are not black boxes that you can’t control: it is possible to tune the build process to get the exact container image you are looking for. Below are some common optimizations you can perform.
Choose a Java version
In the previous sections, I used JDK 21, but you can specify another LTS release if you like: 17, 11, or even 8, plus non-LTS releases. For this purpose, add the following configuration to the Maven plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<env>
<BP_JVM_VERSION>17</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
Or to the Gradle plugin:
tasks.named<BootBuildImage>("bootBuildImage") {
environment.putAll(mapOf(
"BP_JVM_VERSION" to "17"
))
}
Configure the JVM
You can use the JAVA_TOOL_OPTIONS environment variable to set JVM-specific options such as the Garbage Collector implementation, heap size, etc. For instance, let’s specify another GC in the Maven plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<env>
<JAVA_TOOL_OPTIONS>-XX:+UseZGC</JAVA_TOOL_OPTIONS>
</env>
</image>
</configuration>
</plugin>
Or for Gradle:
tasks.named<BootBuildImage>("bootBuildImage") {
environment.putAll(mapOf(
"JAVA_TOOL_OPTIONS" to "-XX:+UseZGC"
))
}
You can also set BP_JVM_JLINK_ENABLED to true to cut out a custom JRE. You can also customize which modules should be added to the final image by specifying them with the BP_JVM_JLINK_ARGS environmental variable.
Enabling jlink could be useful if you want to use an alternative JVM that doesn’t provide a JRE.
Refer to the BellSoft Liberica Buildpack GitHub page for the full list of configuration options.
Enable CDS and AOT for faster startup
If you are not ready to implement GraalVM Native Image in your project, but reducing application startup is vital, you can use CDS. The startup reduction will not be as drastic as in the case of native images, but in turn, you don’t have to do much to use it with your project.
You can enable CDS and AOT processing, which doesn’t impose as strict restrictions as Native Image, but shifts some work to build time.
Enable CDS and AOT and add process-aot
goal to the Maven plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<env>
<BP_SPRING_AOT_ENABLED>true</BP_SPRING_AOT_ENABLED>
<BP_JVM_CDS_ENABLED>true</BP_JVM_CDS_ENABLED>
</env>
</image>
</configuration>
<executions>
<execution>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
Or, if you use Gradle:
plugins {
java
id("org.graalvm.buildtools.native") version "0.10.3"
}
tasks.named<BootBuildImage>("bootBuildImage") {
environment.putAll(mapOf(
"BP_SPRING_AOT_ENABLED" to "true",
"BP_JVM_CDS_ENABLED" to "true"
))
}
After that, you can build the buildpack with the usual command: mvn spring-boot:build-image
for Maven or gradle bootBuildImage
for Gradle.
Check the images:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
buildpacks-demo 0.0.1-SNAPSHOT 9190499f73a0 44 years ago 396MB
Start the container image:
docker run --rm -p 8080:8080 docker.io/library/buildpacks-demo:0.0.1-SNAPSHOT
...
2024-09-26T11:18:13.650Z INFO 1 --- [buildpacks-demo] [ main] c.e.b.BuildpacksDemoApplication : Started BuildpacksDemoApplication in 1.6 seconds (process running for 2.395)
As you can see, the containerized application with CDS and AOT enabled starts two times faster than the regular container image.
Choose another builder to reduce the image size
What if reducing startup is not that critical to you, but you would like to have a more lightweight container image? In this case, you can use Alpaquita Buildpacks based on Liberica JDK and Alpaquita Linux, a 100% Alpine-compatible distro.
To do that, you need to specify another builder in your configuration file.
Maven:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<builder>bellsoft/buildpacks.builder:musl</builder>
</image>
</configuration>
</plugin>
</plugins>
</build>
Gradle:
bootBuildImage {
builder = "bellsoft/buildpacks.builder:musl"
}
After that, use the standard command for containerizing the app with a buildpack and check the images when the build is ready:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
buildpacks-demo 0.0.1-SNAPSHOT 4d8acb81e41f 44 years ago 140MB
The resulting image is 60% smaller than the first image we have built!
Conclusion
To sum up, buildpacks facilitate deployment and eliminate the need to maintain and update Dockerfiles. In case the default settings are not enough, you can easily customize the buildpack to meet your requirements.