Add support for caching to bind mounts when building images

When building an image using the Maven `spring-boot:build-image` goal or
the Gradle `bootBuildImage` task, the build and launch caches can be
configured to use a bind mount as an alternative to using a named
volume.

Closes gh-28387
This commit is contained in:
Scott Frederick
2023-08-21 15:03:39 -05:00
parent d46a58f0f6
commit c17ecf0f0b
25 changed files with 575 additions and 47 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -68,6 +68,12 @@ public abstract class AbstractBuildLog implements BuildLog {
log(" > Using build cache volume '" + buildCacheVolume + "'");
}
@Override
public void executingLifecycle(BuildRequest request, LifecycleVersion version, Cache buildCache) {
log(" > Executing lifecycle version " + version);
log(" > Using build cache " + buildCache);
}
@Override
public Consumer<LogUpdateEvent> runningPhase(BuildRequest request, String name) {
log();

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -79,6 +79,14 @@ public interface BuildLog {
*/
void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume);
/**
* Log that the lifecycle is executing.
* @param request the build request
* @param version the lifecycle version
* @param buildCache the build cache in use
*/
void executingLifecycle(BuildRequest request, LifecycleVersion version, Cache buildCache);
/**
* Log that a specific phase is running.
* @param request the build request

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@ package org.springframework.boot.buildpack.platform.build;
import java.util.Objects;
import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
@@ -37,7 +38,22 @@ public class Cache {
/**
* A cache stored as a volume in the Docker daemon.
*/
VOLUME;
VOLUME("volume"),
/**
* A cache stored as a bind mount.
*/
BIND("bind mount");
private final String description;
Format(String description) {
this.description = description;
}
public String getDescription() {
return this.description;
}
}
@@ -55,16 +71,44 @@ public class Cache {
return (this.format.equals(Format.VOLUME)) ? (Volume) this : null;
}
/**
* Return the details of the cache if it is a bind cache.
* @return the cache, or {@code null} if it is not a bind cache
*/
public Bind getBind() {
return (this.format.equals(Format.BIND)) ? (Bind) this : null;
}
/**
* Create a new {@code Cache} that uses a volume with the provided name.
* @param name the cache volume name
* @return a new cache instance
*/
public static Cache volume(String name) {
Assert.notNull(name, "Name must not be null");
return new Volume(VolumeName.of(name));
}
/**
* Create a new {@code Cache} that uses a volume with the provided name.
* @param name the cache volume name
* @return a new cache instance
*/
public static Cache volume(VolumeName name) {
Assert.notNull(name, "Name must not be null");
return new Volume(name);
}
/**
* Create a new {@code Cache} that uses a bind mount with the provided source.
* @param source the cache bind mount source
* @return a new cache instance
*/
public static Cache bind(String source) {
Assert.notNull(source, "Source must not be null");
return new Bind(source);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
@@ -87,14 +131,18 @@ public class Cache {
*/
public static class Volume extends Cache {
private final String name;
private final VolumeName name;
Volume(String name) {
Volume(VolumeName name) {
super(Format.VOLUME);
this.name = name;
}
public String getName() {
return this.name.toString();
}
public VolumeName getVolumeName() {
return this.name;
}
@@ -120,6 +168,56 @@ public class Cache {
return result;
}
@Override
public String toString() {
return this.format.getDescription() + " '" + this.name + "'";
}
}
/**
* Details of a cache stored in a bind mount.
*/
public static class Bind extends Cache {
private final String source;
Bind(String source) {
super(Format.BIND);
this.source = source;
}
public String getSource() {
return this.source;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
if (!super.equals(obj)) {
return false;
}
Bind other = (Bind) obj;
return Objects.equals(this.source, other.source);
}
@Override
public int hashCode() {
int result = super.hashCode();
result = 31 * result + ObjectUtils.nullSafeHashCode(this.source);
return result;
}
@Override
public String toString() {
return this.format.getDescription() + " '" + this.source + "'";
}
}
}

View File

@@ -18,6 +18,7 @@ package org.springframework.boot.buildpack.platform.build;
import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.function.Consumer;
import com.sun.jna.Platform;
@@ -34,6 +35,7 @@ import org.springframework.boot.buildpack.platform.docker.type.ImageReference;
import org.springframework.boot.buildpack.platform.docker.type.VolumeName;
import org.springframework.boot.buildpack.platform.io.TarArchive;
import org.springframework.util.Assert;
import org.springframework.util.FileSystemUtils;
/**
* A buildpack lifecycle used to run the build {@link Phase phases} needed to package an
@@ -72,9 +74,9 @@ class Lifecycle implements Closeable {
private final VolumeName applicationVolume;
private final VolumeName buildCacheVolume;
private final Cache buildCache;
private final VolumeName launchCacheVolume;
private final Cache launchCache;
private final String applicationDirectory;
@@ -101,8 +103,8 @@ class Lifecycle implements Closeable {
this.platformVersion = getPlatformVersion(builder.getBuilderMetadata().getLifecycle());
this.layersVolume = createRandomVolumeName("pack-layers-");
this.applicationVolume = createRandomVolumeName("pack-app-");
this.buildCacheVolume = getBuildCacheVolumeName(request);
this.launchCacheVolume = getLaunchCacheVolumeName(request);
this.buildCache = getBuildCache(request);
this.launchCache = getLaunchCache(request);
this.applicationDirectory = getApplicationDirectory(request);
}
@@ -110,33 +112,27 @@ class Lifecycle implements Closeable {
return VolumeName.random(prefix);
}
private VolumeName getBuildCacheVolumeName(BuildRequest request) {
private Cache getBuildCache(BuildRequest request) {
if (request.getBuildCache() != null) {
return getVolumeName(request.getBuildCache());
return request.getBuildCache();
}
return createCacheVolumeName(request, "build");
return createVolumeCache(request, "build");
}
private VolumeName getLaunchCacheVolumeName(BuildRequest request) {
private Cache getLaunchCache(BuildRequest request) {
if (request.getLaunchCache() != null) {
return getVolumeName(request.getLaunchCache());
return request.getLaunchCache();
}
return createCacheVolumeName(request, "launch");
}
private VolumeName getVolumeName(Cache cache) {
if (cache.getVolume() != null) {
return VolumeName.of(cache.getVolume().getName());
}
return null;
return createVolumeCache(request, "launch");
}
private String getApplicationDirectory(BuildRequest request) {
return (request.getApplicationDirectory() != null) ? request.getApplicationDirectory() : Directory.APPLICATION;
}
private VolumeName createCacheVolumeName(BuildRequest request, String suffix) {
return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6);
private Cache createVolumeCache(BuildRequest request, String suffix) {
return Cache.volume(
VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", "." + suffix, 6));
}
private ApiVersion getPlatformVersion(BuilderMetadata.Lifecycle lifecycle) {
@@ -155,9 +151,14 @@ class Lifecycle implements Closeable {
void execute() throws IOException {
Assert.state(!this.executed, "Lifecycle has already been executed");
this.executed = true;
this.log.executingLifecycle(this.request, this.lifecycleVersion, this.buildCacheVolume);
this.log.executingLifecycle(this.request, this.lifecycleVersion, this.buildCache);
if (this.request.isCleanCache()) {
deleteVolume(this.buildCacheVolume);
if (this.buildCache.getVolume() != null) {
deleteVolume(this.buildCache.getVolume().getVolumeName());
}
if (this.buildCache.getBind() != null) {
deleteBind(this.buildCache.getBind().getSource());
}
}
run(createPhase());
this.log.executedLifecycle(this.request);
@@ -184,8 +185,8 @@ class Lifecycle implements Closeable {
phase.withArgs(this.request.getName());
phase.withBinding(Binding.from(this.layersVolume, Directory.LAYERS));
phase.withBinding(Binding.from(this.applicationVolume, this.applicationDirectory));
phase.withBinding(Binding.from(this.buildCacheVolume, Directory.CACHE));
phase.withBinding(Binding.from(this.launchCacheVolume, Directory.LAUNCH_CACHE));
phase.withBinding(Binding.from(getCacheBindingSource(this.buildCache), Directory.CACHE));
phase.withBinding(Binding.from(getCacheBindingSource(this.launchCache), Directory.LAUNCH_CACHE));
if (this.request.getBindings() != null) {
this.request.getBindings().forEach(phase::withBinding);
}
@@ -199,6 +200,10 @@ class Lifecycle implements Closeable {
return phase;
}
private String getCacheBindingSource(Cache cache) {
return (cache.getVolume() != null) ? cache.getVolume().getName() : cache.getBind().getSource();
}
private void configureDaemonAccess(Phase phase) {
if (this.dockerHost != null) {
if (this.dockerHost.isRemote()) {
@@ -269,6 +274,15 @@ class Lifecycle implements Closeable {
this.docker.volume().delete(name, true);
}
private void deleteBind(String source) {
try {
FileSystemUtils.deleteRecursively(Path.of(source));
}
catch (IOException ex) {
throw new IllegalStateException("Error cleaning bind mount directory '" + source + "'", ex);
}
}
/**
* Common directories used by the various phases.
*/

View File

@@ -239,6 +239,14 @@ class BuildRequestTests {
assertThat(withCache.getBuildCache()).isEqualTo(Cache.volume("build-volume"));
}
@Test
void withBuildBindCacheAddsCache() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
BuildRequest withCache = request.withBuildCache(Cache.bind("/tmp/build-cache"));
assertThat(request.getBuildCache()).isNull();
assertThat(withCache.getBuildCache()).isEqualTo(Cache.bind("/tmp/build-cache"));
}
@Test
void withBuildVolumeCacheWhenCacheIsNullThrowsException() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
@@ -254,6 +262,14 @@ class BuildRequestTests {
assertThat(withCache.getLaunchCache()).isEqualTo(Cache.volume("launch-volume"));
}
@Test
void withLaunchBindCacheAddsCache() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
BuildRequest withCache = request.withLaunchCache(Cache.bind("/tmp/launch-cache"));
assertThat(request.getLaunchCache()).isNull();
assertThat(withCache.getLaunchCache()).isEqualTo(Cache.bind("/tmp/launch-cache"));
}
@Test
void withLaunchVolumeCacheWhenCacheIsNullThrowsException() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));

