Введение

Testcontainers - Java-библиотека, которая управляет Docker-контейнерами прямо из тестового кода. Во время выполнения тестов она запускает нужный контейнер - базу данных, брокер сообщений, поисковый движок и т.д. - а по завершении останавливает и удаляет контейнер.

Зачем это нужно? Для интеграционных тестов на реальном ПО, а не на in-memory эмуляторах. Тест работает с тем же движком, что и в продакшене.

В этой статье я разберу, как можно оптимизировать работу с Testcontainers:

  1. tmpfs - перенос файлов в оперативную память.

  2. Прединициализация - перенос тяжёлой инициализацию в отдельный 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-образ дают один и тот же результат), то нет смысла повторять её на каждом старте. Достаточно один раз:

  1. поднять временный контейнер;

  2. выполнить в нём всю инициализацию - DDL, миграции, заполнить тестовыми данными;

  3. превратить готовый контейнер в Docker-образ через docker commit;

  4. в дальнейших тестах использовать уже этот подготовленный образ.

Тяжёлая инициализация выполняется один раз при сборке образа, а не при каждом start(). Это и есть прединициализация.

На практике всё укладывается в две фазы:

Фаза

Когда

Что происходит

Сборка Docker-образа

Первый запуск

Сервис стартует, используя tmpfs, и проходит инициализацию. При остановке контейнера файлы сохраняются в отдельный каталог (не tmpfs), затем собирается Docker-образ.

Тестовый старт

Каждый start()

При старте файлы восстанавливаются в 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

Заключение

Итого:

  1. tmpfs ускоряет работу контейнера, но не убирает повторную инициализацию.

  2. Прединициализация убирает главную повторяющуюся проблему - инициализацию на каждом старте, делая ее однокртано при первом запуске.

Для MySQL на тяжёлом сценарии разница между Testcontainers + tmpfs и preinit + tmpfs составила примерно 13,6 с против 1,4 с. Это уменьшение времени инициализации на порядок.

Исходники и примеры:

Комментарии (0)