Привет, Хабр!

Testcontainers Ричард Норт выкатил в 2015 году, изначально только под Java, и идея была простой: хватит подменять Postgres на H2, а Kafka на embedded‑заглушку, давайте поднимать в тесте ровно тот сервис, который стоит в проде, только в контейнере и на время прогона. За десять лет библиотека расползлась на Java,.NET, Go, Node.js, Python, Ruby, Rust и дальше по списку, обросла Testcontainers Cloud и Desktop, а компанию AtomicJar в конце 2023 года купил Docker.

Мотивация не поменялась. H2 умеет не всё, что умеет Postgres, по‑своему трактует типы, планирует запросы иначе и молча прощает то, на чём настоящая СУБД падает. Тест зелёный, прод красный. Testcontainers эту дыру закрывает целиком.

Взамен приходит другой класс проблем. Локально всё летает, а на пайплайне тесты падают в трёх запусках из ста, каждый раз на разных местах, и никакой связи с изменениями в коде не прослеживается. Логика приложения тут обычно ни при чём: причины лежат в жизненном цикле контейнеров, в сети, в дележе памяти между параллельными джобами. Ниже пять историй, которые повторяются на проектах любого размера.

Тег latest, который однажды съезжает

Начинается всё безобидно.

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:latest");

Несколько недель полёт нормальный. Потом CI начинает валиться на паре тестов, диффа в коде почти нет. Разбор показывает, что в реестре обновилась метка latest, агент подтянул свежий мажор Postgres, где поменялось значение параметра по умолчанию или поведение системного представления, и несколько запросов стали возвращать другое.

latest в Docker означает не «последняя стабильная», а «то, что было помечено этим тегом на момент pull». Обновление приезжает без вашего участия и, как правило, в самый неудачный момент. Мажорные версии Postgres, MySQL, Kafka и Redis выходят раз в год‑полтора и приносят изменения, обратно совместимые на бумаге и заметно разные на практике.

Версия должна быть той же, что в проде, и написана явно:

private static final String POSTGRES_IMAGE =
    System.getProperty("postgres.image", "postgres:16.4-alpine");

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(POSTGRES_IMAGE);

Константа выносится в общий базовый класс или в properties, чтобы одна и та же версия использовалась и в тестах, и в docker‑compose для локальной разработки. Расхождение версий между тестовыми файлами — отдельный источник веселья, когда один набор тестов видит одно поведение, другой другое.

Про alpine короткая ремарка: образ меньше (порядка 250 МБ против 500), pull в CI быстрее, но часть расширений в него не входит. Если приложение опирается на pgcrypto, postgis или что‑то ещё, это проверяется отдельно, прямо в тесте:

jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto");

Такая строка в сетапе ловит неподходящий образ до того, как это сделает падение непонятного теста.

Thread.sleep, которого хватало ровно до первого нагруженного агента

Второй сюжет знаком всем, кто хоть раз видел совет «дай контейнеру пару секунд на разогрев».

@BeforeAll
static void setUp() throws InterruptedException {
    postgres.start();
    Thread.sleep(5000);   // «чтобы точно поднялся»
    initTestData();
}

На ноутбуке пять секунд с запасом: Docker прогрет, образ в кеше, Postgres встаёт за секунду. На CI‑агенте, который тянет образ из реестра и одновременно обслуживает три чужие джобы, инициализация занимает восемь‑двенадцать секунд. Сон заканчивается раньше, чем сервис начинает принимать соединения, и тест падает с connection refused — каждый раз на новом месте, что и делает его классически флапающим.

Ждать надо не время, а признак готовности. У модульных контейнеров (PostgreSQLContainer, MySQLContainer, KafkaContainer, ElasticsearchContainer) правильная стратегия уже вшита: библиотека знает, что готовность Postgres — это способность принять соединение, а не запись в логе, и .start() не вернёт управление раньше. Поэтому в связке с модулями sleep не нужен вообще, он там просто лишний.

Проблема вылезает на GenericContainer с произвольным образом, где стратегию надо задать руками.

@Container
static GenericContainer<?> app = new GenericContainer<>("myapp:1.4.2")
    .withExposedPorts(8080)
    .waitingFor(Wait.forHttp("/health")
        .forStatusCode(200)
        .withStartupTimeout(Duration.ofSeconds(60)));

Если приложение не умеет в healthcheck, но пишет характерную строку в лог, годится Wait.forLogMessage(".*Started Application in.*", 1). Если в образе есть свой HEALTHCHECK, достаточно Wait.forHealthcheck().

Разница со sleep большая. startupTimeout задаёт верхнюю границу, а не длительность ожидания: контейнер готов за три секунды — тест поедет через три секунды, не готов за девяносто — вы получите внятную ошибку про таймаут вместо загадочного отказа в соединении на середине прогона.

Reuse, включённый в CI (и заодно оставивший зоопарк у вас на ноутбуке)

Дальше кто‑нибудь читает документацию и находит режим переиспользования контейнеров.

# ~/.testcontainers.properties
testcontainers.reuse.enable=true
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.4-alpine")
    .withReuse(true);

Локально эффект впечатляет: прогон, который начинался с десяти секунд подъёма контейнера, стартует мгновенно. Настройки радостно коммитятся в общий репозиторий, и через день в CI начинают всплывать чужие данные в таблицах, сиквенсы с неожиданных значений и коллизии по ID.

Testcontainers считает контейнер переиспользуемым по хешу конфигурации: тот же образ, те же переменные окружения, те же копируемые файлы — старый контейнер не убивается, а переиспользуется вместе со всем своим состоянием. Для локального цикла «поправил‑прогнал‑поправил» это подарок, для CI это гарантированная грязь. Плюс в чистом CI‑окружении reuse не даёт вообще ничего: контейнер там всё равно поднимается с нуля.

Простейшее решение — развести конфигурацию:

static boolean reuseEnabled() {
    return System.getenv("CI") == null;
}

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.4-alpine")
    .withReuse(reuseEnabled());

И честная очистка состояния между тестами, транзакционным откатом или напрямую:

@AfterEach
void cleanUp() {
    jdbcTemplate.execute("TRUNCATE TABLE users, orders, products RESTART IDENTITY CASCADE");
}

У reuse есть и вторая сторона, про которую редко предупреждают. Переиспользуемые контейнеры по замыслу исключены из‑под Ryuk, того самого сайдкара, который прибирает за тестами. Ryuk их не трогает, а сами они не останавливаются после прогона. Пока конфигурация совпадает, у вас крутится один тёплый Postgres. Стоит поменять ветку, миграцию или переменную окружения — хеш не сойдётся, и поднимется новый. Люди находят у себя по полторы сотни живых контейнеров недельной давности и удивляются, куда делась память.

Периодический docker container prune --filter "label=org.testcontainers=true" лечит симптом, но помнить про механизм всё равно приходится.

Фиксированный порт против параллельных джоб

Привычка к «настоящему» Postgres на 5432 иногда доходит и до тестов.

dataSource = createDataSource("jdbc:postgresql://localhost:5432/test", "test", "test");

Локально порт свободен. На раннере, где рядом крутятся ещё две джобы (а на self‑hosted агенте нередко и собственный Postgres самой CI‑платформы), второй пайплайн получает port is already allocated и падает ещё до первого теста. Причём сообщение приходит не всегда внятное: иногда это просто «не удалось запустить контейнер».

Testcontainers для того и придуман, чтобы порты выбирались динамически. Библиотека пробрасывает контейнерный порт на случайный свободный порт хоста, а реальный адрес отдаёт через API:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.4-alpine");

