Введение
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 с. Это уменьшение времени инициализации на порядок.
Исходники и примеры: