---
title: "Жизненный цикл контейнера в тесте"
description: "Жизненный цикл контейнера в testcontainers: ContainerRequest от Env до Files, Started и ручной Start, t.Cleanup с t.Context() и Terminate после останова."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/03-zhiznennyj-cikl/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Жизненный цикл контейнера в тесте

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

Каждый контейнер в тесте проходит один и тот же маршрут: декларация в ContainerRequest, создание, запуск, ожидание готовности, работа и Terminate. Разберём его по шагам: как поля Env, ExposedPorts, Cmd, Labels и Files превращаются в docker create, зачем существует Started: false, чем t.Context() из Go 1.24 лучше context.Background, почему уборку пишут руками даже при Terminate и что случится с контейнерами при панике теста. Порядок шагов не перестановочен: нарушите его — и тест либо краснеет на ровном месте, либо оставляет за собой мусор. Сценарий «testcontainers жизненный цикл» здесь закрыт целиком, а связка «containerrequest go» разобрана поле за полем.

Контейнер в интеграционном тесте — не магия, а конечный автомат с коротким списком состояний: создан, запущен, готов, отработал, удалён. Пока тест жив, этим автоматом управляете вы: какая декларация ушла в Docker, когда поднимется процесс, кто вызовет останов и удаление. Библиотека берёт на себя проводку — docker create, docker start, ожидание готовности, docker rm, — но порядок и гарантии расставляет тест: одна пропущенная уборка на быстрой машине проходит незамеченной, а на CI превращается в рой зависших контейнеров и исчерпанные порты. Маршрут стоит усвоить один раз целиком: он не меняется от модуля к модулю, меняются только декларации поверх него. Этот урок — карта всего маршрута: декларация ContainerRequest поле за полем, роль Started и ручной Start, путь через wait-стратегию, Terminate с гарантиями. Механика первого контейнера из [второго урока](/courses/testcontainers/lessons/02-ustanovka-pervyj-kontejner/) здесь уже знакома — теперь она собирается в дисциплину, которая переживёт и панику, и падение сборки.

### ContainerRequest: декларация будущего контейнера

Вся будущая жизнь контейнера описывается одной структурой — ContainerRequest из пакета testcontainers: образ, переменные окружения, порты, команда, метки, файлы, стратегия готовности. Структуру ContainerRequest Go применяет внутри GenericContainer, когда тест передаёт ему GenericContainerRequest: в этой обёртке рядом с вложенной декларацией живут свои поля — Started, провайдер, логгер, режим Reuse. Дизайн осмысленный: декларация отвечает на вопрос «что запустить», обёртка — на вопрос «как ведёт себя тест». До обращения к Docker декларацию проверяет Validate: он отсекает недопустимые комбинации, например образ одновременно с контекстом сборки. В момент создания провайдер дописывает к вашим меткам служебные — семейство org.testcontainers с языком, версией и идентификатором сессии, — и именно по ним уборщик в конце прогона находит ресурсы, которые создал тест. Декларацию удобно читать как docker run, записанный типами: одно поле — один флаг запуска, и ничего из теста в образ не просачивается. Когда модуль базы в четвёртом уроке подставит декларацию поверх вашей, вы уже будете знать, что именно он дописывает.