@DynamicPropertySource
static void datasourceProps(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

В Node.js то же самое делает getConnectionUri(), в Go и Python есть свои эквиваленты поверх getMappedPort(). Параллельные джобы разъезжаются по разным портам и перестают друг друга замечать.

Фиксировать порт вручную язык вам, конечно, позволит: в Java для этого есть FixedHostPortGenericContainer (в обычном GenericContainer метод withFixedExposedPort не публичный, и это сделано намеренно). Сценарии, где детерминированный порт действительно нужен, существуют, но в обычном интеграционном тесте их нет. Туда же, кстати, относится соблазн включить host network mode: параллельный прогон он ломает ровно так же, а на macOS и Linux ведёт себя по‑разному.

Три контейнера, четыре гигабайта и OOM killer

Последняя история самая неприятная, потому что в логах теста от неё почти ничего не остаётся.

@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.4-alpine");
@Container static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Container static ElasticsearchContainer elastic = new ElasticsearchContainer("elasticsearch:8.13.0");

На машине с 32 ГБ памяти это работает годами. На стандартном раннере с четырьмя гигабайтами первые два контейнера встают нормально, третий стартует, и дальше кто‑то из них получает SIGKILL от OOM killer. Тест падает с внезапным разрывом соединения, каждый раз с разным сервисом. Слова «OOM» вы в выводе теста не увидите, только косвенные следы в системном логе агента.

Elasticsearch по умолчанию берёт около гигабайта heap, плюс накладные расходы JVM, плюс page cache: полтора‑два гигабайта. Kafka забирает ещё гигабайт с лишним. Postgres на их фоне скромен, но общая сумма упирается в потолок раннера.

Тяжёлым контейнерам heap надо задавать явно:

@Container
static ElasticsearchContainer elastic = new ElasticsearchContainer("elasticsearch:8.13.0")
    .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
    .withEnv("discovery.type", "single-node")
    .withEnv("xpack.security.enabled", "false");

@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
    .withEnv("KAFKA_HEAP_OPTS", "-Xms512m -Xmx512m");

Сверху при желании вешается жёсткий лимит на сам контейнер:

.withCreateContainerCmdModifier(cmd ->
    cmd.getHostConfig().withMemory(768L * 1024 * 1024));

Тестовые лимиты живут в районе трети‑половины от продовых: этого хватает, чтобы функциональность работала, и достаточно мало, чтобы неаккуратный запрос на миллион строк вылез именно в тесте, а не в проде. Мерить производительность в этих условиях бессмысленно, для этого есть отдельный стенд.

Вторая половина решения — не разгонять параллельность до предела. Разумная схема: файлы тестов бегут параллельно, тесты внутри файла последовательно, число одновременных файлов ограничено памятью агента, а не числом ядер.

// vitest.config.ts
export default {
  test: {
    fileParallelism: true,
    sequence: { concurrent: false },
    poolOptions: { threads: { maxThreads: 4 } },
  },
};

Тут же стоит договориться о гранулярности контейнеров. Один общий контейнер на весь CI‑прогон убивает изоляцию и возвращает вас к проблемам общего стенда. Свежий контейнер на каждый тест съедает большую часть времени прогона на старты. Рабочая середина — контейнер на тестовый класс или файл, с очисткой данных между тестами.


Если посмотреть на пять историй вместе, у них одна природа. Локальная машина прощает почти всё: порт свободен, памяти много, Docker прогрет, никто рядом не бежит. CI не прощает ничего, потому что там дефицит по всем осям сразу и соседи, которых вы не видите. Тест, написанный с молчаливой опорой на «а у меня же работает», в этой среде и начинает мигать.

Отсюда простой критерий при написании интеграционного теста: он не должен полагаться ни на одно свойство окружения, которое вы не задали явно. Ни на версию образа, ни на скорость старта, ни на свободный порт, ни на доступную память, ни на состояние, оставшееся от прошлого прогона. Всё, что вы не зафиксировали, однажды окажется другим.


Когда ваши тесты тоже живут по принципу «локально зелёные, в CI как повезёт», проблему обычно приходится искать шире самого кода. Версии образов, контейнерная сеть, лимиты памяти, параллельные джобы и устройство пайплайна влияют на результат не меньше, чем тестовая логика.

Разобраться в соседних частях этой системы можно на бесплатных уроках:

  • 27 июля, 20:00 — «Использование GitLab CI для работы с Ansible». Записаться
    Посмотрим, как устроить автоматизацию инфраструктуры внутри CI и убрать из пайплайна лишние ручные зависимости.

  • 10 августа, 20:00 — «Kubernetes + CI/CD + GitOps — делаем стабильный деплой без выхода из кластера». Записаться
    Разберм, как собирать воспроизводимый процесс доставки и управлять изменениями окружения через GitOps.

  • 18 августа, 20:00 — «Безопасный релиз на практике: SAST, SCA, контейнеры и security gates». Записаться
    Как встроить контейнерные проверки и автоматические барьеры в пайплайн, чтобы проблемы находились до релиза, а не после него.

CI всё равно останется средой с ограниченными ресурсами и соседями, которых вы не видите. Но чем меньше в нём неявных допущений, тем реже пайплайн будет решать судьбу релиза случайным образом.

Больше бесплатных уроков июля смотрите в дайджесте.

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


  1. monco83
    17.07.2026 15:01

    У нас была плавающая ошибка, связанная с именем сети

    Изначально код был написан так.

    
    private readonly INetwork someNetwork = new NetworkBuilder()
        .WithName("some_network") 
        .Build();
    

    Тесты могли проходить, могли падать. Падали, когда два раннера работали одновременно.
    Заработало, когда переписали вот так.

    private readonly INetwork someNetwork = new NetworkBuilder()
         .WithName($"some_network_{Guid.NewGuid()}")
         .Build();

    Или можно было просто WithName убрать.