Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~18 мин +50 XP

Грабли: порты, скорость и флейки

За курс накопился ворох синяков: контейнер не стартует без видимой причины, тест зелёный сегодня и красный завтра, после прогона остаются контейнеры-зомби, а CI ползёт минутами. Типовые testcontainers ошибки собраны в этом уроке по схеме «симптом — причина — лечение»: новой теории нет, только опыт предыдущих тем, свёрнутый в справочник, который удобно перечитать перед PR. Отдельный сюжет — флейковые тесты go-сервисов, падающие без изменения кода: почти всегда виноват не тест, а пропущенная готовность сервиса, гонка имён или Sleep вместо стратегии ожидания. Список стоит держать под рукой до конца карьеры: хотя бы одна грабля уже лежала на вашем пути, а вторая ждёт на первом параллельном прогоне. Каждая запись здесь честная — с реальным симптомом и проверенным лекарством, без магии и догадок. Часть приёмов покажется знакомой — это специально: собранный в одном месте список работает как предполётный осмотр перед каждым прогоном.

Содержание урока · 10 разделов

Грабли с контейнерами редко изобретают заново: список короткий, и почти каждый провал укладывается в пять групп. Первая — контейнер не запускается: опечатка в образе, незадекларированный порт, стратегия, которая ждёт того, чего нет. Вторая — тест зелёный случайно: time.Sleep подпирает готовность, и на быстрой машине удача держится месяцами. Третья — гонки при параллельности: фиксированное имя, общий контейнер без синхронизации, данные, которые один тест оставил другому. Четвёртая — утечки: забытый Terminate превращает прогон в склад контейнеров, пока за дело не возьмётся уборщик. Пятая — медленный CI: свежий пулл образа на каждую джобу и контейнер на каждый тест. Это и есть типовые testcontainers ошибки, и диагностика у них общая: обёртка ошибки называет этап — create, start или wait, — и по этапу сразу видно, куда смотреть. Часть историй здесь заканчивается исправлением одной строки, часть — переделкой фикстуры: разница видна по симптомам, которые собраны в таблице в конце урока. Дальше — по грабле на раздел, с таблицей и чек-листом в финале.

Контейнер не запускается: образ, порт, ожидание#

Стартовый провал случается на трёх этажах, и этаж подсказывает сама обёртка ошибки. Create падает раньше всякого процесса приложения: опечатка в имени или теге образа, недопустимая комбинация полей декларации — Docker даже не пытался ничего запускать, и лечение начинается со сверки Image. Start падает, когда процесс умирает мгновенно: битая команда, невалидный конфиг, занятый внутри порт — тест видит обёртку start container, а настоящую причину ищут в логах контейнера. Третий этаж самый коварный: wait-стратегия ждёт порт, который никто не экспонировал. Сервис порт слушает, но в ExposedPorts его нет — библиотека честно опрашивает биндинги, не находит их и валится по таймауту ожидания; строка «port not found» означает, что маппинга не существует, а не что сервис медленный. Сюда же примыкает MappedPort до старта: у созданного, но не запущенного контейнера биндингов ещё нет, и вместо хостового порта вернётся та же ошибка. Для диагностики пригодится и логгер библиотеки: с ним в выводе теста видны шаги старта, а вместе с логами контейнера — почти весь маршрут ошибки. Порядок железный: экспонировать порт, дождаться стратегии и только потом забирать хост и порт.

Правильный старт: готовность, уборка, порт

GO
func 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 под нагрузкой — ?

Гонки: имя, 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.

Медленный CI: кэш образов и цена контейнера#

Скорость на CI теряется двумя способами, и первый — сеть: каждая джоба тянет образ заново, десятки секунд на каждый контейнер. Лечение — прогрев: пре-пулльте образы до go test и кэшируйте каталог Docker-данных между запусками; готовый workflow и оговорки про кэш разобраны в уроке про CI. Второй способ дороже — контейнер на каждый тест: сотня тестов даёт сотню стартов, каждый со своей wait-стратегией, своей уборкой и своим шансом флапнуть. Дешевле поднимать один контейнер на пакет в TestMain и чистить данные между тестами: пересоздание оправдано, только когда состояние принципиально нельзя сбросить — свежие миграции, расширения, конфиги с правами. Цена старта различается на порядки: образ уже на машине — секунды, образ тянется по сети — десятки секунд. Заодно выигрывает и стабильность: чем меньше стартов, тем меньше мест, где тест ловит гонку за свободный порт. Рабочее правило: сначала измерьте, сколько реально стоит старт контейнера в вашем прогоне, потом оптимизируйте. Цена замеряется просто: запустите пакет с одним тестом и с двумя и вычтите разницу — это и есть стоимость одного контейнера.

