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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + "'";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user