### Тест с полной декларацией

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

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			Env: map[string]string{
				"APP_ENV":     "test",
				"CONFIG_PATH": "/etc/app/config.yaml",
			},
			ExposedPorts: []string{"8080/tcp"},
			Cmd:          []string{"--log-level=debug"},
			Labels: map[string]string{
				"team":  "payments",
				"suite": "integration",
			},
			Files: []testcontainers.ContainerFile{
				{
					HostFilePath:      "testdata/config.yaml",
					ContainerFilePath: "/etc/app/config.yaml",
					FileMode:          0o644,
				},
			},
			WaitingFor: wait.ForListeningPort("8080/tcp"),
		},
		Started: true,
	})
	if err != nil {
		t.Fatalf("запуск контейнера: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(ctx) })

	host, _ := ctr.Host(ctx)
	port, _ := ctr.MappedPort(ctx, "8080/tcp")
	log.Printf("сервис готов: %s:%s", host, port)
	// ... запросы к http://host:port
}
```

Декларация собирается литералом прямо в тесте. Env передаёт конфиг сервиса без пересборки образа, ExposedPorts объявляет порт, который тест заберёт наружу, Cmd дописывает аргументы команде образа, Labels помечают прогон для фильтрации, Files копирует конфиг внутрь до старта процесса. WaitingFor прячется в PostStart-хук: GenericContainer не вернётся, пока порт не откроется. Started: true стоит в обёртке, а не во вложенной декларации — это политика запуска теста.

### Env, Cmd и Files: конфигурация сервиса

Env — главный канал конфигурации сервиса: большинство образов читает настройки из переменных окружения, и тест подставляет свои значения вместо продакшен-конфига, не пересобирая образ. Cmd — аргументы, которые дописываются к точке входа образа: уровень логов, режим работы, адрес порта. ExposedPorts объявляет, какие порты контейнера тест хочет видеть снаружи, вместе с протоколом — 8080/tcp; маппнутый порт хоста вы узнаёте уже после старта через MappedPort, потому что Docker назначает его из свободных. Объявляйте ровно те порты, с которыми тест реально работает: каждый лишний порт замедляет старт и проверку готовности. Files копирует файлы внутрь при старте: каждый элемент — ContainerFile с путём на хосте или готовым Reader, путём внутри контейнера и правами FileMode; это идеальный канал для конфигов — точный снимок содержимого на момент прогона. Mounts решает другую задачу: он подключает каталоги и тома насквозь, и изменения видны в обе стороны, пока контейнер жив. Конфиг — Files, живые данные — Mounts, и путать их не стоит.

### Поля ContainerRequest: кто за что отвечает

| Поле | За что отвечает | Пример значения |
| --- | --- | --- |
| Image | какой образ запускать; сердце декларации | "myapp/service" |
| Env | переменные окружения — конфиг сервиса без пересборки образа | map[string]string{"APP_ENV": "test"} |
| ExposedPorts | порты, которые тест заберёт наружу, с протоколом | []string{"8080/tcp"} |
| Cmd | аргументы, дописываемые к точке входа образа | []string{"--log-level=debug"} |
| Labels | метки для группировки и фильтрации ресурсов прогона | map[string]string{"suite": "integration"} |
| Files | файлы, копируемые в контейнер при старте | []testcontainers.ContainerFile{{...}} |
| Name | фиксированное имя вместо сгенерированного; без нужды не ставить | "billing-it" |
| AutoRemove | попросить Docker стереть контейнер сразу после останова | true |

### Labels, Name и AutoRemove: метки, имена, удаление

Labels — карта строковых меток, ложащихся на контейнер как docker-лейблы. Тесту они дают группировку и фильтрацию: помечайте прогон командой, фичей или билдом — потом один docker ps --filter label=suite=integration покажет, кто остался висеть. Библиотека складывает ваши метки со своими: служебные метки сессии обязательны, и уборщик находит ресурсы прогона именно по ним, так что ваши метки просто мержатся поверх. Name задаёт контейнеру фиксированное имя вместо сгенерированного. Звучит безобидно, но в параллельном мире это мина: два теста с одним именем на одной машине — это два create, и второй упадёт с конфликтом. Имя оправдано, когда оно нужно протоколу: режим Reuse требует имени, а отладочный docker logs приятнее с говорящим. AutoRemove перекладывает удаление на сам Docker: контейнер стирается сразу при останове, и Terminate уже не найдёт, что убирать. Поле в структуре есть, но исходники помечают его Deprecated в пользу HostConfigModifier — современный путь конфигурирует то же самое через хост-настройки, а гарантированную уборку всё равно держит Terminate.

> **Живое место: одно имя на два прогона.** Классическая флапающая сборка: тест объявляет Name и t.Parallel, локально всё зелёное на одном воркере, а на CI два пакета стартуют одновременно — и второй create падает с конфликтом имён, пока первый жив. Ни один тест не виноват: фиксированное имя — ресурс в единственном экземпляре, его разбирает первый успевший. Пограничный случай страшнее: контейнер с тем же именем не убрался после прошлого прогона — create падает до старта логики, и тест краснеет на пустом месте. Лечение — не задавать Name вовсе: Docker сгенерирует уникальное имя сам. Спорное мнение с условием: говорящее имя упрощает docker logs при отладке — включайте его в отдельном отладочном прогоне, а не в общем наборе.

### Started: создать, но не запустить

Started в обёртке GenericContainerRequest — выключатель политики запуска. При true GenericContainer создаёт контейнер и сам же стартует его: вызов не вернётся, пока процесс не поднят и wait-стратегия не пройдена — тест принимает готовый сервис. При false возвращается созданный, но не запущенный контейнер, и момент старта становится вашим: ctr.Start(ctx) вызывается руками в выбранной точке. Зачем это нужно: проверить поведение сервиса, начавшего жить без зависимости, — состояние created вместо running; подправить файлы или окружение после создания, но до запуска; испытать сам старт — что Cmd поднимает процесс, а не роняет его мгновенно. Отдельный сценарий — проверка ошибки запуска: битая команда вернётся из Start, и тест фиксирует причину assert'ом, а не гадает по зелёному экрану. Механика честная: Start исполняет хуки, поднимает процесс и только после запуска ждёт готовности — ранняя смерть процесса вернётся ошибкой из Start, и у теста остаются логи для разбора. Забыли Start? Тест пройдёт зелёным, а контейнер-зомби будет ждать Terminate из Cleanup — или уборщика.

### Ручной Start и уборка в Cleanup

```go
func TestManualStart(t *testing.T) {
	ctx := t.Context() // отменится вместе с тестом (Go 1.24)

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			ExposedPorts: []string{"8080/tcp"},
		},
		Started: false, // создан, но процесс не поднят
	})
	if err != nil {
		t.Fatalf("создание: %v", err)
	}

	// в Cleanup t.Context() уже отменён — Terminate получает свежий контекст
	t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })

	if err := ctr.Start(ctx); err != nil {
		t.Fatalf("старт: %v", err)
	}

	// ... работа с сервисом

	if err := ctr.Stop(ctx, nil); err != nil {
		t.Fatalf("останов: %v", err)
	}
	// Terminate после Stop — не ошибка: force-remove доведёт уборку до конца
	if err := ctr.Terminate(ctx); err != nil {
		t.Fatalf("уборка: %v", err)
	}
}
```

Started: false возвращает контейнер в состоянии created, и Start вызывается в выбранной тестом точке. t.Context() из Go 1.24 отменяется перед вызовом Cleanup-функций, поэтому внутри Cleanup Terminate получает context.Background — старый контекст к тому моменту уже мёртв. Stop гасит процесс штатно, а Terminate после него ошибкой не падает: безопасные ситуации вроде «уже остановлен» проглатываются, force-remove с томами выполняется. Cleanup срабатывает даже при панике теста.

Путь «testcontainers жизненный цикл» в одном тесте укладывается в шесть шагов, и половина из них скрыта внутри одного вызова. Первый — create: GenericContainer по декларации создаёт контейнер, ещё не запущенный. Второй — start: при Started: true это тот же вызов, при false — ручной ctr.Start. Третий — wait: после поднятия процесса исполняется стратегия из WaitingFor, и [урок об ожиданиях](/courses/testcontainers/lessons/12-ozhidaniya-wait/) покажет, как выбирать между портом, логом и HTTP-пробой; GenericContainer не вернётся, пока она не пройдёт. Четвёртый — работа: тест берёт хост и маппнутый порт и общается с сервисом как с настоящим. Пятый — Terminate: останов с таймаутом и force-remove с томами. Шестой — страховка: уборщик приберёт то, что тест не успел, — его разбор ждёт в восемнадцатом уроке про Ryuk. Порядок шагов фиксирован, и это хорошая новость: один выученный маршрут закрывает и базы, и брокеров, и подменные серверы — меняется лишь декларация.

### Соберите путь контейнера

Расставьте этапы жизненного цикла: от декларации до страховки уборщика.

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

1. `декларация: ContainerRequest с Image, Env, ExposedPorts и Cmd`
2. `GenericContainer выполняет create по декларации`
3. `Started: true — Start поднимает процесс, false — тест запускает сам`
4. `PostStart-хук исполняет wait-стратегию из WaitingFor`
5. `тест забирает Host и MappedPort и работает с сервисом`
6. `Terminate: Stop с таймаутом, затем force-remove с томами`
7. `t.Cleanup гарантирует уборку при провале и панике теста`
8. `уборщик-супервизор зачищает остатки, если процесс теста умер`

### Terminate, t.Cleanup и t.Context(): уборка с гарантией

Terminate — единственный штатный выход из жизни контейнера: сигнатура Terminate(ctx, opts...) принимает контекст и опции, а внутри делает две вещи — останавливает контейнер (с таймаутом, после которого Docker убьёт процесс силой) и удаляет его вместе с томами. Ошибку уборки Terminate возвращает на руки: логируйте её, а не глотайте. Регистрация в t.Cleanup — стандарт: Cleanup-функции выполняются в обратном порядке и всегда — при провале assert'ов и при панике тоже. У библиотеки есть и готовая обёртка: testcontainers.CleanupContainer(t, ctr) регистрирует Terminate в Cleanup за вас — её даже зовут до проверки ошибки создания, потому что nil-контейнер она игнорирует. Контекст для уборки — отдельная тонкость. context.Background живёт до конца процесса: честный, но грубый вариант. t.Context() из Go 1.24 точнее — отменяется вместе с тестом и гасит висящие на нём операции, — но именно поэтому внутри Cleanup он уже отменён, и Terminate там зовут со свежим context.Background. Наконец, Terminate после ручного Stop не ошибка: библиотека считает безобидными ситуации «уже остановлен» и «удаление уже идёт» и всё равно доводит удаление до конца, поэтому дублировать уборку безопасно, а пропустить — нет.

> **Про песочницу: почему код не запустится в браузере.** Честная оговорка: в песочнице сайта этот урок не запустится — GenericContainer требует живого демона Docker, а браузерная среда исполняет тематические фрагменты без демонов. Рабочая схема локальная: сохраните код урока в файл *_test.go рядом с go.mod и запустите go test — весь путь от create до remove пройдёт на вашей машине. Весь код урока разбирается построчно, так что отсутствие Docker чтению не мешает. Домашний эксперимент: docker ps до и после теста — контейнер обязан исчезнуть; закомментируйте t.Cleanup и найдите зомби тем же docker ps.

Паника теста — штатный сценарий для уборки: фреймворк testing перехватывает панику, помечает тест проваленным и всё равно прогоняет Cleanup-функции, поэтому Terminate из Cleanup исполнится и после самого ожесточённого падения. Настоящая дыра — смерть процесса: kill по зависшему прогону, os.Exit в чужом коде, съеденный CI-раннер. Ни defer, ни Cleanup при этом не выполнятся, и контейнеры остались бы висеть до ручной зачистки. На этот случай у библиотеки есть супервизор уборки: он заводится вместе с сессией, помечает созданные ресурсы служебными метками и следит за процессом-владельцем — умрёт процесс, супервизор зачистит всё, что тот насоздавал. Как он устроен, сколько живёт и когда его отключают — тема восемнадцатого урока. Привычка проверять уборку на краевых случаях — убить процесс и посмотреть, кто прибрал, — из тех навыков, что прокачивает [продвинутый курс по Go](/courses/go-advanced/): там контексты, горутины и жизненные циклы процессов разбираются глубже.

### Проверка: Started false и тишина

**Вопрос:** Тест передал Started: false и после GenericContainer ни разу не вызвал Start. Что происходит?

- Контейнер запустится при первом обращении к Host
- ✅ Контейнер остаётся созданным, но не запущенным: процесса нет, wait-стратегия не исполнялась
- GenericContainer вернёт ошибку: без Start декларация недействительна
- Docker сам запустит контейнер по таймауту ожидания

Started: false означает политику ручного старта: GenericContainer ограничивается create и возвращает управление. Процесс не поднят, PostStart-хук с wait-стратегией не исполнялся, порты не готовы к работе. Terminate из Cleanup при этом обязателен: иначе созданный контейнер останется висеть до конца сессии.

### Проверка: Terminate после Stop

**Вопрос:** Тест вызвал ctr.Stop, затем Terminate. Что вернёт Terminate?

- Ошибку: контейнер уже остановлен, второй останов недопустим
- ✅ nil: останов уже остановленного пропускается, force-remove с томами выполняется
- Панику: у остановленного контейнера нет идентификатора
- Ошибку таймаута: остановленный контейнер не отвечает на команды

Библиотека относит «уже остановлен» и «удаление уже идёт» к безопасным ситуациям: Terminate не считает их ошибкой, пропускает лишний останов и доводит до конца force-remove с томами. Дублирование уборки безопасно — пропустить уборку опасно. Поэтому cleanup пишут всегда, независимо от порядка остановов.

### Что упадёт при параллельном прогоне

```go
// Тест A и тест B помечены t.Parallel().
// У обоих в ContainerRequest:
//     Name: "billing-it", Started: true
// Оба теста стартуют одновременно на одной машине.
```

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

- Оба теста получат один и тот же контейнер по имени
- ✅ Второй create упадёт: контейнер с именем billing-it уже существует
- Библиотека подберёт свободное имя сама и запустит два контейнера
- Второй тест зависнет в очереди, пока имя освободится

Name — ресурс в единственном экземпляре: Docker не допустит два контейнера с одним именем, и второй create вернёт конфликт. Библиотека имена не перехватывает и очередей не строит: без Name каждый контейнер получает уникальное сгенерированное, и параллельность безопасна. Отсюда правило: Name — только когда он нужен протоколу.

### Соберите декларацию

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

```go
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
	ContainerRequest: testcontainers.ContainerRequest{
		Image:        "myapp/service",
		___: map[string]string{"APP_ENV": "test"},
		ExposedPorts: []string{"8080/tcp"},
		___: wait.ForListeningPort("8080/tcp"),
	},
	___: true,
})
```

Подсказка: Конфиг сервиса живёт в Env, готовность ждёт WaitingFor, а политика запуска Started стоит в обёртке GenericContainerRequest, а не во вложенной декларации.

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

### Чек-лист жизненного цикла

- декларация — ContainerRequest: Image, Env, ExposedPorts, Cmd, Labels, Files
- Started: true — запуск и wait внутри GenericContainer; false — ручной ctr.Start
- wait-стратегия исполняется после старта: тест принимает готовый контейнер
- Name без нужды не ставить: имя конфликтует при параллельных прогонах
- Terminate регистрировать в t.Cleanup — сработает при провале и панике
- внутри Cleanup контекст — context.Background: t.Context() уже отменён
- Terminate после ручного Stop — не ошибка: force-remove всё равно пройдёт
- уборка при смерти процесса — работа супервизора: тема урока про Ryuk

### Карточки: жизненный цикл

- **ContainerRequest** — Декларация контейнера: Image, Env, ExposedPorts, Cmd, Labels, Files, WaitingFor и прочие поля запуска. Валидируется до обращения к Docker, а провайдер дополняет её служебными метками сессии.
- **Started** — Поле обёртки GenericContainerRequest, а не декларации: true — GenericContainer сам стартует и ждёт wait-стратегию, false — контейнер остаётся created, и Start зовёт тест руками.
- **t.Cleanup** — Cleanup-функции теста: выполняются в обратном порядке и всегда — при провале и панике тоже. Место для Terminate; внутри нужен context.Background, потому что t.Context() уже отменён.
- **t.Context() (Go 1.24)** — Контекст, отменяемый перед Cleanup: удобен в теле теста вместо context.Background — живёт ровно столько, сколько тест, и гасит свои операции вместе с собой.
- **Name** — Фиксированное имя контейнера. Без нужды не ставить: два параллельных прогона с одним именем конфликтуют на create. Нужен для Reuse и удобных docker logs.
- **Terminate** — Штатная уборка: Stop с таймаутом, затем force-remove с томами; ошибку возвращает на руки. После ручного Stop не падает — безопасные ситуации проглатываются.

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

- Декларация ContainerRequest описывает весь запуск: Image, Env, ExposedPorts, Cmd, Labels, Files и WaitingFor — docker run, записанный типами.
- Started живёт в обёртке GenericContainerRequest: true — запуск и wait внутри GenericContainer, false — ручной ctr.Start в выбранной точке.
- Terminate в t.Cleanup — стандарт уборки: Cleanup выполняется при провале и панике; внутри Cleanup контекст — context.Background, не t.Context().
- Terminate после ручного Stop не ошибка: безопасные ситуации проглатываются, force-remove с томами доводится до конца.

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

Соберите тест на образ с env-конфигом: Env, ExposedPorts, Cmd, Labels и Files, Started: true, Terminate в t.Cleanup. Затем выключите Started, вызовите Start руками, а после работы выполните Stop и убедитесь, что последующий Terminate вернул nil. Финал: закомментируйте t.Cleanup, запустите go test и найдите контейнер-зомби через docker ps.

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

### Почему Terminate зовут в t.Cleanup, а не через defer?

Cleanup даёт единый механизм фреймворка: он выполняет функции в обратном порядке при любом исходе теста — провале assert'ов, t.FailNow и панике, — и не зависит от того, доживёт ли функция до выхода. Регистрацию можно сделать сразу после создания контейнера, рядом с ним, и уборка не потеряется среди прочих defer. Отдельный бонус: при t.Parallel порядок уборки фреймворк разруливает сам, а ручные defer в параллельных горутинах легко перепутать местами.

### Можно ли обойтись без Terminate — уборщик же зачистит?

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

### Что вернёт MappedPort, если wait-стратегия ещё не прошла?

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