22 минуты вместо двух: как контейнер на каждый тест съедал прогон — и что помогло

Интеграционные тесты Go-сервиса шли 22 минуты и случайно падали по таймауту — списывали на медленную машину. Причина оказалась арифметической: 266 вызовов тестового хелпера поднимали 266 контейнеров Postgres. Разбираем переход на один контейнер и CREATE DATABASE ... TEMPLATE, замеры до и после (×10 и ×36), воспроизводимый стенд, границы приёма — права, FORCE, размер шаблона, параллельность пакетов — и честную границу между тем, что измерено, и тем, что осталось правдоподобной гипотезой

Гравюра, разделённая надвое вертикальной линейкой со штампом «×36.7». Слева порт, забитый одинаковыми грузовыми контейнерами с эмблемой слона: они уходят сотнями в глубину кадра, несколько накренились и лежат мёртвым грузом с бирками «created»; портовый кран держит очередной контейнер на весу, рядом отметка «60 s»; крупный секундомер показывает 22:51. Справа пустой причал и один-единственный такой же контейнер, из люка с надписью TEMPLATE по короткому конвейеру сходят тонкие карточки со слоном — каждая в собственной рамке, они лежат порознь, не сливаясь в стопку; секундомер показывает 2:16

Интеграционные тесты платформы шли 22–24 минуты в одном пакете и 11–14 в другом. Изредка какой-нибудь тест падал по таймауту ожидания порта — всегда разный, всегда воспроизводился в изоляции за секунды. Диагноз ставился привычный: машина не тянет, докер под WSL2 медленный, перезапустим.

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

Опирается на тестирование Go-сервиса, где testcontainers-go разобран как основа интеграционных тестов. Здесь — что делать, когда этой основы становится много.

Оба варианта — «контейнер на каждый тест» и «общий контейнер плюс шаблонная база» — собраны в воспроизводимый стенд: digital-cookbook/performance/testcontainers-template-db. Числа ниже можно снять у себя одной командой и посмотреть, как соотношение меняется на вашем железе.

Симптом и ложный след

Прогон выглядел так:

ok  github.com/.../internal/storage   638s
ok  github.com/.../cmd/api           1371s

Двадцать три минуты на пакет — это уже не «долго», это «никто не будет гонять локально». Тесты запускались перед пушем, в CI и больше нигде.

Флаки выглядели ещё хуже, потому что были неотличимы от настоящих падений:

--- FAIL: TestCreateCourseForm_ExistingID_Conflict (65.66s)
    auth_middleware_test.go:43: Received unexpected error:
        container did not start within 60s

Обратите внимание на время: 65 секунд и падение на строке подъёма контейнера, а не на утверждении. Тот же тест в одиночку проходил за 20 секунд. Отсюда и вывод «окружение», и ритуал перепрогона.

Ложный след держался на том, что вывод был правильным: это действительно окружение. Просто окружение портили сами тесты.

Арифметика, которую стоило сделать раньше

Тестовый хелпер выглядел безобидно:

func newTestStore(t *testing.T) *Store {
    // поднять контейнер postgres:16-alpine
    // дождаться порта
    // накатить миграции
    // вернуть Store
}

Вызовов этого хелпера в пакете internal/storage116. Соседнего newAuthTestHandler в cmd/api — около 150.

Двести шестьдесят шесть контейнеров Postgres за прогон. Каждый — образ, сеть, ожидание порта, тринадцать миграций с нуля. Никакого «медленного докера» тут не нужно: даже полторы секунды на контейнер дают шесть минут, а под WSL2 полутора секундами дело не обходится.

Хуже, что они не всегда убирались. docker ps -a показал 33 контейнера в состоянии created — застрявших между созданием и стартом.

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

Зато стенд показывает, на чём именно падает. Вот кусок лога упавшего прогона:

--- PASS: TestNaive/case-05 (3.13s)
--- FAIL: TestNaive/case-06 (64.70s)
    get state: Get ".../containers/1a02ab.../json": context deadline exceeded
--- PASS: TestNaive/case-07 (3.11s)

Отказ не в том, что Postgres долго стартует. Не ответил Docker API: запрос состояния контейнера через сокет не вернулся за отведённые ретраи. Соседние случаи при этом отрабатывают за 3.1 секунды — демон не лёг, он отвечает неравномерно.

Дальше начинается то, чего измерения не показывают. Почему API перестал отвечать вовремя — исчерпание какого именно ресурса, очередь внутри демона, хвосты teardown, особенности WSL2 — по этим данным сказать нельзя: ни метрик демона, ни событий Docker, сопоставленных с моментами отказов, никто не снимал. Поэтому формулировка ровно такая, какая подтверждается: повторный жизненный цикл контейнеров на этой машине оказался достаточным условием, чтобы Docker API переставал отвечать вовремя. Накопление контейнеров воспроизводится отдельно и остаётся правдоподобным усилителем — не причиной.

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

Что заменить

Нужны два свойства, которые кажутся противоречащими:

  • изоляция — тест не должен видеть данные соседа, иначе порядок выполнения станет частью условия;
  • дешевизна — потому что изоляции требуется двести шестьдесят шесть раз.

Контейнер на тест даёт первое ценой второго. Общая база на всех даёт второе ценой первого — и порождает отдельный жанр отладки «почему тест зелёный в одиночку и красный в пакете». Способы готовить данные так, чтобы тесты не мешали друг другу, разбирает статья про фикстуры и тестовые данныеСкоро; здесь речь о более грубом уровне — не о содержимом базы, а о самой базе.

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

CREATE DATABASE testdb_9f3a1c TEMPLATE lab_template;

Это копирование файлов внутри одного сервера — десятки миллисекунд. Не «почти изоляция», а полноценная отдельная база: свои таблицы, своё содержимое, свой DROP в конце.

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

Схема получается такая:

один контейнер на тестовый бинарь
  └── база-шаблон  (миграции накатаны один раз)
        ├── testdb_9f3a1c   тест A
        ├── testdb_2b77e0   тест B
        └── …               по одной на тест, живут миллисекунды

Реализация

Пакет целиком — около полутора сотен строк. Ключевые места ниже.

Подъём один раз за процесс. go test компилирует и запускает каждый пакет отдельным бинарём, поэтому «один раз за процесс» и означает «один контейнер на пакет»:

var (
    once     sync.Once
    setupErr error
    pool     *pgxpool.Pool // соединение к служебной базе: CREATE/DROP DATABASE
    endpoint string        // host:port контейнера
)

func New(t *testing.T) string {
    t.Helper()
    if testing.Short() {
        t.Skip("интеграционный тест: нужен Docker; пропущен в -short")
    }

    // Подъём получает свой бюджет, а не бюджет теста, которому не повезло
    // оказаться первым: контейнер живёт дольше любого теста.
    once.Do(func() {
        setupCtx, cancel := context.WithTimeout(context.Background(), startupTimeout)
        defer cancel()
        setupErr = setup(setupCtx)
    })
    require.NoError(t, setupErr, "общий контейнер-шаблон не поднялся")

    // Контекст теста создаётся ПОСЛЕ подъёма. Создай его раньше — первый тест
    // пакета отдал бы свои секунды на ожидание контейнера и пришёл бы к
    // CREATE DATABASE с истёкшим дедлайном.
    ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
    t.Cleanup(cancel)

    name := newDBName(t)
    _, err := pool.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", name, templateDB))
    require.NoError(t, err)

    t.Cleanup(func() {
        // Ошибка уборки НЕ глотается: молчаливый провал — это утечка баз,
        // которая проявится через сотню тестов исчерпанным диском, и связать
        // её с причиной будет уже нечем.
        dropCtx, cancel := context.WithTimeout(context.Background(), opTimeout)
        defer cancel()
        if _, err := pool.Exec(dropCtx,
            fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", name)); err != nil {
            t.Errorf("база %s не удалена: %v", name, err)
        }
    })

    return dsnFor(name)
}

Три вещи в этом фрагменте выглядят перестраховкой, и каждая появилась после того, как стенд на них споткнулся: раздельные бюджеты на подъём и на операции, создание контекста теста после once.Do, и проверка ошибки удаления базы. Подробности — в разделе про границы ниже.

Почему sync.Once, а не TestMain. TestMain — очевидный ответ на «инициализировать один раз». Но он объявляется в каждом пакете-потребителе: пришлось бы добавить одинаковую заглушку в два пакета, держать их в синхронности и не забыть про третий, когда он появится. sync.Once с ленивой инициализацией даёт тот же эффект при одном источнике правды — и, что важнее, позволил не трогать ни один из 266 вызовов. Сигнатуры newTestStore и newAuthTestHandler остались прежними, изменилось только их нутро.

Но Once решает только подъём. Уборку он не решает: срабатывает на первом тесте, а не после последнего, и явный Terminate звать некому.

Соблазн — оставить это реаперу ryuk: он привязан к сессии тестового процесса и уберёт контейнер сам. Соблазну лучше не поддаваться. Реапер — аварийный запасной путь, а не способ уборки: он работает с задержкой, сам требует контейнера и отключается переменной TESTCONTAINERS_RYUK_DISABLED, которую в CI ставят чаще, чем кажется — в окружениях без прав на монтирование сокета Docker или с политикой «никаких привилегированных контейнеров» он просто не стартует.

Правильная комбинация — оба механизма, каждый на своей половине:

func TestMain(m *testing.M) {
    code := m.Run()
    if pool != nil {
        pool.Close()
    }
    if container != nil {
        ctx, cancel := context.WithTimeout(context.Background(), startupTimeout)
        // Ошибка уборки повышает код выхода: контейнер, который не убрался,
        // достаётся следующему прогону, и лучше узнать об этом сразу.
        if err := testcontainers.TerminateContainer(container,
            testcontainers.StopContext(ctx)); err != nil {
            fmt.Fprintf(os.Stderr, "контейнер не убран: %v\n", err)
            if code == 0 {
                code = 1
            }
        }
        cancel()
    }
    os.Exit(code)
}

И одно место в setup, которое легко пропустить: контейнер надо запомнить до проверки ошибки его создания.

pg, err := tcpg.Run(ctx, image, /* ... */)
container = pg // присваиваем до проверки: неудачный Run может вернуть
if err != nil {   // частично созданный контейнер, и без этого TestMain
    return err    // о нём не узнает
}

В варианте с контейнером на каждый тест то же самое делается через testcontainers.CleanupContainer(t, pg) — его тоже регистрируют до if err != nil, он корректно обрабатывает nil.

sync.Once — для подъёма, чтобы не трогать вызовы. TestMain — для завершения, чтобы не зависеть от реапера. Один TestMain на пакет писать всё же придётся, но это заглушка в пять строк, а не правка 266 вызовов, и она не растекается по коду.

Миграции — боевым путём. В шаблон они катятся той же функцией, что накатывает их на стендах:

if _, err := pool.Exec(ctx, "CREATE DATABASE "+templateDB); err != nil {
    return fmt.Errorf("create template db: %w", err)
}
if err := migrate.Run(dsnFor(templateDB)); err != nil {
    return fmt.Errorf("migrate template: %w", err)
}

Это не косметика. До перехода тесты катили миграции своим исполнителем: ручной список из тринадцати файлов и наивный разбор -- +goose Down строками. То есть проверяли не ту последовательность, которая едет на стенды. Такое расхождение не выстреливает годами, а потом выстреливает миграцией, которую тесты никогда не видели.

Две ловушки Postgres

К шаблону не должно быть соединений. CREATE DATABASE ... TEMPLATE требует, чтобы к базе-источнику никто не был подключён, иначе:

ERROR: source database "lab_template" is being accessed by other users

Поэтому служебное соединение (pool выше) держится к другой базе — postgres, — а migrate.Run открывает и закрывает своё соединение к шаблону сам. После возврата из миграций к шаблону не подключён никто, и это именно то, что нужно.

DROP DATABASE без WITH (FORCE) падает. Если тест или его хелперы не успели закрыть соединения к моменту t.Cleanup, удаление базы отобьётся. WITH (FORCE) (Postgres 13+) обрывает чужие соединения сам:

_, _ = pool.Exec(context.Background(),
    fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", name))

Без него уборка становится вероятностной, и мусор возвращается — ровно тот, с которого всё началось.

Имя базы — не t.Name(). Подтесты содержат /, а в общем случае произвольные символы, недопустимые в непокавыченном идентификаторе. Восемь случайных байт в hex решают и это, и уникальность при повторном запуске той же функции.

Замеры

Один и тот же коммит, одна машина, чистый Docker до каждого прогона:

Пакет Было Стало Раз
internal/storage 638s 17.4s ×36.7
cmd/api 1371s 135.9s ×10.1
Полный прогон go test ./... -p 1 3m14s

Контейнеров в Docker после прогона (плюс пятнадцать секунд на реап): ноль своих.

Разница множителей объяснима: в cmd/api часть времени уходит не на базу, поэтому там выигрыш меньше. Чем чище пакет от прочей работы, тем ближе ускорение к отношению «контейнер против CREATE DATABASE».

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

Вариант Медиана Разброс Упало Первым / вторым
общий контейнер + TEMPLATE 5.9s 5.7–5.9 0 из 4 2 / 2
контейнер на случай 65.8s 65.8–66.6 1 из 4 2 / 2

Отношение — ×11.2. Повторов четыре, а не три, и это не педантизм: порядок вариантов чередуется, при нечётном числе один из них шёл бы первым чаще другого. Колонка справа показывает назначенные позиции, а не успешные — считай она успешные, упавший прогон исчезал бы из баланса и прятал бы связь между позицией и падением. Связь тут есть: единственное падение наивного варианта пришлось на позицию «первым».

И снята она на отдохнувшем демоне, — вот как она такой стала.

Сначала порядок вариантов был постоянным: сперва шаблонный, потом наивный. Шаблонный выглядел безупречно — ноль падений, — а наивный терял один-два прогона из трёх. Вывод напрашивался: наивный нестабилен по природе.

Потом порядок стали чередовать, и падать начали оба, по два прогона из трёх. У шаблонного отказ был свой: reaper: ... could not start container. То есть его безупречность была артефактом — он всегда шёл первым, по спокойному демону.

После этого между прогонами появилось ожидание восстановления: убрать свои контейнеры, дождаться, пока догорят реаперы предыдущего прогона, и пока docker info начнёт отвечать быстрее полутора секунд. Тогда оба варианта стали доходить до конца, и осталась чистая разница в скорости — та, что в таблице.

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

На пяти случаях видно устройство: у шаблонного варианта время между пятью и двадцатью случаями почти не изменилось (5.3 → 6.0), у наивного выросло вчетверо (18.4 → 65.8). Фиксированная часть у первого — подъём одного контейнера, дальше случай стоит десятки миллисекунд.

Проверить, не веря на слово: STAND_CASES=50 ./run.sh покажет ваше соотношение и вашу долю упавших прогонов. Скрипт сначала убедится, что go, docker и демон на месте, и откажется печатать таблицу, если чего-то нет: результат, полученный из сломанного окружения, хуже отсутствия результата.

Границы приёма

Он не бесплатный и не универсальный. Пять вещей, которые стоит проверить до внедрения.

Права. Роли нужен CREATEDB. Владельцем созданной базы становится тот, кто её создал: если тесты подключаются другой ролью, права придётся выдать явно. Для DROP DATABASE ... WITH (FORCE) нужно право завершать чужие сеансы — суперпользователь либо членство в pg_signal_backend.

FORCE не всесилен. Он обрывает обычные сеансы, но база не удалится, если на ней остались подготовленные транзакции (PREPARE TRANSACTION), слоты логической репликации или подписки. В тестовом контуре это редкость — но если тесты трогают логическую репликацию, уборка начнёт падать, и падать будет не там, где причина.

Шаблон копирует не всё. CREATE DATABASE ... TEMPLATE копирует содержимое базы, но не настройки уровня базы (ALTER DATABASE ... SET) и не права доступа к ней. Если тесты зависят от таких настроек, применяйте их к каждой копии отдельно.

Цена растёт с размером шаблона. Копирование файлов дёшево ровно до тех пор, пока файлов немного. Схема в шаблоне — копейки; гигабайт фикстур в шаблоне — уже нет. Плюс диск: одновременно живут все базы, которые не успели удалиться.

Контейнер получается один на пакет, а не на прогон. go test компилирует каждый пакет отдельным бинарём и запускает пакеты параллельно. Десять пакетов с интеграционными тестами — десять контейнеров одновременно, и «мы же свели всё к одному контейнеру» перестаёт быть правдой. Если это много, ограничивайте -p, а не рассчитывайте на арифметику из этой статьи.

Мелочи, которые видно только на своём коде. Образ стоит закреплять по digest, а не плавающим тегом: postgres:16-alpine молча переезжает на новый патч-релиз, и замеры разных дней начинают сравнивать разные образы. Контекст на подъём контейнера должен быть заведомо больше таймаута стратегии ожидания — иначе отказ приходит как безликое context deadline exceeded из библиотеки вместо «порт не открылся за 60 секунд», и лог перестаёт быть уликой. И уборку контейнера регистрируйте до проверки ошибки его создания: неудачный запуск может оставить частично созданный контейнер, а t.Fatalf строкой выше не даст его убрать — тот самый мусор, ради которого всё затевалось.

Сколько из этого — заслуга приёма

Три оговорки, без которых цифры выше вводят в заблуждение.

Замеры сняты на WSL2 поверх Windows. Старт контейнера там дорог как нигде: файловая система, сеть, прослойка виртуализации. На линуксовом раннере база была бы заметно лучше, а значит и множитель скромнее. Ждать ×36 у себя не стоит — ждать стоит того же порядка изменения, но с другим коэффициентом.

Замер, который переживает такие оговорки, строится иначе — с повторами, ротацией порядка и доверительными интервалами; как это выглядит на практике, показывает разбор регрессии crypto/rsa. Здесь такой строгости нет и не требуется: разница между секундами и минутами не нуждается в benchstat, чтобы быть заметной.

Эффектов на самом деле два, и они разного размера. Первый — перестать создавать двести шестьдесят шесть контейнеров. Второй — клонировать шаблон вместо повторного наката миграций. Основная часть выигрыша от первого; второй нужен для того, чтобы первый не пришлось оплачивать потерей изоляции. Если у вас контейнер поднимается один раз, а базы вы чистите TRUNCATE между тестами — вы уже забрали большую часть выигрыша, и TEMPLATE даст скорее аккуратность, чем секунды.

Приём не новый. Шаблонные базы Postgres — известный трюк, переиспользование контейнеров задокументировано в testcontainers. Здесь ценность не в изобретении, а в измеренном до/после и в том, что переход обошёлся без правки 266 вызовов.

Побочная находка: дефолты клиента и дефолты сети

В том же разборе всплыло отдельное: два теста проверяли поведение при недоступном Redis и подключались к 127.0.0.1:1. Ожидание — мгновенный отказ. Под WSL2 соединение висело до исчерпания дефолтов go-redis — около 108 секунд на тест.

Лечится в тесте, а не в продакшн-коде:

func brokenSessions() *auth.Sessions {
    return auth.NewSessions(redis.NewClient(&redis.Options{
        Addr:        "127.0.0.1:1",
        DialTimeout: 50 * time.Millisecond,
        MaxRetries:  -1, // без ретраев
    }), time.Hour)
}

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

Что забрать с собой

  • Посчитайте вызовы тестового хелпера. Не «тесты медленные», а «сколько раз мы поднимаем контейнер». Число обычно объясняет всё.
  • Падение ровно на границе таймаута — повод считать ресурсы, а не перезапускать. Посмотрите docker ps -a после прогона и чем занят демон в момент отказа. Пока не посмотрели — это гипотеза, а не причина.
  • Изоляция нужна на уровне базы, а не контейнера. CREATE DATABASE ... TEMPLATE даёт полную изоляцию за десятки миллисекунд — в пределах, перечисленных выше.
  • sync.Once для подъёма, TestMain для уборки. Первый позволяет не трогать вызовы, второй — не зависеть от реапера, который в CI может быть отключён.
  • Один контейнер получается на пакет, а не на прогон. go test гоняет пакеты параллельно; считайте по числу пакетов.
  • Миграции в тестах — боевым путём. Свой исполнитель означает, что тесты проверяют не то, что поедет на стенд.
  • WITH (FORCE) в DROP DATABASE — иначе уборка вероятностная.
  • В тестах на отказ задавайте таймауты явно. Иначе меряете терпение клиентской библиотеки.

Обсуждение в Telegram

Присоединиться →

Комментарии