Один контейнер на пакет: TestMain

GO
var 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: контексты тестов к тому моменту уже отменены, а если процесс умрёт раньше — остатки зачистит уборщик по служебным меткам.

Соберите безопасный тест

Восстановите тест: готовность, политика запуска и уборка на своих местах.

ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "myapp/service", ExposedPorts: []string{"8080/tcp"}, : wait.ForListeningPort("8080/tcp"), }, : true, }) (t, ctr) if err != nil { t.Fatalf("старт контейнера: %v", err) } port, err := ctr.MappedPort(ctx, "8080/tcp")

Ошибка → симптом → лечение#

ОшибкаСимптомЛечение
Забыт 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 — прогревом образов и одним контейнером на пакет. Дальше по курсу два шага: в двадцать четвёртом уроке приёмы курса соберутся в тестовые хелперы и структуру проекта, чтобы грабли не обходить заново в каждом репозитории, а в итоговом проекте вы построите целый интеграционный стенд из нескольких контейнеров. Если что-то из сегодняшнего списка у вас уже случалось — закрепите знание на своём репозитории: локальный прогон с нарочно сломанным тестом запоминается лучше любого конспекта, а песочница здесь лишь помощник для чтения кода. И вернитесь к этому списку через месяц: второе прочтение после практики отмечает грабли, которые вы успели поймать сами, — лучший способ измерить прогресс.

Карточки: грабли и лечение

Термин

ErrReuseEmptyName

нажмите, чтобы перевернуть
Определение

Ошибка GenericContainer: Reuse требует непустого Name. Проверка стоит до обращения к Docker и до создания провайдера, поэтому режим не работает «по интуиции» — имя обязательно.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Пять групп граблей: старт, случайная зелень, гонки, утечки, медленный CI — у каждой свой симптом и проверенное лечение.
  • Ошибка testcontainers называет этап: create — образ и декларация, start — процесс и конфиг, wait — порт и готовность.
  • Готовность наблюдают, а не угадывают: wait-стратегия вместо Sleep; порт экспортируют до ожидания и забирают после старта.
  • Уборку регистрируют до проверки ошибки: CleanupContainer в t.Cleanup, а страховка на случай смерти процесса — уборщик Ryuk.

Домашнее задание

Устройте охоту на грабли в своём проекте: найдите Sleep рядом со стартом контейнера и замените его wait-стратегией; проверьте, что CleanupContainer стоит до проверки ошибки; пометьте два теста t.Parallel и убедитесь, что конфликтов имён нет. Затем закомментируйте уборку и найдите зомби через docker ps — лучшая прививка от утечек.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Грабли: порты, скорость и флейки)

Вопросы и ответы по уроку

Тест падает раз в неделю на CI — с чего начать разбор?

С повторяемости: прогоните тест локально раз двадцать с тем же кодом и нагрузите машину соседними задачами — флейк обычно воспроизводится. Затем прочитайте ошибку по этапам: create, start или wait. Падение на wait означает негарантированную готовность — ищите Sleep или слабую стратегию; на start — сервису не хватает ресурсов под нагрузкой; на create — гонку имён или конфликт ресурсов. Гонки лечатся изоляцией: уникальные имена, отдельные схемы, чистка данных. Финальный штрих — сравнить вывод падения с выводом зелёного прогона: различие покажет, на каком этапе поведение расходится.

Один контейнер на пакет или контейнер на каждый тест?

По умолчанию — контейнер на каждый тест: максимальная изоляция, никакой утечки состояния, а цена — секунды на старт. Переходите на один контейнер на пакет, когда тестов много или старт дороги: поднимайте в TestMain, чистите данные между тестами и зовите Terminate после m.Run. Порог простой: если пакет занимает минуты из-за стартов — объединяйте; если тесты конфликтуют по данным — сначала чистка, потом объединение. Возможен и компромисс на уровне группы тестов: общий контейнер для смоука, отдельные — для тестов на изоляцию.

Что делать, если тест зелёный локально и красный в CI?

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Go Base25 уроков~12 чновичок25 уроков уже доступны

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах