Содержание урока · 10 разделов
- Контейнер не запускается: образ, порт, ожидание
- Зелёный случайно: Sleep вместо готовности
- Гонки: имя, Reuse и грязное состояние
- Утечки: забытый Terminate и роль Ryuk
- Медленный CI: кэш образов и цена контейнера
- Ошибка → симптом → лечение
- Чек-лист: тест с контейнером готов к PR
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Грабли с контейнерами редко изобретают заново: список короткий, и почти каждый провал укладывается в пять групп. Первая — контейнер не запускается: опечатка в образе, незадекларированный порт, стратегия, которая ждёт того, чего нет. Вторая — тест зелёный случайно: time.Sleep подпирает готовность, и на быстрой машине удача держится месяцами. Третья — гонки при параллельности: фиксированное имя, общий контейнер без синхронизации, данные, которые один тест оставил другому. Четвёртая — утечки: забытый Terminate превращает прогон в склад контейнеров, пока за дело не возьмётся уборщик. Пятая — медленный CI: свежий пулл образа на каждую джобу и контейнер на каждый тест. Это и есть типовые testcontainers ошибки, и диагностика у них общая: обёртка ошибки называет этап — create, start или wait, — и по этапу сразу видно, куда смотреть. Часть историй здесь заканчивается исправлением одной строки, часть — переделкой фикстуры: разница видна по симптомам, которые собраны в таблице в конце урока. Дальше — по грабле на раздел, с таблицей и чек-листом в финале.
Контейнер не запускается: образ, порт, ожидание#
Стартовый провал случается на трёх этажах, и этаж подсказывает сама обёртка ошибки. Create падает раньше всякого процесса приложения: опечатка в имени или теге образа, недопустимая комбинация полей декларации — Docker даже не пытался ничего запускать, и лечение начинается со сверки Image. Start падает, когда процесс умирает мгновенно: битая команда, невалидный конфиг, занятый внутри порт — тест видит обёртку start container, а настоящую причину ищут в логах контейнера. Третий этаж самый коварный: wait-стратегия ждёт порт, который никто не экспонировал. Сервис порт слушает, но в ExposedPorts его нет — библиотека честно опрашивает биндинги, не находит их и валится по таймауту ожидания; строка «port not found» означает, что маппинга не существует, а не что сервис медленный. Сюда же примыкает MappedPort до старта: у созданного, но не запущенного контейнера биндингов ещё нет, и вместо хостового порта вернётся та же ошибка. Для диагностики пригодится и логгер библиотеки: с ним в выводе теста видны шаги старта, а вместе с логами контейнера — почти весь маршрут ошибки. Порядок железный: экспонировать порт, дождаться стратегии и только потом забирать хост и порт.
Правильный старт: готовность, уборка, порт
GOfunc TestCache_SetGet(t *testing.T) {
ctx := context.Background()
ctr, err := redis.Run(ctx, "redis:7-alpine")
// регистрация до проверки ошибки: nil-контейнер — no-op
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatalf("старт redis: %v", err)
}
host, err := ctr.Host(ctx)
if err != nil {
t.Fatalf("хост: %v", err)
}
port, err := ctr.MappedPort(ctx, "6379/tcp")
if err != nil {
t.Fatalf("порт: %v", err)
}
log.Printf("redis готов: %s:%s", host, port)
}Модуль сам дописывает экспозицию порта и wait-стратегию: redis.Run возвращает уже готовый сервис, и половина граблей старта исчезает. CleanupContainer зовут до проверки ошибки — nil-контейнер для него безвреден, а регистрация в Cleanup переживёт и провал assert'ов, и панику. MappedPort берут после возврата Run: до старта биндингов нет, и вместо порта вернётся not found. Ошибка старта называет этап — create, start или wait.
Проверка: MappedPort до старта
Контейнер создан со Started: false, и тест сразу вызвал ctr.MappedPort(ctx, "8080/tcp"). Что вернёт вызов?
Зелёный случайно: Sleep вместо готовности#
Флейковые тесты go-пакетов почти всегда начинаются одинаково: контейнер стартовал, сервис ещё думает, а тест уже стучится в порт — и кто-то подставляет time.Sleep на пару секунд. Локально это работает: машина свободна, образ прогрет, сервис успевает подняться с запасом. На CI под соседними джобами те же две секунды кончаются мгновенно, и тест начинает флапать: падает без изменения кода, лечится повторным запуском и подрывает доверие к зелёному экрану. Sleep — ставка на худший из миров: слишком короткий интервал даёт флейки, слишком длинный — минуты лишнего ожидания в каждом прогоне. Отдельный соблазн — увеличивать интервал после каждого падения: цифра растёт, диагноз так и не ставится, а следующий раннер найдёт ещё более медленную машину. Лечение одно: готовность не угадывают, а наблюдают. wait-стратегия — порт, строка в логе, HTTP-проба — возвращает управление ровно тогда, когда сервис реально отвечает, а таймаут отсекает бесконечное ожидание явной ошибкой. Выбор стратегии и тонкости таймаутов разобраны в уроке об ожиданиях. Правило простое: каждое ожидание в тесте должно отвечать на вопрос «что именно я наблюдаю», а Sleep на этот вопрос не отвечает.
Что случится с тестом на CI
Прочитайте код и выберите, что он выведет:
// Тест пишет в Redis сразу после запуска контейнера:
// ctr, _ := redis.Run(ctx, "redis:7-alpine")
// time.Sleep(2 * time.Second) // «прогрев»
// client.Set(ctx, "k", 1, 0)
// Локально стабильно зелёный; в CI под нагрузкой — ?Классика жанра: тест с time.Sleep(2 * time.Second) живёт в проекте год и вдруг краснеет после обновления CI-раннера или просто в час пик. Код не менялся — изменились условия: сервис стал подниматься на полсекунды дольше, и запас сна кончился. Такие тесты опаснее падающих: они приучают команду к кнопке «перезапустить». Найдите Sleep рядом со стартом контейнера и замените его wait-стратегией — даже если он пока не подводит. Замена занимает десять минут, а экономит часы разбора первого настоящего флейка.
Гонки: имя, Reuse и грязное состояние#
Параллельный прогон вскрывает грабли, невидимые на одном воркере. Фиксированный Name — ресурс в единственном экземпляре: два теста с одним именем стартуют одновременно, и второй create падает с конфликтом; имя оправдано только тогда, когда оно нужно протоколу — например, режиму Reuse. Reuse без имени падает ещё раньше: проверка стоит в самом начале GenericContainer и отклоняет пустое имя до всякого Docker. Сам режим переиспользования добавляет сюжет про грязное состояние: контейнер живёт между тестами и помнит всё, что они записали, поэтому третий тест внезапно проваливается из-за данных, оставленных первым. Общий контейнер требует дисциплины: уникальные ключи и схемы от t.Name(), TRUNCATE и FLUSHDB в Cleanup, идемпотентные миграции — приёмы разобраны в уроке про reuse и теме про параллельность. И помните: мьютекс внутри библиотеки защищает только сам запуск контейнера, но не данные внутри него. Симптом гонки коварен: локально всё зелёное, а падение появляется только на CI с несколькими воркерами — такой флейк стоит искать в общих ресурсах. Отсюда и правило диагностики: прежде чем грешить на библиотеку, воспроизведите прогон с двумя воркерами локально — большинство гонок видно и на одной машине.
Проверка: Reuse без имени
Тест передал Reuse: true, но забыл задать Name. Что произойдёт при вызове GenericContainer?
Утечки: забытый Terminate и роль Ryuk#
Утечку обнаруживают по косвенным признакам: docker ps после прогона показывает рой контейнеров, машина заканчивает память, CI-раннер не может забрать порт. Причина всегда одна — уборка не зарегистрирована или сработала не там, где вы думали. Дисциплина простая: CleanupContainer или t.Cleanup с Terminate пишут сразу после создания контейнера, до проверки ошибки, — тогда уборка исполнится при любом исходе, включая панику и t.FailNow. Библиотека здесь на вашей стороне: Terminate не считает ошибкой «не найден» и «удаление уже идёт», поэтому дублировать уборку безопасно, а пропускать — нет. Страховочный слой — уборщик-супервизор: он помечает ресурсы служебными метками и зачищает их после смерти процесса-владельца, так что убитая джоба не оставляет зомби навсегда. Но между тестами зомби живут зря, съедая память и порты, поэтому CleanupContainer — не бюрократия, а освобождение ресурса в момент, когда он больше не нужен. Отдельный подвох — ручной defer в хелпере: параллельные горутины и defer легко расходятся по порядку, а Cleanup-функции фреймворк исполняет сам и в правильный момент. Проверка дешёвая: один взгляд на docker ps после прогона — и утечка не доедет до продакшена. Механику уборщика во всех деталях разбирает урок про Ryuk.
Честная оговорка: в песочнице сайта код урока не запустится — контейнеры требуют живого демона Docker, а браузерная среда его не даёт. Схема локальная: сохраните фрагменты в файл *_test.go рядом с go.mod, запустите go test и сломайте их нарочно: уберите Started, замените стратегию на Sleep, закомментируйте CleanupContainer. Половина граблей закрепляется только через собственный красный тест, и локальный Docker — лучшее место для таких экспериментов. Локально подойдёт и Docker Desktop, и Podman-провайдер — важно, чтобы демон отвечал на health-запросы.
Медленный CI: кэш образов и цена контейнера#
Скорость на CI теряется двумя способами, и первый — сеть: каждая джоба тянет образ заново, десятки секунд на каждый контейнер. Лечение — прогрев: пре-пулльте образы до go test и кэшируйте каталог Docker-данных между запусками; готовый workflow и оговорки про кэш разобраны в уроке про CI. Второй способ дороже — контейнер на каждый тест: сотня тестов даёт сотню стартов, каждый со своей wait-стратегией, своей уборкой и своим шансом флапнуть. Дешевле поднимать один контейнер на пакет в TestMain и чистить данные между тестами: пересоздание оправдано, только когда состояние принципиально нельзя сбросить — свежие миграции, расширения, конфиги с правами. Цена старта различается на порядки: образ уже на машине — секунды, образ тянется по сети — десятки секунд. Заодно выигрывает и стабильность: чем меньше стартов, тем меньше мест, где тест ловит гонку за свободный порт. Рабочее правило: сначала измерьте, сколько реально стоит старт контейнера в вашем прогоне, потом оптимизируйте. Цена замеряется просто: запустите пакет с одним тестом и с двумя и вычтите разницу — это и есть стоимость одного контейнера.
Один контейнер на пакет: TestMain
GOvar redisAddr string
func TestMain(m *testing.M) {
ctx := context.Background()
ctr, err := redis.Run(ctx, "redis:7-alpine")
if err != nil {
log.Fatalf("контейнер для пакета: %v", err)
}
host, err := ctr.Host(ctx)
if err != nil {
log.Fatalf("хост: %v", err)
}
port, err := ctr.MappedPort(ctx, "6379/tcp")
if err != nil {
log.Fatalf("порт: %v", err)
}
redisAddr = net.JoinHostPort(host, port.Port())
code := m.Run()
_ = ctr.Terminate(context.Background())
os.Exit(code)
}Дорогой контейнер поднимают один раз на пакет: сотня тестов получит один Redis вместо сотни запусков — самый дешёвый способ ускорить CI. Данные между тестами чистит каждая фикстура: FLUSHDB в t.Cleanup дешевле пересоздания. Terminate зовут после m.Run со свежим context.Background: контексты тестов к тому моменту уже отменены, а если процесс умрёт раньше — остатки зачистит уборщик по служебным меткам.
Соберите безопасный тест
Восстановите тест: готовность, политика запуска и уборка на своих местах.
Ошибка → симптом → лечение#
| Ошибка | Симптом | Лечение |
|---|---|---|
| Забыт Started: true | запросы падают с connection refused на ровном месте | поставить Started: true или вызвать ctr.Start руками |
| Порт ждут, но не экспонировали | старт висит и падает по таймауту: port not found | добавить порт в ExposedPorts и в wait-стратегию |
| MappedPort до старта | ошибка «port not found» сразу после создания | забирать порт после GenericContainer или Start |
| Reuse без фиксированного Name | ошибка про пустое имя ещё до Docker | задать Name в декларации или убрать Reuse |
| Sleep вместо wait-стратегии | тест зелёный локально и флапает на CI | ждать порт, лог или HTTP-пробу через wait |
| Забытый t.Cleanup и Terminate | рой контейнеров после прогона, кончились порты | CleanupContainer сразу после создания контейнера |
| Фиксированный Name без нужды | конфликт имён при параллельном прогоне | убрать Name: уникальное имя сгенерирует Docker |
| Контейнер на каждый тест | пакет идёт минутами, CI горит | один контейнер на пакет плюс чистка данных |
Порядок диагностики упавшего старта
Расставьте шаги: от чтения ошибки до изменения кода теста.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чек-лист: тест с контейнером готов к PR#
- Started: true или явный Start: контейнер реально запущен до первого запроса
- wait-стратегия вместо Sleep: порт, лог или HTTP-проба с явным таймаутом
- CleanupContainer или t.Cleanup с Terminate зарегистрированы до проверки ошибки
- контекст уборки — context.Background: t.Context() внутри Cleanup уже отменён
- Name не задан без нужды: параллельный прогон не упрётся в конфликт имён
- данные между тестами чистятся: TRUNCATE, FLUSHDB или префиксы от t.Name()
- образ с закреплённым тегом из стабильного реестра, а не плавающий latest
- после прогона docker ps пуст: ни зомби, ни осиротевших сетей и томов
Грабли — это не позор, а карта: каждая запись из этого урока экономит вечер отладки. Пробегите таблицу перед PR, сверитесь с чек-листом, и большинство провалов перестанет быть сюрпризом: старт диагностируется по этапу ошибки, готовность наблюдается стратегией, гонки лечатся изоляцией, утечки — дисциплиной уборки, медленный CI — прогревом образов и одним контейнером на пакет. Дальше по курсу два шага: в двадцать четвёртом уроке приёмы курса соберутся в тестовые хелперы и структуру проекта, чтобы грабли не обходить заново в каждом репозитории, а в итоговом проекте вы построите целый интеграционный стенд из нескольких контейнеров. Если что-то из сегодняшнего списка у вас уже случалось — закрепите знание на своём репозитории: локальный прогон с нарочно сломанным тестом запоминается лучше любого конспекта, а песочница здесь лишь помощник для чтения кода. И вернитесь к этому списку через месяц: второе прочтение после практики отмечает грабли, которые вы успели поймать сами, — лучший способ измерить прогресс.
Карточки: грабли и лечение
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пять групп граблей: старт, случайная зелень, гонки, утечки, медленный CI — у каждой свой симптом и проверенное лечение.
- Ошибка testcontainers называет этап: create — образ и декларация, start — процесс и конфиг, wait — порт и готовность.
- Готовность наблюдают, а не угадывают: wait-стратегия вместо Sleep; порт экспортируют до ожидания и забирают после старта.
- Уборку регистрируют до проверки ошибки: CleanupContainer в t.Cleanup, а страховка на случай смерти процесса — уборщик Ryuk.
Домашнее задание
Устройте охоту на грабли в своём проекте: найдите Sleep рядом со стартом контейнера и замените его wait-стратегией; проверьте, что CleanupContainer стоит до проверки ошибки; пометьте два теста t.Parallel и убедитесь, что конфликтов имён нет. Затем закомментируйте уборку и найдите зомби через docker ps — лучшая прививка от утечек.
Личные заметкиПусто
Вопросы и ответы по уроку
Тест падает раз в неделю на CI — с чего начать разбор?
С повторяемости: прогоните тест локально раз двадцать с тем же кодом и нагрузите машину соседними задачами — флейк обычно воспроизводится. Затем прочитайте ошибку по этапам: create, start или wait. Падение на wait означает негарантированную готовность — ищите Sleep или слабую стратегию; на start — сервису не хватает ресурсов под нагрузкой; на create — гонку имён или конфликт ресурсов. Гонки лечатся изоляцией: уникальные имена, отдельные схемы, чистка данных. Финальный штрих — сравнить вывод падения с выводом зелёного прогона: различие покажет, на каком этапе поведение расходится.
Один контейнер на пакет или контейнер на каждый тест?
По умолчанию — контейнер на каждый тест: максимальная изоляция, никакой утечки состояния, а цена — секунды на старт. Переходите на один контейнер на пакет, когда тестов много или старт дороги: поднимайте в TestMain, чистите данные между тестами и зовите Terminate после m.Run. Порог простой: если пакет занимает минуты из-за стартов — объединяйте; если тесты конфликтуют по данным — сначала чистка, потом объединение. Возможен и компромисс на уровне группы тестов: общий контейнер для смоука, отдельные — для тестов на изоляцию.
Что делать, если тест зелёный локально и красный в CI?
Сравнивайте условия, а не код: свободная машина против нагруженного раннера, прогретый образ против свежего пулла, одна джоба против параллельных соседей. Проверьте по чек-листу: есть ли wait-стратегия с явным таймаутом, зарегистрирована ли уборка, нет ли фиксированных имён. Поднимите таймауты ожидания до реалистичных и добавьте логи контейнера в вывод теста — флейк превратится в обычную диагностируемую ошибку.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах