Skip to content

Commit 43a76ab

Browse files
authored
Reusable containers (stage1, alpha) (#1781)
* Simplify Kafka container by deferring the Kafka command * WIP: reusable containers based on labels * do not call `newInstance` from `newInstanceFromConnectionUrl` * disable the port forwarding in `ReusabilityUnitTests` * Simplify the startup sequence * speed up port detection by running the checks as a single command * Separate "environment" and "classpath" properties (for global things) * Update TestcontainersConfiguration.java * Add test for the reuse env property * Add `@UnstableAPI` annotation * Add `VisibleForTesting` on `findContainerForReuse`
1 parent 3806235 commit 43a76ab

File tree

9 files changed

+380
-19
lines changed

9 files changed

+380
-19
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.testcontainers;
2+
3+
import java.lang.annotation.Documented;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
8+
9+
/**
10+
* Marks that the annotated API is a subject to change and SHOULD NOT be considered
11+
* a stable API.
12+
*/
13+
@Retention(RetentionPolicy.SOURCE)
14+
@Target({
15+
ElementType.TYPE,
16+
ElementType.METHOD,
17+
ElementType.FIELD,
18+
})
19+
@Documented
20+
public @interface UnstableAPI {
21+
}

core/src/main/java/org/testcontainers/containers/GenericContainer.java

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package org.testcontainers.containers;
22

3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.MapperFeature;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.SerializationFeature;
37
import com.github.dockerjava.api.DockerClient;
48
import com.github.dockerjava.api.command.CreateContainerCmd;
59
import com.github.dockerjava.api.command.InspectContainerResponse;
@@ -12,13 +16,15 @@
1216
import com.github.dockerjava.api.model.PortBinding;
1317
import com.github.dockerjava.api.model.Volume;
1418
import com.github.dockerjava.api.model.VolumesFrom;
19+
import com.google.common.annotations.VisibleForTesting;
1520
import com.google.common.base.Strings;
21+
import com.google.common.collect.ImmutableMap;
1622
import lombok.AccessLevel;
1723
import lombok.Data;
18-
import lombok.EqualsAndHashCode;
1924
import lombok.NonNull;
2025
import lombok.Setter;
2126
import lombok.SneakyThrows;
27+
import org.apache.commons.codec.digest.DigestUtils;
2228
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
2329
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
2430
import org.apache.commons.compress.utils.IOUtils;
@@ -33,6 +39,7 @@
3339
import org.rnorth.visibleassertions.VisibleAssertions;
3440
import org.slf4j.Logger;
3541
import org.testcontainers.DockerClientFactory;
42+
import org.testcontainers.UnstableAPI;
3643
import org.testcontainers.containers.output.OutputFrame;
3744
import org.testcontainers.containers.startupcheck.IsRunningStartupCheckStrategy;
3845
import org.testcontainers.containers.startupcheck.MinimumDurationRunningStartupCheckStrategy;
@@ -62,11 +69,14 @@
6269
import java.io.FileOutputStream;
6370
import java.io.IOException;
6471
import java.io.InputStream;
72+
import java.lang.reflect.Method;
6573
import java.nio.charset.Charset;
6674
import java.nio.file.Path;
6775
import java.time.Duration;
76+
import java.time.Instant;
6877
import java.util.ArrayList;
6978
import java.util.Arrays;
79+
import java.util.Collection;
7080
import java.util.Collections;
7181
import java.util.HashMap;
7282
import java.util.HashSet;
@@ -100,6 +110,8 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
100110

101111
public static final String INTERNAL_HOST_HOSTNAME = "host.testcontainers.internal";
102112

113+
static final String HASH_LABEL = "org.testcontainers.hash";
114+
103115
/*
104116
* Default settings
105117
*/
@@ -168,11 +180,12 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
168180

169181
protected final Set<Startable> dependencies = new HashSet<>();
170182

171-
/*
183+
/**
172184
* Unique instance of DockerClient for use by this container object.
185+
* We use {@link LazyDockerClient} here to avoid eager client creation
173186
*/
174187
@Setter(AccessLevel.NONE)
175-
protected DockerClient dockerClient = DockerClientFactory.instance().client();
188+
protected DockerClient dockerClient = LazyDockerClient.INSTANCE;
176189

177190
/*
178191
* Info about the Docker server; lazily fetched.
@@ -222,6 +235,8 @@ public class GenericContainer<SELF extends GenericContainer<SELF>>
222235
@Nullable
223236
private Map<String, String> tmpFsMapping;
224237

238+
@Setter(AccessLevel.NONE)
239+
private boolean shouldBeReused = false;
225240

226241
public GenericContainer() {
227242
this(TestcontainersConfiguration.getInstance().getTinyImage());
@@ -276,13 +291,15 @@ protected void doStart() {
276291
try {
277292
configure();
278293

294+
Instant startedAt = Instant.now();
295+
279296
logger().debug("Starting container: {}", getDockerImageName());
280297
logger().debug("Trying to start container: {}", image.get());
281298

282299
AtomicInteger attempt = new AtomicInteger(0);
283300
Unreliables.retryUntilSuccess(startupAttempts, () -> {
284301
logger().debug("Trying to start container: {} (attempt {}/{})", image.get(), attempt.incrementAndGet(), startupAttempts);
285-
tryStart();
302+
tryStart(startedAt);
286303
return true;
287304
});
288305

@@ -291,7 +308,25 @@ protected void doStart() {
291308
}
292309
}
293310

294-
private void tryStart() {
311+
@UnstableAPI
312+
@SneakyThrows
313+
protected boolean canBeReused() {
314+
for (Class<?> type = getClass(); type != GenericContainer.class; type = type.getSuperclass()) {
315+
try {
316+
Method method = type.getDeclaredMethod("containerIsCreated", String.class);
317+
if (method.getDeclaringClass() != GenericContainer.class) {
318+
logger().warn("{} can't be reused because it overrides {}", getClass(), method.getName());
319+
return false;
320+
}
321+
} catch (NoSuchMethodException e) {
322+
// ignore
323+
}
324+
}
325+
326+
return true;
327+
}
328+
329+
private void tryStart(Instant startedAt) {
295330
try {
296331
String dockerImageName = image.get();
297332
logger().debug("Starting container: {}", dockerImageName);
@@ -300,16 +335,49 @@ private void tryStart() {
300335
CreateContainerCmd createCommand = dockerClient.createContainerCmd(dockerImageName);
301336
applyConfiguration(createCommand);
302337

303-
containerId = createCommand.exec().getId();
338+
createCommand.getLabels().put(DockerClientFactory.TESTCONTAINERS_LABEL, "true");
304339

305-
connectToPortForwardingNetwork(createCommand.getNetworkMode());
340+
boolean reused = false;
341+
if (shouldBeReused) {
342+
if (!canBeReused()) {
343+
throw new IllegalStateException("This container does not support reuse");
344+
}
306345

307-
copyToFileContainerPathMap.forEach(this::copyFileToContainer);
346+
if (TestcontainersConfiguration.getInstance().environmentSupportsReuse()) {
347+
String hash = hash(createCommand);
308348

309-
containerIsCreated(containerId);
349+
containerId = findContainerForReuse(hash).orElse(null);
310350

311-
logger().info("Starting container with ID: {}", containerId);
312-
dockerClient.startContainerCmd(containerId).exec();
351+
if (containerId != null) {
352+
logger().info("Reusing container with ID: {} and hash: {}", containerId, hash);
353+
reused = true;
354+
} else {
355+
logger().debug("Can't find a reusable running container with hash: {}", hash);
356+
357+
createCommand.getLabels().put(HASH_LABEL, hash);
358+
}
359+
} else {
360+
logger().info("Reuse was requested but the environment does not support the reuse of containers");
361+
}
362+
} else {
363+
createCommand.getLabels().put(DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL, DockerClientFactory.SESSION_ID);
364+
}
365+
366+
if (!reused) {
367+
containerId = createCommand.exec().getId();
368+
369+
// TODO add to the hash
370+
copyToFileContainerPathMap.forEach(this::copyFileToContainer);
371+
}
372+
373+
connectToPortForwardingNetwork(createCommand.getNetworkMode());
374+
375+
if (!reused) {
376+
containerIsCreated(containerId);
377+
378+
logger().info("Starting container with ID: {}", containerId);
379+
dockerClient.startContainerCmd(containerId).exec();
380+
}
313381

314382
logger().info("Container {} is starting: {}", dockerImageName, containerId);
315383

@@ -331,7 +399,7 @@ private void tryStart() {
331399
// Wait until the process within the container has become ready for use (e.g. listening on network, log message emitted, etc).
332400
waitUntilContainerStarted();
333401

334-
logger().info("Container {} started", dockerImageName);
402+
logger().info("Container {} started in {}", dockerImageName, Duration.between(startedAt, Instant.now()));
335403
containerIsStarted(containerInfo);
336404
} catch (Exception e) {
337405
logger().error("Could not start container", e);
@@ -351,6 +419,31 @@ private void tryStart() {
351419
}
352420
}
353421

422+
@UnstableAPI
423+
@SneakyThrows(JsonProcessingException.class)
424+
final String hash(CreateContainerCmd createCommand) {
425+
// TODO add Testcontainers' version to the hash
426+
String commandJson = new ObjectMapper()
427+
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
428+
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
429+
.writeValueAsString(createCommand);
430+
431+
return DigestUtils.sha1Hex(commandJson);
432+
}
433+
434+
@VisibleForTesting
435+
Optional<String> findContainerForReuse(String hash) {
436+
// TODO locking
437+
return dockerClient.listContainersCmd()
438+
.withLabelFilter(ImmutableMap.of(HASH_LABEL, hash))
439+
.withLimit(1)
440+
.withStatusFilter(Arrays.asList("running"))
441+
.exec()
442+
.stream()
443+
.findAny()
444+
.map(it -> it.getId());
445+
}
446+
354447
/**
355448
* Set any custom settings for the create command such as shared memory size.
356449
*/
@@ -613,7 +706,6 @@ private void applyConfiguration(CreateContainerCmd createCommand) {
613706
if (createCommand.getLabels() != null) {
614707
combinedLabels.putAll(createCommand.getLabels());
615708
}
616-
combinedLabels.putAll(DockerClientFactory.DEFAULT_LABELS);
617709

618710
createCommand.withLabels(combinedLabels);
619711
}
@@ -1215,7 +1307,7 @@ public SELF withStartupAttempts(int attempts) {
12151307
}
12161308

12171309
/**
1218-
* Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart()}.
1310+
* Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart(Instant)}.
12191311
* Invocation happens eagerly on a moment when container is created.
12201312
* Warning: this does expose the underlying docker-java API so might change outside of our control.
12211313
*
@@ -1247,6 +1339,12 @@ public SELF withTmpFs(Map<String, String> mapping) {
12471339
return self();
12481340
}
12491341

1342+
@UnstableAPI
1343+
public SELF withReuse(boolean reusable) {
1344+
this.shouldBeReused = reusable;
1345+
return self();
1346+
}
1347+
12501348
@Override
12511349
public boolean equals(Object o) {
12521350
return this == o;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.testcontainers.containers;
2+
3+
import com.github.dockerjava.api.DockerClient;
4+
import lombok.experimental.Delegate;
5+
import org.testcontainers.DockerClientFactory;
6+
7+
enum LazyDockerClient implements DockerClient {
8+
9+
INSTANCE;
10+
11+
@Delegate
12+
final DockerClient getDockerClient() {
13+
return DockerClientFactory.instance().client();
14+
}
15+
}

core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import lombok.*;
44
import lombok.extern.slf4j.Slf4j;
5+
import org.testcontainers.UnstableAPI;
56

67
import java.io.*;
78
import java.net.MalformedURLException;
@@ -89,12 +90,17 @@ public boolean isDisableChecks() {
8990
return Boolean.parseBoolean((String) environmentProperties.getOrDefault("checks.disable", "false"));
9091
}
9192

93+
@UnstableAPI
94+
public boolean environmentSupportsReuse() {
95+
return Boolean.parseBoolean((String) environmentProperties.getOrDefault("testcontainers.reuse.enable", "false"));
96+
}
97+
9298
public String getDockerClientStrategyClassName() {
9399
return (String) environmentProperties.get("docker.client.strategy");
94100
}
95101

96102
/**
97-
*
103+
*
98104
* @deprecated we no longer have different transport types
99105
*/
100106
@Deprecated

0 commit comments

Comments
 (0)