Posts

How to use buildpacks with Spring Boot

Oct 3, 2024
Catherine Edelveis
18.6

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.

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.

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading