---
title: "GenericContainer: любой Docker-образ"
description: "GenericContainer для любого Docker-образа: декларация поле за полем, Started против ручного Start, Host с MappedPort, Terminate в t.Cleanup, проверка сервиса."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/11-generic-container/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# GenericContainer: любой Docker-образ

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

Модули прошлых уроков закрыли базы и брокеров, но мир образов шире: веб-сервер, файловое хранилище, внутренний демон команды — модулей на всех не напасёшься. Здесь сценарий «testcontainers любой образ» закрывается универсальным входом: GenericContainer принимает GenericContainerRequest — декларацию запуска поле за полем — и возвращает контейнер с общим интерфейсом, годный для любого демона. Связку «genericcontainer go» разберём на живых примерах: произвольный образ, Env и Cmd, выбор между Started и ручным Start, адрес сервиса через Host и MappedPort, уборка через t.Cleanup и мини-проверка доступности HTTP-ответом. Попутно сравним этот путь с модулями и современной функцией Run — и расставим, когда что выбирать. К концу урока вы поднимете в тесте что угодно: от однострочного nginx до хранилища с командой запуска и health-пробой.

GenericContainer — самая старая и самая универсальная функция библиотеки: она принимает декларацию произвольного образа и возвращает контейнер, не спрашивая, что это за сервис. Вся остальная библиотека построена вокруг неё: модули прошлых уроков — пресеты, которые собирают ту же декларацию за вас, а современная функция Run — тонкая обёртка над ней. Универсальность оборачивается ответственностью: модулю порты и стратегию готовности подсказывает автор модуля, здесь их выбираете вы. Ничего нового учить не придётся: декларация ContainerRequest разобрана в [третьем уроке](/courses/testcontainers/lessons/03-zhiznennyj-cikl/), а список нужных полей короткий — образ, окружение, порты, команда, wait-стратегия. Незнакомый демон перестаёт быть проблемой: если сервис работает в docker run, он заработает и в тесте — тот же образ, те же аргументы, тот же маппинг портов, только с гарантированной уборкой. Отдельный бонус — скорость: новый сторонний сервис в цепочке зависимостей больше не блокирует тесты — декларация, вызов, проверка. GenericContainer часто становится первым шагом знакомства с библиотекой и остаётся рабочей лошадкой для служебных контейнеров.

### GenericContainer: один вход для любого образа

Сигнатура короткая: GenericContainer принимает контекст и GenericContainerRequest, возвращает пару — контейнер и ошибку. Тип результата принципиален: не конкретная структура, а интерфейс testcontainers.Container, одинаковый для любого образа. В интерфейсе собрано всё, что нужно тесту: Host и MappedPort дают адрес, Logs отдаёт поток логов для разбора упавшего старта, Exec запускает команду внутри, CopyFileToContainer кладёт файл, Terminate прибирает. Переход от модуля к произвольному образу не меняет клиентский код: методы те же. Связка «genericcontainer go» ценна именно этим: сценарий «testcontainers любой образ» — nginx, minio, файловый прокси, внутренний демон — закрывается одним вызовом без поиска подходящего модуля и без лишней зависимости в go.mod. Ошибка возвращается на руки вместе с контейнером, если тот успел создаться: вызывающему оставляют шанс прибрать за собой — к этому моменту мы вернёмся отдельно. Интерфейс шире, чем нужно одному тесту: Inspect возвращает сырой инспект Docker, State отличает бегущий контейнер от остановленного, а SessionID отдаёт идентификатор сессии, по которому уборщик находит ресурсы прогона.

### Произвольный образ в тесте

```go
func TestNginxServes(t *testing.T) {
	ctx := context.Background()

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "nginx",
			ExposedPorts: []string{"80/tcp"},
			WaitingFor:   wait.ForHTTP("/"),
		},
		Started: true,
	})
	if err != nil {
		t.Fatalf("запуск: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(ctx) })

	host, err := ctr.Host(ctx)
	if err != nil {
		t.Fatalf("хост: %v", err)
	}
	port, err := ctr.MappedPort(ctx, "80/tcp")
	if err != nil {
		t.Fatalf("порт: %v", err)
	}

	resp, err := http.Get(fmt.Sprintf("http://%s:%s/", host, port.Port()))
	if err != nil {
		t.Fatalf("запрос: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("статус: %d", resp.StatusCode)
	}
}
```

Декларация минимальна: образ, порт с протоколом и стратегия ForHTTP, которая дёргает корень и ждёт ответа. Started: true — вызов не вернётся, пока nginx не начнёт отвечать. После возврата Host и MappedPort собирают адрес: наружный порт Docker назначил из свободных, поэтому узнают его только после старта. Terminate зарегистрирован в t.Cleanup — сработает при провале и панике. Финал — обычный http.Get: сервис проверен как настоящий.

### GenericContainerRequest: декларация и обёртка

Запрос к GenericContainer — обёртка с вложенной декларацией. Внутри — знакомый ContainerRequest: Image, Env, ExposedPorts, Cmd, Labels, Files, WaitingFor; полный разбор полей — в третьем уроке. Для произвольного образа обычно хватает четырёх: Image, Env, ExposedPorts и WaitingFor, а Cmd нужен, когда демон требует аргументов запуска. Снаружи — политика запуска, у которой четыре собственных поля. Started включает автостарт: true — create, start и ожидание готовности внутри вызова, false — только create. ProviderType выбирает провайдера: нулевое значение — автодетект по переменной DOCKER_HOST, а набор провайдеров ограничен Docker и Podman. Logger подменяет логгер для этого контейнера — спасает, когда общий вывод тонет в логах параллельного прогона. Reuse включает режим переиспользования: библиотека найдёт живой контейнер с тем же Name или создаст новый, а при пустом имени вернёт ошибку ErrReuseEmptyName — поле Name нельзя оставлять незаполненным. Перед обращением к демону декларацию проверяет Validate: попытка указать одновременно Image и контекст сборки отсекается до docker create, а пустая декларация без образа и контекста отвергается с требованием указать хоть что-то — забыть Image и получить загадочный контейнер не выйдет, тест упадёт на валидации с внятной ошибкой.

### Модуль против GenericContainer

| Критерий | Модуль | GenericContainer |
| --- | --- | --- |
| Когда выбирать | под образ есть модуль: Postgres, Redis, Kafka | модуля нет: nginx, minio, внутренний демон команды |
| Вход в API | Run из пакета модуля с опциями | GenericContainer с декларацией в руках |
| Возвращаемый тип | конкретный: *RedisContainer, *MinioContainer | общий интерфейс testcontainers.Container |
| Готовность | wait-стратегия встроена авторами модуля | стратегию выбираете сами в WaitingFor |
| Удобные методы | ConnectionString, Brokers и подобные | общие: Host, MappedPort, Logs, Exec |
| Настройка | опции модуля: WithUsername, WithConfigFile | поля декларации: Env, Cmd, Files, Mounts |
| Движок под капотом | Run, а под ним тот же GenericContainer | сам GenericContainer |

### Started: true против ручного Start

Started: true — путь по умолчанию: GenericContainer сам создаёт, стартует и ждёт готовности, возвращая сервис, с которого сразу можно брать адрес и слать запросы. Started: false возвращает созданный, но не запущенный контейнер, и момент старта переезжает в тест: ctr.Start(ctx) вызывается в выбранной вами точке. Ручной старт нужен, когда между созданием и запуском должно что-то случиться: скопировать конфиг внутрь уже созданного контейнера, дождаться соседа по стенду, подключить сеть; или когда тестируется сам запуск — команда из Cmd обязана поднять процесс, а не уронить его мгновенно. Важная деталь механики: при ошибке старта GenericContainer возвращает и ошибку, и контейнер — тот уже создан, а значит, его можно прибрать и прочитать логи. Проверка «а что, если сервис не стартует» пишется честно: Start вернул ошибку — assert на неё, Terminate в Cleanup — как обычно. Ещё одна тонкость автостарта: Started: true не перезапускает уже работающий контейнер — перед Start библиотека проверяет IsRunning и пропускает запуск, если процесс жив. Для переиспользуемых контейнеров это критично: второй вызов с тем же Name не упадёт, а тихо вернёт существующий сервис.

> **Живое место: контейнер при ошибке старта не nil.** Привычка «ошибка значит nil» здесь ломается: исходники GenericContainer прямо оставляют контейнер в руках вызывающего при провале create и start, чтобы тот мог вызвать уборку. Отсюда порядок из документации: CleanupContainer(t, ctr) вызывают сразу после GenericContainer, до проверки ошибки — на nil-контейнере это no-op, на живом — гарантированный Terminate. Практичный приём: логи упавшего старта печатают прямо в тесте перед Fatalf — поток достаётся через Logs(ctx), и причина «container exited» становится видной без перезапуска. Если пишете уборку руками, не привязывайте её к успешному запуску: err != nil с ранним выходом без уборки оставит контейнер до конца сессии.

### Host и MappedPort: адрес живого сервиса

Адрес сервиса собирается из двух вызовов. Host возвращает адрес, на котором демон выставил порт: при локальном docker это localhost, на удалённом демоне CI-раннера — его хост, поэтому адрес всегда берут из вызова, а не хардкодят. MappedPort принимает пару «порт/протокол» — например, 80/tcp — и возвращает наружный порт, который Docker назначил из свободных при старте; именно поэтому порт нельзя угадать заранее и нельзя зафиксировать декларацией. Кто не хочет склеивать адрес вручную, берёт готовое: PortEndpoint вернёт строку вида proto://host:port по заданному порту и протоколу, а Endpoint — то же для самого низкого выставленного порта. Нюанс сетевого режима host: маппинга нет, порт контейнера равен порту хоста, и MappedPort вернёт его как есть. Готовность к моменту возврата гарантирует wait-стратегия: без WaitingFor библиотека вернёт контейнер сразу после поднятия процесса, и первый запрос может прийти к сервису, который ещё не слушает порт. Каталог стратегий — порт, лог, HTTP-проба, команда — разобран в [уроке про ожидания](/courses/testcontainers/lessons/12-ozhidaniya-wait/). Порт возвращается строкой, так что склейка адреса не требует конвертаций: port.Port() отдаёт готовое значение, и fmt.Sprintf собирает адрес за один вызов. Для образов с единственным портом удобен Endpoint: он берёт самый низкий выставленный порт, который почти всегда и есть рабочий.

### Ручной Start и проверка готовности

```go
func TestMinioReady(t *testing.T) {
	ctx := context.Background()

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image: "minio/minio",
			Env: map[string]string{
				"MINIO_ROOT_USER":     "itest",
				"MINIO_ROOT_PASSWORD": "itest-secret",
			},
			Cmd:          []string{"server", "/data"},
			ExposedPorts: []string{"9000/tcp"},
			WaitingFor:   wait.ForHTTP("/minio/health/live").WithPort("9000"),
		},
		Started: false, // создан, но не запущен: старт — на совести теста
	})
	if err != nil {
		t.Fatalf("создание: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })

	// между create и start можно подложить конфиг или проверить соседей
	if err := ctr.Start(ctx); err != nil {
		t.Fatalf("старт: %v", err)
	}

	endpoint, err := ctr.PortEndpoint(ctx, "9000", "http")
	if err != nil {
		t.Fatalf("адрес: %v", err)
	}

	resp, err := http.Get(endpoint + "/minio/health/ready")
	if err != nil {
		t.Fatalf("проба готовности: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("готовность: %d", resp.StatusCode)
	}
}
```

Started: false возвращает контейнер в состоянии created, и Start зовётся в выбранной тестом точке — например, после подкладки конфига. Стратегия WaitingFor исполняется внутри Start, поэтому после его возврата сервис уже отвечает на health-пробу. PortEndpoint собирает готовый адрес без ручной склейки хоста и порта. В Cleanup Terminate получает context.Background: t.Context() к тому моменту уже отменён.

### Terminate: уборка через t.Cleanup

Универсальный контейнер убирается по тем же правилам, что и модульный. Стандарт — t.Cleanup(func() { _ = ctr.Terminate(ctx) }): Cleanup-функции выполняются в обратном порядке и всегда — при провале assert'ов и при панике. Контекст для уборки — context.Background: если в теле теста вы пользовались t.Context(), к моменту Cleanup он уже отменён, и Terminate с мёртвым контекстом не успеет договориться с демоном. Удобнее готовая обёртка CleanupContainer(t, ctr): она регистрирует Terminate в Cleanup за вас, зовётся сразу после GenericContainer — до проверки ошибки — и молчит на nil. Для мест, где testing.TB недоступен, есть TerminateContainer(ctr). Ошибку уборки возвращайте тесту, а не глотайте: молчаливый провал Terminate вылезет исчерпанными портами в следующем прогоне. Страховка на случай смерти процесса — работа уборщика-супервизора, его разбор живёт в уроке про Ryuk; здесь достаточно привычки: создал контейнер — зарегистрируй уборку. Cleanup-функции исполняются в обратном порядке регистрации: если тест поднимает цепочку сервисов, последний созданный гаснет первым — порядок зависимостей соблюдается без ручного управления.

### Тот же образ через Run

```go
func TestNginxViaRun(t *testing.T) {
	ctx := context.Background()

	// Run: тот же движок, декларация собирается опциями
	ctr, err := testcontainers.Run(ctx, "nginx",
		testcontainers.WithExposedPorts("80/tcp"),
		testcontainers.WithWaitStrategy(wait.ForHTTP("/")),
	)
	testcontainers.CleanupContainer(t, ctr) // до проверки ошибки: nil — no-op
	if err != nil {
		t.Fatalf("запуск: %v", err)
	}

	uri, err := ctr.PortEndpoint(ctx, "80", "http")
	if err != nil {
		t.Fatalf("адрес: %v", err)
	}

	resp, err := http.Get(uri)
	if err != nil {
		t.Fatalf("запрос: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("статус: %d", resp.StatusCode)
	}
}
```

Run — современная обёртка того же движка: образ идёт первым аргументом, Started: true зашит внутри, а настройки передаются опциями — WithExposedPorts, WithWaitStrategy, WithEnv и другими. Возвращается конкретный *DockerContainer, а не интерфейс. Под капотом — всё тот же GenericContainer, поэтому переезд между стилями механический. Документация рекомендует Run, сам GenericContainer устаревшим не помечен. Опции композируются: к этому же вызову добавляются WithEnv, WithCmd, WithLabels — декларация собирается по частям.

### Проверка: ошибка старта

**Вопрос:** GenericContainer вернул и контейнер, и ошибку: Start не смог поднять процесс. Что делать с контейнером?

- Ничего: при ошибке контейнер всегда nil, уборка не нужна
- ✅ Он может быть не nil: прибрать через CleanupContainer или Terminate и забрать логи для разбора
- Вызвать Start ещё раз: GenericContainer обязан доделать работу сам
- Дождаться уборщика: он приберёт немедленно после возврата ошибки

Исходники сознательно отдают созданный контейнер вместе с ошибкой — у вызывающего остаётся шанс вызвать Terminate и прочитать Logs. Уборщик срабатывает только в конце сессии, а не сразу после ошибки. Повторный Start в обязанности библиотеки не входит: политика запуска — дело теста.

### Проверка: наружный порт

**Вопрос:** Тест знает порт 8080/tcp внутри контейнера. Как узнать адрес сервиса снаружи?

- Прочитать ExposedPorts обратно: там уже готовый адрес хоста
- Сложить Image и ExposedPorts: адрес выводится из имени образа
- ✅ После старта вызвать MappedPort(ctx, "8080/tcp") рядом с Host(ctx)
- Использовать 8080 как порт хоста: Docker маппит порт в порт

Наружный порт Docker назначает из свободных при старте, поэтому декларация его знать не может. MappedPort отдаёт маппнутый порт по паре «порт/протокол», Host — адрес демона; PortEndpoint собирает строку целиком. Совпадение наружного порта с контейнерным — случайность, на неё ставку не делают.

### Что вернёт MappedPort после ручного Start

```go
// GenericContainerRequest:
//     Started: false, WaitingFor: wait.ForHTTP("/")
// Тест:
//     err := ctr.Start(ctx) // вернул nil
//     port, err := ctr.MappedPort(ctx, "80/tcp")
//     resp, err := http.Get("http://host:" + port.Port())
```

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

- Ошибку: wait-стратегия исполняется только при Started: true
- ✅ Маппнутый порт без ошибки: стратегия исполнится внутри Start до его возврата
- Порт, но сервис ещё не готов: при ручном Start wait не запускается
- Пустую строку: порт назначается только при Started: true

Wait — часть старта, а не автостарта: Start поднимает процесс и исполняет стратегию из WaitingFor, после чего возвращается. Started управляет лишь тем, кто вызывает Start — библиотека или тест. Поэтому после возврата ручного Start контейнер готов ровно так же, как после Started: true.

### Соберите декларацию произвольного образа

Восстановите декларацию: окружение, порты, готовность и политика запуска.

```go
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
	ContainerRequest: testcontainers.ContainerRequest{
		Image: "minio/minio",
		___: map[string]string{"MINIO_ROOT_USER": "itest"},
		___:          []string{"9000/tcp"},
		___:   wait.ForHTTP("/minio/health/live").WithPort("9000"),
	},
	___: true,
})
```

Подсказка: Env — карта переменных, ExposedPorts — срез пар порт/протокол, WaitingFor — стратегия готовности, Started — политика запуска в обёртке.

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

### Соберите generic-тест

Расставьте шаги теста с произвольным образом: от выбора до уборки.

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

1. `образ без готового модуля: nginx, minio или внутренний демон`
2. `декларация ContainerRequest: Image, Env, Cmd, ExposedPorts`
3. `обёртка GenericContainerRequest: Started, ProviderType, Logger, Reuse`
4. `GenericContainer: create по декларации, при Started: true — и start`
5. `Start исполняет wait-стратегию из WaitingFor`
6. `Host и MappedPort дают адрес живого сервиса`
7. `мини-проверка доступности: HTTP-проба или первый запрос клиента`
8. `Terminate из t.Cleanup срабатывает при провале и панике`
9. `уборщик-супервизор подчищает остатки, если процесс теста умер`

Сценарий «testcontainers любой образ» уложился в один маршрут: декларация поле за полем, один вызов, адрес через Host и MappedPort, уборка в Cleanup. Выбор между тремя входами теперь осознанный. Модуль — когда он есть и покрывает задачу: готовые порты, wait и удобные методы экономят десяток строк и защищают от чужих граблей. Run — короткий современный путь для быстрых деклараций. GenericContainer — когда модуля нет, служебный образ не стоит лишней зависимости в go.mod или нужен полный контроль над запуском. Логичное продолжение — свой образ, собранный из Dockerfile прямо в тесте: им займётся [урок про сборку собственного образа](/courses/testcontainers/lessons/15-svoj-obraz/). Правило простое: пока нужного модуля нет, generic закрывает дыру немедленно — образ из реестра, несколько полей декларации, зелёный тест. А если готовности из коробки мало — wait-стратегии из следующего урока научат ждать не порт, а осмысленный сигнал: строку в логе, HTTP-пробу, ответ команды.

### Типовые ошибки с произвольным образом

- ждать наружный порт из декларации: Docker назначает его при старте, адрес узнают через MappedPort после возврата вызова
- оставить WaitingFor пустым: контейнер вернётся сразу после поднятия процесса, первый запрос придёт в тишину
- выходить из теста при err != nil без CleanupContainer: контейнер при ошибке старта не nil и провисит до конца сессии
- писать Started во вложенном ContainerRequest: политика запуска живёт в обёртке GenericContainerRequest
- хардкодить localhost вместо Host(ctx): на удалённом демоне адрес другой
- указать одновременно Image и Context: Validate отсечёт декларацию до обращения к демону
- включать Reuse без Name: вернётся ErrReuseEmptyName
- заменять wait-стратегию сном: ForHTTP, ForListeningPort и ForLog уже умеют ждать готовность

> **Про песочницу: нужен локальный Docker.** Честная оговорка: в песочнице сайта этот урок не исполняется — GenericContainer требует живого демона Docker, а браузерная среда запускает только тематические фрагменты без контейнеров. Рабочая схема локальная: сохраните код урока в файл *_test.go рядом с go.mod и запустите go test — образы nginx и minio подтянутся из реестра при первом прогоне. Код разобран построчно, так что отсутствие Docker чтению не мешает.

### Карточки: GenericContainer

- **GenericContainer** — Универсальный вход: принимает GenericContainerRequest и возвращает интерфейс testcontainers.Container для любого образа. На нём построены и модули, и Run.
- **GenericContainerRequest** — Обёртка запроса: вложенная декларация ContainerRequest плюс собственные поля Started, ProviderType, Logger и Reuse. Reuse требует непустого Name, иначе ErrReuseEmptyName.
- **Started** — Политика запуска: true — create, start и wait внутри вызова; false — контейнер остаётся created, и Start зовёт тест в выбранной точке. Ошибка старта возвращает контейнер вместе с ошибкой.
- **MappedPort** — Наружный порт по паре «порт/протокол», например 80/tcp. Назначается Docker из свободных при старте, поэтому узнаётся только после запуска; в host-режиме равен порту контейнера.
- **PortEndpoint** — Готовая строка proto://host:port по заданному порту и протоколу: собирает Host и MappedPort за один вызов. Endpoint делает то же для самого низкого порта.
- **CleanupContainer** — Регистрирует Terminate в Cleanup теста. Зовут сразу после GenericContainer, до проверки ошибки: на nil это no-op, на живом контейнере — гарантированная уборка.

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

- GenericContainer — универсальный вход: GenericContainerRequest собирает декларацию ContainerRequest и политику запуска, возвращает общий интерфейс Container.
- Started: true — старт и wait внутри вызова; false — ручной Start; при ошибке старта контейнер возвращается вместе с ошибкой и остаётся на уборке.
- Адрес сервиса: Host плюс MappedPort("порт/tcp") или готовая строка PortEndpoint; наружный порт назначает Docker, декларацией его не зафиксировать.
- Уборка — Terminate в t.Cleanup или CleanupContainer(t, ctr) до проверки ошибки; Run — современная обёртка, модули построены поверх неё.

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

Поднимите через GenericContainer любой образ без модуля: Env, Cmd, ExposedPorts, WaitingFor, Started: true, Terminate в t.Cleanup и проверка HTTP-ответа через http.Get. Затем выключите Started, вызовите Start руками и убедитесь, что PortEndpoint даёт рабочий адрес. Напоследок сломайте Cmd заведомо несуществующей командой и проверьте, что GenericContainer вернул контейнер вместе с ошибкой, а CleanupContainer прибрал его без ручных проверок на nil. Сверьте docker ps до и после прогона: ни одного оставшегося контейнера.

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

### Когда нужен GenericContainer, если есть модули и Run?

Модуль — пресет: он сам выставляет порты, wait-стратегию и удобные методы вроде ConnectionString. Run — короткий современный путь с опциями. GenericContainer берут, когда модуля под образ нет, служебный контейнер не тянет отдельную зависимость или нужен полный контроль над декларацией. Под всеми тремя входами работает один и тот же движок, так что переезд между ними механический: модуль — готовый рецепт, generic — продуктовая корзина.

### Что вернёт GenericContainer при ошибке?

Зависит от этапа: если create не удался и контейнера нет, вернётся nil вместе с ошибкой. Если же create прошёл, а Start упал, вернутся и ошибка, и контейнер: исходники сознательно отдают его вызывающему для уборки и чтения логов. Поэтому CleanupContainer зовут до проверки ошибки — на nil это no-op. Тип возвращаемого значения — главное отличие этого вызова: интерфейс вместо конкретной структуры.

### Обязательно ли указывать WaitingFor?

Технически нет: библиотека вернёт контейнер сразу после поднятия процесса. Практически — без стратегии первый запрос может прийти к сервису, который ещё не слушает порт: готовность сегодня определяют только wait-стратегии, неявная проверка всех выставленных портов из старых версий убрана. Для HTTP-сервиса хватает ForHTTP("/"), для демона без HTTP — ForListeningPort или ForLog, а ForAll объединяет несколько сигналов в одно условие: порт плюс строка в логе надёжнее одиночного сигнала.
