Введение
Testcontainers - Java-библиотека, которая управляет Docker-контейнерами прямо из тестового кода. Во время выполнения тестов она запускает нужный контейнер - базу данных, брокер сообщений, поисковый движок и т.д. - а по завершении останавливает и удаляет контейнер.
Зачем это нужно? Для интеграционных тестов на реальном ПО, а не на in-memory эмуляторах. Тест работает с тем же движком, что и в продакшене.
В этой статье я разберу, как можно оптимизировать работу с Testcontainers:
tmpfs - перенос файлов в оперативную память.
Прединициализация - перенос тяжёлой инициализацию в отдельный Docker-образ.
Если по первому пункту, в интернете есть статьи, то по второму - практически не встречаются, и выбранный мною подход нигде не описан.
В качестве примера буду использовать контейнер с MySQL, хотя все написаное справедливо и для любых других сервисов.
Часть 1. Testcontainers и tmpfs
Обычный тест на MySQL
Зависимости:
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "org.testcontainers:testcontainers-mysql"
testImplementation "com.mysql:mysql-connector-j:9.6.0"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}
Поднимаем MySQL-контейнер, прокидываем имя БД и креды, скармливаем пару скриптов инициализации и стартуем:
MySQLContainer container = new MySQLContainer(DockerImageName.parse("mysql:8.0.45"));
container.withDatabaseName("testdb")
.withUsername("user")
.withPassword("password")
.withInitScripts("mysql/init.tables.sql", "mysql/init.data.sql");
container.start();
Далее выполняются сами тесты.
В данной статье не будем рассматривать время выполнения самого тест кейса, так как статей с такой информацией полно в интернете. Сконцентрируемся на инициализации.
Куда уходит время
Если упрощённо нарисовать путь от вызова start() до готового контейнера, получится примерно так:
container.start() → pull Docker-образа → запуск процесса СУБД → инициализация данных (DDL, миграции, тестовые данные) → сервис готов
После первого docker pull образ уже лежит локально, так что этот этап можно не учитывать. Основное время уходит на два других этапа: запуск процесса СУБД внутри контейнера и инициализацию данных. Первое почти не зависит от размера бд, но занимает много времени: mysqld стартует, инициализирует свои служебные каталоги и затем открывает порт. Второе зависит от размера бд: множество таблиц, индексы, объёмные тестовые данные, набор Flyway/Liquibase миграций - удлиняют запуск.
tmpfs: первый шаг
Самое простое, что можно сделать, не меняя логики, - положить каталог данных СУБД в tmpfs, то есть в RAM:
container.withTmpFs(Map.of("/var/lib/mysql", "rw"));
Создание таблиц и прочий I/O начинают работать быстрее.
Замеры
Замер времени container.start() с двумя профилями нагрузки:
пустая БД - чистый старт без данных;
100 таблиц - 100 таблиц по 20 строк, то есть 2000
INSERTплюс DDL.
Медиана времени container.start(), мс.
MySQL: с tmpfs и без
Сценарий | Testcontainers, мс | Testcontainers + tmpfs, мс |
|---|---|---|
Пустая БД | 10 513 | 8 593 |
100 таблиц | 28 437 | 13 613 |
На пустой базе выигрыш скромный - экономия пары секунд на старте процесса. А вот при тяжёлой инициализации разница уже двукратная: было ~28,4 секунды, стало ~13,6. Эффект налицо. Но 13,6 секунды на один запуск теста - это всё ещё очень много, особенно когда сам тест отрабатывает за десятки миллисекунд.
Часть 2. Прединициализация контейнеров
Идея: сделать инициализацию один раз
Ключевое наблюдение: если инициализация детерминирована (одни и те же скрипты, одни и те же креды, один и тот же docker-образ дают один и тот же результат), то нет смысла повторять её на каждом старте. Достаточно один раз:
поднять временный контейнер;
выполнить в нём всю инициализацию - DDL, миграции, заполнить тестовыми данными;
превратить готовый контейнер в Docker-образ через
docker commit;в дальнейших тестах использовать уже этот подготовленный образ.
Тяжёлая инициализация выполняется один раз при сборке образа, а не при каждом start(). Это и есть прединициализация.
На практике всё укладывается в две фазы:
Фаза | Когда | Что происходит |
|---|---|---|
Сборка Docker-образа | Первый запуск | Сервис стартует, используя tmpfs, и проходит инициализацию. При остановке контейнера файлы сохраняются в отдельный каталог (не tmpfs), затем собирается Docker-образ. |
Тестовый старт | Каждый | При старте файлы восстанавливаются в tmpfs, после чего запускается процесс СУБД. Скрипты инициализации повторно не выполняются. |
Этого удаётся добиться за счёт специального entrypoint-скрипта вместо родного: при первом запуске он выполняет инициализацию используя tmpfs и при остановке контейнера сохраняет файлы в образ, при последующих - восстанавливает файлы в tmpfs и запускает родной entrypoint.
Библиотека preinit-testcontainers
Всю эту логику я реализовал в библиотеке preinit-testcontainers.
Библиотека - модульная. Содержит несколько модулей, в частности preinit-testcontainers-mysql для MySQL. Так же есть модули для других сервисов: Clickhouse, PostgreSQL, redis. Библиотека универсальна и может быть использована для запуска любых других контейнеров, а не только вышеперечисленных.
Импорт MySQL-модуля:
testImplementation "com.sviat-tech:preinit-testcontainers-mysql:2.0.1"
Создание контейнера выглядит следующим образом:
import com.sviattech.preinittestcontainers.PreInitStartCallback;
import com.sviattech.preinittestcontainers.mysql.CreateMySQLContainerCommand;
import com.sviattech.preinittestcontainers.mysql.MySQLContainerFactory;
import org.testcontainers.mysql.MySQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.List;
CreateMySQLContainerCommand command = CreateMySQLContainerCommand.builder()
.withBaseImageName("mysql:8.0.45")
.withInitScripts(List.of("mysql/init.tables.sql", "mysql/init.data.sql"))
.withDbName("testdb")
.withUsername("user")
.withPassword("password")
.withAfterPreInitStartCallback(PreInitStartCallback.of(
"mysql-callback-seed-v1",
container -> {
MySQLContainer mysql = (MySQLContainer) container;
try (Connection connection = DriverManager.getConnection(
mysql.getJdbcUrl(), "user", "password");
Statement statement = connection.createStatement()) {
statement.execute("INSERT INTO users (id, name) VALUES (999, 'from-callback')");
}
}))
.build();
try (MySQLContainer container = MySQLContainerFactory.createMySQLContainer(command)) {
container.start();
// assertions, JDBC, Spring Data...
}
Цифры
Здесь два разных этапа:
первый запуск - сборка подготовленного образа;
повторный
start()- обычный старт уже после сборки.
Сначала посмотрим на повторный старт - ради него всё и затевалось.
Testcontainers + tmpfs против Preinit + tmpfs (повторный старт)
Сценарий | Testcontainers + tmpfs, мс | Preinit + tmpfs (повторный старт), мс |
|---|---|---|
100 таблиц | 13 613 | 1 389 |
Пустая БД | 8 593 | 1 445 |
Для MySQL со 100 таблицами время старта падает с ~13,6 до ~1,4 секунды - примерно в 10 раз. На пустой БД эффект тоже значительный (~6×).
Preinit + tmpfs (первый старт) - однократные затраты
Первый запуск с прединициализацией занимает больше времени: нужно собрать образ, и только потом стартовать контейнер. Время сборка + первый старт, мс:
Сценарий | MySQL, мс |
|---|---|
100 таблиц | 15 174 |
Пустая БД | 9 967 |
Отдельно фаза сборки для MySQL со 100 таблицами - ~13,8 с. Это сопоставимо с Testcontainers + tmpfs + инициализация.
Выгода очевидна: на первый запуск уходит ~15 секунд, а дальше каждый старт - ~1,4 секунды вместо ~13,6.
Кратко, как менялось время старта:
обычный контейнер со скриптами инициализации - десятки секунд;
tmpfs уменьшает это время примерно вдвое;
прединициализация - однократная сборка образа (~15 с);
повторный тестовый старт - около 1,4 секунды.
Замеры на разных СУБД
Медиана времени container.start(), мс. Два профиля нагрузки: пустая БД и 100 таблиц (100 таблиц × 20 строк).
Сценарий | Режим | MySQL, мс | PostgreSQL, мс | ClickHouse, мс |
|---|---|---|---|---|
Пустая БД | Testcontainers | 10 513 | 1 508 | 5 486 |
Пустая БД | Testcontainers + tmpfs | 8 593 | 1 325 | 5 576 |
Пустая БД | Preinit + tmpfs (повторный старт) | 1 445 | 451 | 2 388 |
100 таблиц | Testcontainers | 28 437 | 15 068 | 29 526 |
100 таблиц | Testcontainers + tmpfs | 13 613 | 3 663 | 14 256 |
100 таблиц | Preinit + tmpfs (повторный старт) | 1 389 | 551 | 3 403 |
Заключение
Итого:
tmpfs ускоряет работу контейнера, но не убирает повторную инициализацию.
Прединициализация убирает главную повторяющуюся проблему - инициализацию на каждом старте, делая ее однокртано при первом запуске.
Для MySQL на тяжёлом сценарии разница между Testcontainers + tmpfs и preinit + tmpfs составила примерно 13,6 с против 1,4 с. Это уменьшение времени инициализации на порядок.
Исходники и примеры:

























