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

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

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

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

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

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

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

GenericContainer — самая старая и самая универсальная функция библиотеки: она принимает декларацию произвольного образа и возвращает контейнер, не спрашивая, что это за сервис. Вся остальная библиотека построена вокруг неё: модули прошлых уроков — пресеты, которые собирают ту же декларацию за вас, а современная функция Run — тонкая обёртка над ней. Универсальность оборачивается ответственностью: модулю порты и стратегию готовности подсказывает автор модуля, здесь их выбираете вы. Ничего нового учить не придётся: декларация ContainerRequest разобрана в третьем уроке, а список нужных полей короткий — образ, окружение, порты, команда, 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, внутренний демон команды
Вход в APIRun из пакета модуля с опциями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 не упадёт, а тихо вернёт существующий сервис.

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

Адрес сервиса собирается из двух вызовов. Host возвращает адрес, на котором демон выставил порт: при локальном docker это localhost, на удалённом демоне CI-раннера — его хост, поэтому адрес всегда берут из вызова, а не хардкодят. MappedPort принимает пару «порт/протокол» — например, 80/tcp — и возвращает наружный порт, который Docker назначил из свободных при старте; именно поэтому порт нельзя угадать заранее и нельзя зафиксировать декларацией. Кто не хочет склеивать адрес вручную, берёт готовое: PortEndpoint вернёт строку вида proto://host:port по заданному порту и протоколу, а Endpoint — то же для самого низкого выставленного порта. Нюанс сетевого режима host: маппинга нет, порт контейнера равен порту хоста, и MappedPort вернёт его как есть. Готовность к моменту возврата гарантирует wait-стратегия: без WaitingFor библиотека вернёт контейнер сразу после поднятия процесса, и первый запрос может прийти к сервису, который ещё не слушает порт. Каталог стратегий — порт, лог, HTTP-проба, команда — разобран в уроке про ожидания. Порт возвращается строкой, так что склейка адреса не требует конвертаций: 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 не смог поднять процесс. Что делать с контейнером?

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

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

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

Прочитайте код и выберите, что он выведет:

// 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())

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

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

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, })

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Сценарий «testcontainers любой образ» уложился в один маршрут: декларация поле за полем, один вызов, адрес через Host и MappedPort, уборка в Cleanup. Выбор между тремя входами теперь осознанный. Модуль — когда он есть и покрывает задачу: готовые порты, wait и удобные методы экономят десяток строк и защищают от чужих граблей. Run — короткий современный путь для быстрых деклараций. GenericContainer — когда модуля нет, служебный образ не стоит лишней зависимости в go.mod или нужен полный контроль над запуском. Логичное продолжение — свой образ, собранный из Dockerfile прямо в тесте: им займётся урок про сборку собственного образа. Правило простое: пока нужного модуля нет, 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 уже умеют ждать готовность

Карточки: GenericContainer

Термин

GenericContainer

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

Универсальный вход: принимает GenericContainerRequest и возвращает интерфейс testcontainers.Container для любого образа. На нём построены и модули, и Run.

1 / 6

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

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

Вывод

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

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

  • 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 до и после прогона: ни одного оставшегося контейнера.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · GenericContainer: любой Docker-образ)

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

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

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

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

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

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

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

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

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

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

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

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

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

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