Reproducible builds are big wins for security, maintainability, and sanity. If you don’t like it when nothing has changed, yet your build suddenly breaks or doesn’t produce the same output, then improving reproducibility is for you. By default, Spring Boot’s Docker/OCI image building solutions, bootBuildImage
(in Gradle) and spring-boot:build-image
(in Maven), do not operate reproducibly. Here’s how to improve that situation for bootBuildImage
– the same approach applies for spring-boot:build-image
with a different syntax that won’t be covered here for sake of brevity.
Why Being Reproducible is Important
Reproducible builds are important and provide benefits in many areas, including:
- Security. Because the same input source code always provides the same output binary artifact, you know that no attacker modified the toolchain to inject vulnerabilities into the artifact.
- Maintainability. Reproducible builds require the build process to be clear and repeatable, meaning that other people and systems can also execute it, reducing “it works on my computer” problems.
- Sanity. Have you ever experienced phantom project breakage? I’ve left a project alone for a while, then come back to it, and suddenly it doesn’t work. Source control shows no commits have been made… yet it doesn’t work.
I previously discussed the benefits of reproducible builds in my Reproducible Builds in Java article.
Benefits of building Docker/OCI images with Buildpacks using bootBuildImage
bootBuildImage
was introduced in May 2020 with the release of Spring Boot 2.3. It provides a way to create a Docker image from a Spring Boot application using Cloud Native Buildpacks. This approach has some significant advantages over alternatives approaches (such as crafting a Dockerfile
and building the image using docker build
, kaniko
, or similar tools):
- There’s no
Dockerfile
to maintain. It can be difficult to write aDockerfile
that produces a secure, minimal docker image, and it’s ongoing effort to keep it secure and up to date. Outsourcing this effort to the experts who author the build packs saves significant effort. - They’re more efficient. Buildpacks use layers allowing for smaller images that can be copied and deployed faster than the typical, simple
Dockerfile
approach. - They include additional features for free, including an integrated SBOM.
- They build using non-root users and include other security benefits which aren’t easy to do or commonly implemented when using a
Dockerfile
How to use bootBuildImage Reproducibly
By default, bootBuildImage
will do a few things that result in non-reproducible behavior:
- Get the latest builder and run images (using the docker “latest” tag). The exact images used will change whenever the “latest” tag is updated.
- Determines which buildpacks to use based on build time analysis. For example, it will use the builder image to determine the
paketo-buildpacks/ca-certificates
buildpack should be used. That decision could change, resulting in that buildpack not being used in the future. - The versions of buildpacks selected can also all change.
To solve these problems, the builder, run, and buildpack images can all be explicitly specified including versions and digests. The digest is important because it’s possible for a version to be updated (a new “3.4.0” image could be published – in Docker images, “versions” are just tags and tags are mutable), but a digest is immutable always pointing to the same image.
Here’s the resulting build.gradle
excerpt:
tasks.named("bootBuildImage") {
// See: https://paketo.io/docs/howto/java/
environment = [
// run the JLink tool and install a minimal JRE for runtime, reducing both image size and attack surface
"BP_JVM_JLINK_ENABLED" : "true",
]
docker {
// version and digest pin all image references. This ensures reproducibility.
// make sure to configure Renovate to keep these image references up to date.
// if these image references are not kept up to date, any security issues discovered within them will never be fixed.
// Use a tiny builder and run image (which produce a distroless-like image) to reduce both image size and attack surface.
builder = "docker.io/paketobuildpacks/builder-jammy-tiny:0.0.33@sha256:e092679d4c4648ee9eaec77fc277952c0104b7c0be8d69d4cf0796f9eeb3bbd9"
runImage = "docker.io/paketobuildpacks/run-jammy-tiny:0.1.15@sha256:07e64eabb1763b51bf38e9865e31362aa213163865e6a3132e4c4b43e95e273c"
buildpacks = [
"gcr.io/paketo-buildpacks/ca-certificates:3.4.0@sha256:18f4c21df483fad2fc947de4f80bbe6b635912ef78b4e9ed10c047eab4dd6f10",
"gcr.io/paketo-buildpacks/bellsoft-liberica:9.8.0@sha256:17d05fc67e416dc180b1bcc03d69fc8e3cdfeab928c7797d318c52cbad4051f2",
"gcr.io/paketo-buildpacks/syft:1.20.0@sha256:558356668ce53f4acf4252cc1cd26b7bc6303c4d3353acee60efc4122aaa25d2",
"gcr.io/paketo-buildpacks/executable-jar:6.5.0@sha256:971568ae093406911d36fb07c35f589ee69b6c7a2d3f6b90e9e4e924f05baa13",
"gcr.io/paketo-buildpacks/dist-zip:5.4.0@sha256:245717608ea3a379831ffe9f0a40c0ca82a851c9a0e60f36db9f727cf887f36a",
"gcr.io/paketo-buildpacks/spring-boot:5.19.0@sha256:8071eea13a32f563b2cfb8536d78f8a777f591aa991ca5cceaa37a72537a88dd",
]
}
}
As a bonus, I’ve also also set BP_JVM_JLINK_ENABLED
which tells the buildpack to use the JLink tool to install a minimal JRE for runtime, another best practice although one not related to reproducibility.
As mentioned in the comments, it’s critical to keep these image references up to date. Here’s a Renovate configuration that could be used as is or merged into an existing renovate.json
to do so:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
],
"pinDigests": true,
"regexManagers": [
{
"description": "Update docker references in build.gradle",
"fileMatch": ["^build.gradle$"],
"matchStrings": [
"\"(?<depName>(?:gcr\\.io|docker\\.io)\\/[^:]+?):(?<currentValue>[^@]+)(?:@(?<currentDigest>sha256:[a-f0-9]+))?\""
],
"datasourceTemplate": "docker",
"versioningTemplate": "docker"
}
]
}
Now enjoy the improved security, maintainability, efficiency, and sanity of reproducible Docker image building using Spring Boot bootBuildImage
.