---
title: "Грабли: порты, скорость и флейки"
description: "Грабли testcontainers: контейнер не стартует, тест зелёный случайно, гонки имён, утечки без Terminate, грязный reuse и медленный CI — симптом и лечение."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/23-grabli/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Testcontainers в Go: интеграционные тесты с Docker», урок 23 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testcontainers/lessons/23-grabli/*

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

Грабли с контейнерами редко изобретают заново: список короткий, и почти каждый провал укладывается в пять групп. Первая — контейнер не запускается: опечатка в образе, незадекларированный порт, стратегия, которая ждёт того, чего нет. Вторая — тест зелёный случайно: 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"). Что вернёт вызов?

- Ноль: порт ещё не назначен, но ошибок не будет
- Внутренний порт контейнера: маппинг для этого не нужен
- ✅ Ошибку «port not found»: у незапущенного контейнера нет биндингов
- Контейнер запустится автоматически ради маппинга

MappedPort читает инспекцию контейнера и ищет совпадение среди биндингов; у созданного, но не запущенного контейнера таблица биндингов пуста, и вызов возвращает not found. Маппинг появляется при старте, поэтому порт забирают после GenericContainer со Started: true или после ручного Start.

### Зелёный случайно: Sleep вместо готовности

Флейковые тесты go-пакетов почти всегда начинаются одинаково: контейнер стартовал, сервис ещё думает, а тест уже стучится в порт — и кто-то подставляет time.Sleep на пару секунд. Локально это работает: машина свободна, образ прогрет, сервис успевает подняться с запасом. На CI под соседними джобами те же две секунды кончаются мгновенно, и тест начинает флапать: падает без изменения кода, лечится повторным запуском и подрывает доверие к зелёному экрану. Sleep — ставка на худший из миров: слишком короткий интервал даёт флейки, слишком длинный — минуты лишнего ожидания в каждом прогоне. Отдельный соблазн — увеличивать интервал после каждого падения: цифра растёт, диагноз так и не ставится, а следующий раннер найдёт ещё более медленную машину. Лечение одно: готовность не угадывают, а наблюдают. wait-стратегия — порт, строка в логе, HTTP-проба — возвращает управление ровно тогда, когда сервис реально отвечает, а таймаут отсекает бесконечное ожидание явной ошибкой. Выбор стратегии и тонкости таймаутов разобраны в [уроке об ожиданиях](/courses/testcontainers/lessons/12-ozhidaniya-wait/). Правило простое: каждое ожидание в тесте должно отвечать на вопрос «что именно я наблюдаю», а Sleep на этот вопрос не отвечает.

### Что случится с тестом на CI

```go
// Тест пишет в Redis сразу после запуска контейнера:
//     ctr, _ := redis.Run(ctx, "redis:7-alpine")
//     time.Sleep(2 * time.Second) // «прогрев»
//     client.Set(ctx, "k", 1, 0)
// Локально стабильно зелёный; в CI под нагрузкой — ?
```

**Что выведет программа?**

- ✅ Тест станет флапающим: Sleep не гарантирует готовность, и на медленной машине интервал кончится раньше
- Ничего: Sleep гарантирует готовность любого сервиса при достаточном интервале
- Библиотека заметит Sleep и подставит wait-стратегию вместо него
- Тест замедлится, но надёжность останется прежней

Sleep измеряет время, а не готовность: на свободной машине сервис успевает, под нагрузкой — нет, и тест начинает падать без изменения кода. Готовность наблюдают wait-стратегией — по порту, логу или HTTP-пробе; она возвращает управление сразу, как только сервис реально отвечает.

> **Живое место: Sleep, который держался годами.** Классика жанра: тест с time.Sleep(2 * time.Second) живёт в проекте год и вдруг краснеет после обновления CI-раннера или просто в час пик. Код не менялся — изменились условия: сервис стал подниматься на полсекунды дольше, и запас сна кончился. Такие тесты опаснее падающих: они приучают команду к кнопке «перезапустить». Найдите Sleep рядом со стартом контейнера и замените его wait-стратегией — даже если он пока не подводит. Замена занимает десять минут, а экономит часы разбора первого настоящего флейка.

### Гонки: имя, Reuse и грязное состояние

Параллельный прогон вскрывает грабли, невидимые на одном воркере. Фиксированный Name — ресурс в единственном экземпляре: два теста с одним именем стартуют одновременно, и второй create падает с конфликтом; имя оправдано только тогда, когда оно нужно протоколу — например, режиму Reuse. Reuse без имени падает ещё раньше: проверка стоит в самом начале GenericContainer и отклоняет пустое имя до всякого Docker. Сам режим переиспользования добавляет сюжет про грязное состояние: контейнер живёт между тестами и помнит всё, что они записали, поэтому третий тест внезапно проваливается из-за данных, оставленных первым. Общий контейнер требует дисциплины: уникальные ключи и схемы от t.Name(), TRUNCATE и FLUSHDB в Cleanup, идемпотентные миграции — приёмы разобраны в [уроке про reuse](/courses/testcontainers/lessons/16-povtornoe-ispolzovanie/) и теме про параллельность. И помните: мьютекс внутри библиотеки защищает только сам запуск контейнера, но не данные внутри него. Симптом гонки коварен: локально всё зелёное, а падение появляется только на CI с несколькими воркерами — такой флейк стоит искать в общих ресурсах. Отсюда и правило диагностики: прежде чем грешить на библиотеку, воспроизведите прогон с двумя воркерами локально — большинство гонок видно и на одной машине.

### Проверка: Reuse без имени

**Вопрос:** Тест передал Reuse: true, но забыл задать Name. Что произойдёт при вызове GenericContainer?

- Docker сам сгенерирует имя и переиспользует подходящий контейнер
- Библиотека создаст новый контейнер, а режим Reuse тихо отключится
- ✅ GenericContainer вернёт ошибку про пустое имя ещё до обращения к Docker
- Контейнер запустится, но порты не смаппятся

Валидация стоит в самом начале GenericContainer: Reuse с пустым именем отклоняется ошибкой ErrReuseEmptyName до создания провайдера — никакой автогенерации имён не предусмотрено. Режим переиспользования ищет контейнер строго по имени, поэтому имя обязательно. Лечение: задать Name в декларации или убрать Reuse, если изоляция важнее скорости.

### Утечки: забытый Terminate и роль Ryuk

Утечку обнаруживают по косвенным признакам: docker ps после прогона показывает рой контейнеров, машина заканчивает память, CI-раннер не может забрать порт. Причина всегда одна — уборка не зарегистрирована или сработала не там, где вы думали. Дисциплина простая: CleanupContainer или t.Cleanup с Terminate пишут сразу после создания контейнера, до проверки ошибки, — тогда уборка исполнится при любом исходе, включая панику и t.FailNow. Библиотека здесь на вашей стороне: Terminate не считает ошибкой «не найден» и «удаление уже идёт», поэтому дублировать уборку безопасно, а пропускать — нет. Страховочный слой — уборщик-супервизор: он помечает ресурсы служебными метками и зачищает их после смерти процесса-владельца, так что убитая джоба не оставляет зомби навсегда. Но между тестами зомби живут зря, съедая память и порты, поэтому CleanupContainer — не бюрократия, а освобождение ресурса в момент, когда он больше не нужен. Отдельный подвох — ручной defer в хелпере: параллельные горутины и defer легко расходятся по порядку, а Cleanup-функции фреймворк исполняет сам и в правильный момент. Проверка дешёвая: один взгляд на docker ps после прогона — и утечка не доедет до продакшена. Механику уборщика во всех деталях разбирает [урок про Ryuk](/courses/testcontainers/lessons/18-ryuk-i-resursy/).

> **Про песочницу: грабли проверяют на живом Docker.** Честная оговорка: в песочнице сайта код урока не запустится — контейнеры требуют живого демона Docker, а браузерная среда его не даёт. Схема локальная: сохраните фрагменты в файл *_test.go рядом с go.mod, запустите go test и сломайте их нарочно: уберите Started, замените стратегию на Sleep, закомментируйте CleanupContainer. Половина граблей закрепляется только через собственный красный тест, и локальный Docker — лучшее место для таких экспериментов. Локально подойдёт и Docker Desktop, и Podman-провайдер — важно, чтобы демон отвечал на health-запросы.

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

Скорость на CI теряется двумя способами, и первый — сеть: каждая джоба тянет образ заново, десятки секунд на каждый контейнер. Лечение — прогрев: пре-пулльте образы до go test и кэшируйте каталог Docker-данных между запусками; готовый workflow и оговорки про кэш разобраны в [уроке про CI](/courses/testcontainers/lessons/20-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: контексты тестов к тому моменту уже отменены, а если процесс умрёт раньше — остатки зачистит уборщик по служебным меткам.

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

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

```go
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")
```

Подсказка: Готовность ждёт стратегия в WaitingFor, политика запуска Started стоит в обёртке, а уборку регистрирует CleanupContainer — до проверки ошибки.

Правильные ответы в пропусках: `WaitingFor`, `Started`, `CleanupContainer`

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

| Ошибка | Симптом | Лечение |
| --- | --- | --- |
| Забыт 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 горит | один контейнер на пакет плюс чистка данных |

### Порядок диагностики упавшего старта

Расставьте шаги: от чтения ошибки до изменения кода теста.

Правильный порядок строк:

1. `прочитать ошибку GenericContainer: обёртка называет этап — create, start или wait`
2. `create упал — сверить имя и тег образа с тем, что лежит в реестре`
3. `start упал — открыть логи контейнера: причина обычно в конфиге или команде`
4. `wait упал — сверить порт в стратегии с ExposedPorts декларации`
5. `поднять образ руками docker run с теми же флагами и воспроизвести отказ`
6. `добавить логгер библиотеки, чтобы видеть шаги старта в выводе теста`
7. `проверить, не забирает ли тест порт раньше, чем контейнер стартовал`
8. `только после диагноза менять код теста, а не наугад увеличивать Sleep`

### Чек-лист: тест с контейнером готов к 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 и до создания провайдера, поэтому режим не работает «по интуиции» — имя обязательно.
- **port not found** — Ответ MappedPort и wait-стратегии, когда маппинга нет: порт не экспонирован, контейнер не стартовал или указан не тот порт и протокол. Лечится сверкой ExposedPorts и стратегии.
- **CleanupContainer** — Хелпер, регистрирующий Terminate в t.Cleanup: зовётся до проверки ошибки, nil-контейнер — no-op. Безопасные ошибки уборки (not found, removal in progress) проглатываются.
- **Флейк** — Тест, падающий без изменения кода: чаще всего Sleep вместо wait-стратегии, гонка фиксированных имён или грязное состояние переиспользуемого контейнера. Лечится наблюдением готовности и изоляцией.
- **Контейнер на пакет** — Фикстура TestMain: один контейнер на пакет тестов, данные чистит каждый тест. Terminate после m.Run со свежим context.Background; умерший процесс не оставляет зомби — их стирает уборщик.
- **Этапы ошибки старта** — Обёртка ошибки называет этап: create — образ и декларация, start — процесс и конфиг, wait — порт и готовность. По этапу сразу видно, где искать причину.

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

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