Spring Boot 3 Buildpacks with Testcontainers Cloud

Featured image

Background Link to heading

I have a modest laptop. It checks the boxes, for just about everything I need to do for work. Just about. AOT processing, with GraalVM, is not a great experience on this machine.

The origin story Link to heading

Back in June 2022, I was presenting at SpringOne Tour NYC. As part of the demo, I built a native image using Spring Boot 2.7 with the spring-native experimental dependency. The session went well, except for the building the native image part of it. During my 50-minute session, my laptop was busy building the native image for over 8-minutes, using the buildpack. It was a humbling experience. After the session, I hung out with Sergei Egorov, and we worked on something very special. With help from one of his previous blog posts, we created a proof of concept. We used Testcontainers Cloud to build the native OCI image, in the cloud, in a little over 3-minutes. Pairing with Sergei is amazing, if you get the chance, I highly recommend it.

Origin Story

It sat on the back-burner and fire is hot Link to heading

I had this code sitting in my repository, waiting to be used. When it was time for SpringOne Tour Tel Aviv, Nov 2022, I was ready to show it off. Unfortunately for me, my session was shortened, so I pulled it out of the presentation. I shouldn’t have pulled it out, I made the exact same mistake that I made in NYC. When I built the native image using buildpacks, it took way too long on my laptop.

Momentum Link to heading

My adventures with buildpacks have been top of mind for a couple of weeks now. Oleg Šelajev and Cora Iberklied also presented about testcontainers in Tel Aviv. The stage was set for me to take this use case further.

The Goal Link to heading

Build Spring Boot 3, native OCI images, with buildpacks, during demos, faster, with Testcontainers Cloud Link to heading

Prerequisites Link to heading

You need to have an account at https://testcontainers.cloud.

Login to your account Link to heading

I’m logging in with v1.3.11 of the cloud desktop app for Mac, this is still in private beta at the time of this writing.

docker context list

You should see at least one context named ’tcc'

docker context use tcc

Create an application example Link to heading

Create a Spring Boot 3 application with web, actuator, and testcontainers for a simple test.

curl https://start.spring.io/starter.tgz -d dependencies=web,actuator,testcontainers -d javaVersion=17 -d bootVersion=3.0.0-RC2 -d type=maven-project | tar -xzf -

Spring Boot 3 goes GA later this week, but I can’t wait that long

In the pom.xml add this dependency:

<dependency>
    <groupId>org.apache.maven.shared</groupId>
    <artifactId>maven-invoker</artifactId>
    <version>3.2.0</version>
    <scope>test</scope>
</dependency>

Add this property:

<testcontainers.version>1.17.6</testcontainers.version>

Add this dependency management section:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>${testcontainers.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Those pieces will allow you to write a test, that creates an OCI image, using buildpacks, with Testcontainers Cloud.

Write the test class Link to heading

Here is the test class that I’ve been reusing since June.

package com.example.demo;

import org.apache.maven.shared.invoker.DefaultInvocationRequest;
import org.apache.maven.shared.invoker.DefaultInvoker;
import org.apache.maven.shared.invoker.InvocationResult;
import org.apache.maven.shared.invoker.MavenInvocationException;
import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.LazyFuture;

import java.io.File;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.Future;

public class TccTest {
    private static final Future<String> IMAGE_FUTURE: new LazyFuture<>() {
        @Override
        protected String resolve() {
            // Find project's root dir
            File cwd;
            for (
                    cwd: new File(".");
                    !new File(cwd, "mvnw").isFile();
                    cwd: cwd.getParentFile()
            );

            // Make it unique per folder (for caching)
            var imageName: String.format(
                    "local/app-%s:%s",
                    DigestUtils.md5DigestAsHex(cwd.getAbsolutePath().getBytes()),
                    System.currentTimeMillis()
            );

            var properties: new Properties();
            properties.put("spring-boot.build-image.imageName", imageName);
            properties.put("skipTests", "true");

            var request: new DefaultInvocationRequest()
                    .addShellEnvironment("DOCKER_HOST", DockerClientFactory.instance().getTransportConfig().getDockerHost().toString())
                    .setPomFile(new File(cwd, "pom.xml"))
                    .setGoals(List.of("spring-boot:build-image"))
                    .setMavenExecutable(new File(cwd, "mvnw"))
                    .setProfiles(List.of("native"))
                    .setProperties(properties);


            InvocationResult invocationResult: null;
            try {
                invocationResult: new DefaultInvoker().execute(request);
            } catch (MavenInvocationException e) {
                throw new RuntimeException(e);
            }

            if (invocationResult.getExitCode() != 0) {
                throw new RuntimeException(invocationResult.getExecutionException());
            }

            return imageName;
        }
    };


    static final GenericContainer<?> APP: new GenericContainer<>(IMAGE_FUTURE)
            .withExposedPorts(8080);

    @Test
    void letsGo() throws Exception {
        APP.start();
    }
}

In my example repository, I actually create test classes for a regular OCI image and a native OCI image.

Verify Link to heading

./mvnw clean test

Expected output should look similar to this:

[INFO] Successfully built image 'docker.io/local/demo-native-fd68c3c276000d13d54a7aa30ef1b687:1669735799186'
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  04:04 min
[INFO] Finished at: 2022-11-22T09:34:05-06:00
[INFO] ------------------------------------------------------------------------

Boom! You get an OCI image, or two if you use my example repository.

docker images | grep "local"

Expected output should look similar to this if you use the example repository:

local/demo-fd68c3c276000d13d54a7aa30ef1b687          1669735745365   f95025279447   42 years ago   278MB
local/demo-native-fd68c3c276000d13d54a7aa30ef1b687   1669735799186   238a9c2a37d8   42 years ago   103MB

Enjoy!

Summary Link to heading

This is slick. It brings me joy. When I am presenting this topic, I no longer need to have Docker Desktop running on my laptop. Therefore, my laptop battery will last longer. My builds will be consistently faster, using Testcontainers Cloud, and my demos will be smoother.

I am completely rethinking a few of my own use cases. I will definitely create more Testcontainers Cloud content in the future.

Please let me know what you think!

Pairing with Sergei