If I run gradlew bootBuildImage the image is built every time as this task has no outputs
> Task :bootBuildImage
Caching disabled for task ':bootBuildImage' because:
Caching has been disabled for the task
Task ':bootBuildImage' is not up-to-date because:
Task has not declared any outputs despite executing actions.
Building image ...
The rebuilding of the image takes 8 seconds vene if nothing has changed. So it is worth caching. If i run tests against the image and I am only changing some tests, I don't want the image to be rebuild every time.
I doesn't help to add a fixed timestamp to get a repeatable build
springBoot {
buildInfo {
properties {
time = "1895-01-01T18:00:00.0Z"
}
}
}
What does help in gradle is to add
tasks.bootBuildImage {
onlyIf {
!tasks.bootJar.get().state.skipped
}
}
credits to gyoder at Stackoverflow
But this does not take into account the following changes: - changing the task configuration of bootBuildImage itself does not trigger a rebuild - deleting the image from the docker daemon
I don't know if it is wise to ask the docker daemon if the image is already available, but the gradle docker plugin does exactly that. It saves the imageId to the build/.docker Directory, looking up this ID in the docker daemon and does not rebuilt the image if the image is already available:
As the task bootBuildImage does connect to the Docker daemon anyway, it might be worth asking the daemon if the image is already available.
private final Spec<Task> upToDateWhenSpec = new Spec<Task>() {
@Override
public boolean isSatisfiedBy(Task element) {
File file = getImageIdFile().get().getAsFile();
if (file.exists()) {
try {
String fileImageId;
try {
fileImageId = Files.readString(file.toPath());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
List<String> repoTags = getDockerClient().inspectImageCmd(fileImageId).exec().getRepoTags();
if (!getImages().isPresent() || repoTags.containsAll(getImages().get())) {
return true;
}
} catch (DockerException ignored) {
}
}
return false;
}
};
Comment From: wilkinsona
This is intentional as there's no way for the image building process to know that everything truly is up-to-date. Depending on the builder that's being used, passing the exact same jar through the image building process could result in a different image because the builder has picked up a change to one of the dependencies that it embeds in the image.
If you're happy for the image to be considered up-to-date based purely on whether the input jar was up-to-date, opting in with some additional configuration such as you shared above is the right thing to do.
Comment From: kicktipp
Thanks for the detailed explanation. But in my understanding a reproducible build is a good idea, so I should set an exact version number for the builder
tasks.bootBuildImage {
builder.set("paketobuildpacks/builder-jammy-full:0.0.48")
}
Is it still true that the build might differ? I don't think so. It is only if ":latest" is used, which is - of course - the default. So an idea might be to cache the result only if the builder is given a tag other than "latest"
In my understanding the plugin produces the exact same image if a version other than "latest" is used for the builder. By the way. The docs are promoting build reproducibility when describing the property of createdDate
Anyway, I am happy with the solution, but maybe I have convinced you of the benefits of a reproducible build.
Comment From: wilkinsona
I don't think so. It is only if ":latest" is used, which is - of course - the default. So an idea might be to cache the result only if the builder is given a tag other than "latest"
As explained in the documentation to which you linked, there are a number of caveats with build reproducibility. It's very hard for us to know when these caveats may and may not apply. When they do, even with a builder with a non-latest tag, it's possible for the image to change.
Short of hardcoding assumptions about specific builders, we can't be certain that the same inputs will produce the same output. If we incorrectly assumed that things are up-to-date when, in fact, they are not, an important update may be missed. Given this, we've taken the more cautious approach so that the worst case is a slightly inefficient build rather than a missed update.
Anyway, I am happy with the solution, but maybe I have convinced you of the benefits of a reproducible build.
There's no need to convince us. In fact, the benefits of a reproducible build mean that re-running the task even when it could have been considered up-to-date will produce the same output as the previous invocation.
Comment From: kicktipp
I wrote myself a small plugin to achive this. I understand that this functionality should not be part of Spring boot, but if anybody finds this issue she can workaround it with this. Not heavily tested but works for me.
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.RegularFile
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Provider
import javax.inject.Inject
// https://github.com/spring-projects/spring-boot/issues/46639
abstract class CacheableBootImageExtension @Inject constructor(project: Project) {
val imageIdFile: RegularFileProperty = project.objects.fileProperty()
}
class CacheableBootImagePlugin : Plugin<Project> {
override fun apply(project: Project) = with(project) {
val ext = extensions.create("cacheableBootImage", CacheableBootImageExtension::class.java, project)
ext.imageIdFile.convention(layout.buildDirectory.file(".docker/imageId.txt"))
pluginManager.withPlugin("org.springframework.boot") {
configureBootBuildImage(project, ext)
}
}
@Suppress("UNCHECKED_CAST")
private fun configureBootBuildImage(project: Project, ext: CacheableBootImageExtension) = with(project) {
val archiveTask = if (pluginManager.hasPlugin("war")) tasks.named("bootWar") else tasks.named("bootJar")
val archiveProvider = archiveTask.flatMap { t ->
val p = t.javaClass.methods.find { it.name == "getArchiveFile" && it.parameterCount == 0 }
?.invoke(t) as Provider<RegularFile>
p
}
val dockerImageName = tasks.named("bootBuildImage")
.flatMap { t -> t.javaClass.getMethod("getImageName").invoke(t) as Provider<String> }
tasks.named("bootBuildImage").configure {
inputs.file(archiveProvider)
outputs.file(ext.imageIdFile)
outputs.upToDateWhen {
logger.warn(dockerImageName.get())
DockerCli.imageIdAvailable(ext.imageIdFile.get().asFile)
}
doLast {
DockerCli.saveImageId(ext.imageIdFile.get().asFile, dockerImageName.get())
}
}
}
}
object DockerCli {
fun getImageId(imageName: String): String {
val proc = java.lang.ProcessBuilder("docker", "image", "inspect", "--format", "{{.Id}}", imageName).start()
val out = proc.inputStream.bufferedReader().readText().trim()
val exit = proc.waitFor()
if (exit == 0 && out.isNotEmpty()) {
println("Image ID found in docker client: $out")
return out
}
return ""
}
fun imageAvailableById(id: String): Boolean {
return id.isNotEmpty() && getImageId(id).isNotEmpty()
}
fun imageIdAvailable(file: java.io.File): Boolean {
if (!file.exists()) {
println("Image ID file does not exist: ${file.absolutePath}")
return false
}
val id = file.readText().trim()
val exists = imageAvailableById(id)
if (exists) {
println("Image bereits vorhanden: $id")
return true
}
return false
}
fun saveImageId(file: java.io.File, imageName: String) {
file.parentFile.mkdirs()
val imageId = getImageId(imageName)
if (imageId.isNotEmpty()) {
println("Image ID saved to file: ${file.absolutePath}")
file.writeText(imageId)
}
}
}