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

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

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

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

Wait-стратегии: когда сервис готов

Стартовавший контейнер — ещё не готовый сервис: процесс поднят, а приложение догружает конфиги, применяет миграции и только потом открывает порт. Если тест ринется в запросы сразу, он упадёт на ровном месте; если уснёт на фиксированное число секунд — будет врать и тормозить. Testcontainers решает задачу «testcontainers wait» wait-стратегиями: тест возвращает контейнер только когда порт, лог, HTTP-проба или SQL-запрос подтвердят готовность. Разберём базовые стратегии, комбинирование через ForAll и ForAny, таймауты и интервалы опроса, а также сценарий «ожидание контейнера go» под свой протокол — через интерфейс Strategy.

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

Механика одинакова для всех стратегий. Поле WaitingFor в декларации контейнера принимает значение интерфейса wait.Strategy, а исполняет его PostStart-хук: процесс стартовал — библиотека печатает строку с человекочитаемым описанием условия и зовёт его метод WaitUntilReady. Пока тот не вернёт nil, GenericContainer не отдаёт управление: тест физически не может получить недогретый контейнер. Ошибка ожидания оборачивается в wait until ready и роняет запуск — с логами, по которым видно, какого условия не дождались. Бросить опрос на полпути библиотека тоже не даст: если процесс умер, пока стратегия ещё тикала, состояние контейнера вернёт осмысленную ошибку — OOMKilled или код выхода, — а не молчаливый таймаут. Картинку полного жизненного цикла мы собрали в одиннадцатом уроке, а здесь раскладываем «testcontainers wait» по слоям: базовые стратегии, комбинаторы, тайминги и свои типы. Сценарий один и тот же у всех модулей курса, от Postgres до брокеров, — меняется лишь декларация.

Sleep вместо стратегии: почему это боль#

Фиксированная пауза — самый дорогой способ решить задачу ожидания контейнера go. Поставьте паузу на пять секунд: на локальной Docker-машине сервис готов через восемьсот миллисекунд, и каждая лишняя секунда — плата за прогон, размноженная числом тестов; на перегруженном CI всё наоборот — двадцать секунд миграции не влезли в вашу паузу, и тест падает без всякой вины сервиса. Флейк из-за сна коварнее остальных: он зелёный девять прогонов из десяти, и ловят его на ветке релиза. Стратегия меняет саму постановку: не «сколько подождать», а «какое событие означает готовность». Тест спит ровно ноль: опрос идёт каждые сто миллисекунд, и управление возвращается в момент события — не раньше и не позже. Побочный бонус — диагностика: стратегия попадает в лог прогона своим описанием вида «log message ready» или «port 5432/tcp to be listening», так что при падении видно, какое условие не сошлось, а не гадание по таймеру. Отсюда и побочный эффект: конфигурация теста перестаёт зависеть от мощности машины — один и тот же прогон занимает одинаковое время на ноутбуке и в CI, потому что нигде не хранится магическое число секунд, подобранное наугад.

Базовая декларация с ForListeningPort

GO
func TestOrderService(t *testing.T) {
	ctx := context.Background()

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/orders",
			ExposedPorts: []string{"8080/tcp"},
			WaitingFor: wait.ForListeningPort("8080/tcp").
				WithStartupTimeout(30 * time.Second).
				WithPollInterval(500 * time.Millisecond),
		},
		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")
	// сервис гарантированно слушает порт — можно стучаться
	base := "http://" + net.JoinHostPort(host, port.Port())
	_ = base
}

Стратегия в WaitingFor собирается цепочкой билдеров: ForListeningPort создаёт стратегию порта, WithStartupTimeout срезает дефолтные шестьдесят секунд до тридцати, WithPollInterval растит частоту опроса с сотни миллисекунд до полусекунды. GenericContainer не вернётся, пока порт не откроется, и к моменту возврата MappedPort уже осмыслен. Cleanup с Terminate регистрируем сразу после проверки ошибки — привычка из жизненного цикла.

ForListeningPort: порт как сигнал готовности#

Стратегия порта проверяет готовность в два слоя. Внешний — со стороны хоста: библиотека достаёт маппнутый порт и диалится по TCP, как это сделал бы настоящий клиент; соединение refused — значит, сервис ещё не слушает. Внутренний — изнутри контейнера: запускается короткая команда на /bin/sh, которая смотрит /proc/net/tcp, пробует nc и bash-овский /dev/tcp — открыт ли порт на интерфейсах контейнера. Двухслойность спасает от случая, когда Docker смаппировал порт, а процесс ещё не привязался к нему. У образов без шелла внутренняя проверка не пройдёт в принципе: библиотека заметит код выхода 126 или 127, напишет в лог и продолжит валидировать только внешний доступ — стратегия не ломается, но знать про это поведение полезно. Для нестандартных ситуаций есть переключатели: SkipInternalCheck пригодится, когда сервис привязывает порт позже дополнительных условий, ForMappedPort вообще не трогает порт и ждёт только завершение маппинга, а ForExposedPort ждёт первый объявленный порт, если указывать конкретный незачем. Знание этого двухслойного протокола пригодится при отладке: если тест завис в ожидании порта, смотрите, на каком слое застряли, — в логах прогона видны попытки внешнего диала и внутренние exec-команды.