View File

@@ -218,6 +218,18 @@ class LifecycleTests {
assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
}
@Test
void executeWithCacheBindMountsExecutesPhases() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().wait(any())).willReturn(ContainerStatus.of(0, null));
BuildRequest request = getTestRequest().withBuildCache(Cache.bind("/tmp/build-cache"))
.withLaunchCache(Cache.bind("/tmp/launch-cache"));
createLifecycle(request).execute();
assertPhaseWasRun("creator", withExpectedConfig("lifecycle-creator-cache-bind-mounts.json"));
assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
}
@Test
void executeWithCreatedDateExecutesPhases() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -67,7 +67,7 @@ class PrintStreamBuildLogTests {
Consumer<TotalProgressEvent> pullRunImageConsumer = log.pullingImage(runImageReference, ImageType.RUNNER);
pullRunImageConsumer.accept(new TotalProgressEvent(100));
log.pulledImage(runImage, ImageType.RUNNER);
log.executingLifecycle(request, LifecycleVersion.parse("0.5"), VolumeName.of("pack-abc.cache"));
log.executingLifecycle(request, LifecycleVersion.parse("0.5"), Cache.volume(VolumeName.of("pack-abc.cache")));
Consumer<LogUpdateEvent> phase1Consumer = log.runningPhase(request, "alphabet");
phase1Consumer.accept(mockLogEvent("one"));
phase1Consumer.accept(mockLogEvent("two"));

View File

@@ -0,0 +1,39 @@
{
"User": "root",
"Image": "pack.local/ephemeral-builder",
"Cmd": [
"/cnb/lifecycle/creator",
"-app",
"/workspace",
"-platform",
"/platform",
"-run-image",
"docker.io/cloudfoundry/run:latest",
"-layers",
"/layers",
"-cache-dir",
"/cache",
"-launch-cache",
"/launch-cache",
"-daemon",
"docker.io/library/my-application:latest"
],
"Env": [
"CNB_PLATFORM_API=0.8"
],
"Labels": {
"author": "spring-boot"
},
"HostConfig": {
"Binds": [
"/var/run/docker.sock:/var/run/docker.sock",
"pack-layers-aaaaaaaaaa:/layers",
"pack-app-aaaaaaaaaa:/workspace",
"/tmp/build-cache:/cache",
"/tmp/launch-cache:/launch-cache"
],
"SecurityOpt" : [
"label=disable"
]
}
}