ForLog, ForHTTP и ForSQL: событие вместо порта#

Порт отвечает раньше, чем сервис готов принимать полезную нагрузку, поэтому событийные стратегии часто честнее. ForLog читает docker logs и ждёт подстроку: модуль Redis ловит фирменную строку готовности Redis, а модуль Postgres — «database system is ready to accept connections» с WithOccurrence(2), потому что Postgres печатает её дважды при восстановлении. Подстрока ищется чувствительно к регистру; нужен шаблон — AsRegexp переключает стратегию на регулярное выражение. ForHTTP шлёт настоящие запросы: ForHTTP на путь healthz ходит на наименьший открытый TCP-порт, ждёт статус из StatusCodeMatcher — по умолчанию 200 — и опционально сверяет тело через WithResponseMatcher; порт уточняется WithPort, метод — WithMethod, Basic-доступ — WithBasicAuth, а WithForcedIPv4LocalHost чинит docker-баги с IPv6. ForSQL поднимает планку до уровня протокола: ForSQL принимает порт, имя драйвера и функцию сборки URL и до победы выполняет SELECT 1 через database/sql — драйвер должен быть зарегистрирован, а запрос меняется WithQuery. Модуль CockroachDB именно так ждёт движок: HTTP на health плюс ForSQL с драйвером pgx. Из той же серии ForExit — ожидание, пока контейнер завершится сам: идеально для одноразовых задач вроде миграций, где успех — это именно выход.

Сервис → стратегия#

СервисСтратегияПочему так
Web-сервис с healthzwait.ForHTTP("/healthz").WithPort("8080/tcp")проверяет прикладную готовность, а не факт запуска процесса
Postgres через модульForLog готовности с WithOccurrence(2)строка готовности печатается дважды — ждём вторую
Redis через модульForListeningPort плюс ForLogпорт и фирменная строка готовности — двойное подтверждение
Kafka-брокерForLog с AsRegexpготовность видна по переходу фазы в логе брокера
База без модуляForSQL("5432/tcp", "pgx", url)SELECT 1 — честный ответ движка, а не открытый порт
Образ с HEALTHCHECKwait.ForHealthCheck()Docker сам следит за здоровьем — переиспользуем вердикт
Одноразовый миграторwait.ForExit()контейнер должен завершиться сам — ждём выхода, а не порта
Сервис с маркер-файломwait.ForFile("/tmp/ready")приложение пишет файл, когда прогрелось

ForAll и ForAny: комбинируем условия#

Реальному сервису мало одного условия, и вот здесь включаются комбинаторы. ForAll — конъюнкция: стратегии исполняются по очереди, и ожидание пройдёт, когда каждая вернёт nil; порядок имеет значение — порт логично проверить раньше HTTP-пробы, чтобы запросы не улетали в ещё закрытый порт. Общий бюджет задаёт WithDeadline: дедлайн накрывает все вложенные стратегии разом, а WithStartupTimeoutDefault подставляет таймаут только тем из них, у которых личного нет. ForAny — дизъюнкция: каждая стратегия стартует в своей горутине, первый успех отменяет остальные, и ожидание завершается. У дизъюнкции жёсткая семантика провала: любая стратегия, вернувшая ошибку, немедленно роняет всё ожидание, даже если соседняя вот-вот бы прошла, — ретраев внутри комбинатора нет. Устаревший WithStartupTimeout у ForAll не сломает старый код: он просто передаёт значение в WithDeadline, и набор по-прежнему ограничивается целиком. Приятная мелочь: nil-элементы внутри списка комбинатор молча пропускает — модуль может дописывать стратегии по ходу инициализации, не проверяя каждый элемент на пустоту. Практический ориентир: два-три условия легко живут в ForAll с одним дедлайном; больше — повод задуматься, не ждёте ли вы взаимоисключающих событий, и не превратится ли набор в вечный список, где одно условие не сработает никогда.

ForAll: порт и лог вместе

GO
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
	ContainerRequest: testcontainers.ContainerRequest{
		Image:        "postgres:17-alpine",
		Env:          map[string]string{"POSTGRES_PASSWORD": "s3cret"},
		ExposedPorts: []string{"5432/tcp"},
		WaitingFor: wait.ForAll(
			wait.ForListeningPort("5432/tcp"),
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2),
		).WithDeadline(45 * time.Second),
	},
	Started: true,
})
if err != nil {
	t.Fatalf("postgres не готов: %v", err)
}

ForAll требует оба условия: сначала откроется порт, затем лог готовности появится второй раз — так отсекается окно, когда Postgres напечатал строку и ушёл в восстановление. WithDeadline ставит общий бюджет в сорок пять секунд на оба условия, и он не суммируется с личными таймаутами стратегий. Этот набор — дословная связка, которой живёт встроенный модуль Postgres.

Таймауты и интервалы: чей бюджет на что#

У каждой стратегии личный таймаут старта — по умолчанию шестьдесят секунд — и опрос каждые сто миллисекунд. WithStartupTimeout сужает личный бюджет: Redis-модуль даёт порту десять секунд, LocalStack просит сто двадцать на HTTP-пробу — тяжёлые образы просто не успевают за дефолтом. На комбинаторах арифметика другая: WithDeadline накрывает весь набор целиком и не складывается с личными таймаутами — сработает более короткий контекст. Интервал опроса — отдельная ручка WithPollInterval: сотня миллисекунд хороша для дешёвых проверок, но HTTP-проба ходит по сети на каждой итерации, и для неё ставят полсекунды или секунду. Щедрый таймаут вредит не меньше короткого: реальный сбой вместо быстрого красного теста минуту держит прогон в тишине, умножая это на число тестов. Отмена идёт через контекст, и при истечении бюджета ошибка уносит число сделанных попыток и последнюю причину — разбор не начинается с нуля. Страховка снизу тоже есть: пока стратегия ждёт, библиотека сверяет состояние контейнера, и процесс, умерший от OOM или завершившийся с кодом, возвращает ошибку немедленно — молчаливого ожидания мёртвого контейнера не случится. Заметьте: HTTP-проба ограничена и собственным таймаутом клиента в секунду на запрос — зависший сервис не заблокирует опрос дольше этого окна, и стратегия просто сделает следующую попытку.

Своя стратегия: интерфейс wait.Strategy

GO
type cmdReady struct {
	cmd      []string
	interval time.Duration
}

// WaitUntilReady — единственный метод интерфейса wait.Strategy
func (r cmdReady) WaitUntilReady(ctx context.Context, target wait.StrategyTarget) error {
	ticker := time.NewTicker(r.interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			code, _, err := target.Exec(ctx, r.cmd)
			if err != nil {
				return err
			}
			if code == 0 {
				return nil
			}
		}
	}
}

// использование в декларации:
// WaitingFor: cmdReady{
//   cmd:      []string{"/app/check.sh"},
//   interval: 250 * time.Millisecond,
// }

Интерфейс стратегии — один метод: WaitUntilReady получает контекст и StrategyTarget — обёртку над контейнером с Host, Inspect, MappedPort, Logs, Exec и State. Наша стратегия тикает с заданным интервалом и ждёт нулевой код выхода служебной команды; отмена приходит через ctx.Done, как у встроенных стратегий. Ноль кода — договорённость ExecStrategy, поэтому при желании сравнивайте и другие коды под свой протокол.

Встроенные стратегии закрывают девять десятых случаев, а остальное добирают комбинаторы и свои типы. Помните про модули: Redis, Postgres и Kafka подставляют наборы условий сами, а CustomizeRequest-опции WithWaitStrategy и WithAdditionalWaitStrategy заменяют или дополняют их — вторая собирает общую ForAll с дедлайном в шестьдесят секунд. Из экзотики пригодятся ForExec — готовность по коду выхода команды внутри контейнера — и ForNop, оборачивающий вашу функцию в стратегию без отдельного типа. Дисциплина выбора простая: порт для утилит и образов без прикладных сигналов, лог для сервисов со строкой старта, HTTP для прикладных проб, SQL для движков баз. Такое «ожидание контейнера go» переживает и медленный CI, и быстрый ноутбук, не тратя лишних секунд. И помните про параллельные прогоны: чем короче честное ожидание, тем быстрее проходит весь набор тестов. Дальше по курсу testcontainers — тринадцатый урок про логи и файлы, а драйверную часть для ForSQL удобно копать в курсе по pgx.

Проверка: образ без шелла

Вы ждёте ForListeningPort("9000/tcp") от образа, в котором нет /bin/sh. Что произойдёт?

Проверка: ForAny и медленная ошибка

В ForAny переданы две стратегии: первая вернёт ошибку через минуту, вторая — nil через пять секунд. Чем закончится ожидание?

Что вернёт GenericContainer

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

// сервис печатает в лог:
//     Server started on :8080

WaitingFor: wait.ForLog("server started").
    WithStartupTimeout(10 * time.Second),

Соберите комбинатор

Восстановите декларацию: комбинация порта и лога готовности плюс политика запуска.

ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "postgres:17-alpine", ExposedPorts: []string{"5432/tcp"}, WaitingFor: wait.ForAll( wait.("5432/tcp"), wait.("database system is ready to accept connections"), ), }, : true, })

Соберите путь ожидания

Расставьте шаги: от декларации стратегии до готового контейнера и граничного падения.

Строки

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

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

Типовые ошибки ожидания#

  • Sleep вместо стратегии: медленно на локальной машине и флейково на CI
  • ждать лог без WithOccurrence, когда строка готовности печатается повторно
  • ForHTTP без WithPort на многопортовом образе: запросы уходят на наименьший порт, а не на нужный
  • шаблон регулярки без AsRegexp: строка ищется как обычная подстрока
  • дефолтный интервал опроса на тяжёлой HTTP-пробе: лишние сетевые походы на каждой итерации
  • щедрый таймаут вместо честного: сбой держит прогон минуту в тишине
  • своя стратегия игнорирует ctx.Done: опрос продолжается после отмены теста
  • ForAny как страховка от неверной настройки: первая же ошибка валит всё ожидание

Карточки: wait-стратегии

Термин

wait.Strategy

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

Интерфейс ожидания из пакета wait: один метод WaitUntilReady(context.Context, StrategyTarget) error. Своя стратегия — это тип с этим методом; StrategyTarget даёт Host, Inspect, MappedPort, Logs, Exec и State.

1 / 6

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

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

Вывод

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

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

  • Wait-стратегия — интерфейс wait.Strategy с единственным методом WaitUntilReady; WaitingFor исполняется PostStart-хуком, и GenericContainer не возвращается до nil.
  • Базовый набор: ForListeningPort для порта, ForLog для строки в логах, ForHTTP для реальной HTTP-пробы, ForSQL для SELECT 1 через database/sql.
  • ForAll требует все условия по очереди, ForAny — первый успех в параллели, но любая ошибка валит ожидание; общий бюджет задаёт WithDeadline.
  • Дефолты: таймаут старта шестьдесят секунд, интервал опроса сто миллисекунд; тонкая настройка — WithStartupTimeout и WithPollInterval.
  • Своя стратегия — тип с методом WaitUntilReady: тикать вручную, уважать ctx.Done и пользоваться Host, Exec и Inspect у StrategyTarget.

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

Соберите тест с GenericContainer и WaitingFor из ForAll: ForListeningPort плюс ForLog готовности выбранного образа. Затем смените ForLog на ForHTTP по пути healthz с WithPort и StatusCodeMatcher под свой сервис. Найдите образ без шелла и проверьте, как ForListeningPort ведёт себя без внутренней проверки. Финал: напишите стратегию на target.Exec с интервалом в четверть секунды и убедитесь, что после отмены теста она выходит по ctx.Done, а не висит.

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

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

Почему ForLog не видит строку, которая точно была в логах?

Частые причины — регистр и повтор: подстрочный поиск идёт по сырым байтам и чувствителен к регистру, а строка готовности может печататься не один раз — при восстановлении базы та же фраза появляется дважды. Проверьте точный текст в docker logs, поставьте WithOccurrence, если вхождений несколько, и помните: вместе с ошибкой стратегия сообщает число найденных вхождений — это сразу отделяет «строки нет» от «строка ещё не дописана». Помогает и сухой прогон с печатью логов контейнера: сверка точной фразы снимает половину таких загадок.

Нужна ли своя стратегия, если в образе уже есть HEALTHCHECK?

Если хотите переиспользовать вердикт Docker — берите ForHealthCheck: она ждёт переход состояния контейнера в healthy, полагаясь на HEALTHCHECK из образа. Порт и лог остаются независимыми проверками: healthcheck описывает здоровье процесса, а стратегия порта — доступность снаружи. В тестах разумно доверять готовому healthcheck и не дублировать его собственными HTTP-пробами — но только если он действительно есть и настроен под тестовую нагрузку.

Чем WithStartupTimeout отличается от WithDeadline?

WithStartupTimeout ставит бюджет одной конкретной стратегии: у ForListeningPort, ForLog, ForHTTP и ForSQL это личный таймер с шестьюдесятью секундами по умолчанию. WithDeadline живёт на комбинаторе ForAll или ForAny и накрывает весь набор разом, не складываясь с личными таймаутами, — сработает более короткий контекст. Устаревший WithStartupTimeout на ForAll теперь просто передаёт значение в WithDeadline, так что старый код ограничивает набор целиком.

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

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

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

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

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

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

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