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

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

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

Урок 22 из 25 ~16 мин +50 XP

Отладка упавших тестов

Тест с контейнером падает тише обычного: assert краснеет, а улика — процесс в чужой операционной системе — исчезает вместе с уборкой. Отладка тестов Go на живых контейнерах опирается на три источника правды: логи упавшего контейнера, живая диагностика через Exec и State с кодом выхода. Разберём, как вытащить последние строки журнала в момент провала, чем Exec отличается от docker exec, что именно рассказывает код выхода и почему CleanupContainer стирает улики даже при красном тесте. Отдельно — ручной сценарий: фиксированное имя контейнера и выключенный Ryuk, чтобы осмотреть всё руками. Инструменты одни и те же на любой машине с Docker, разница лишь в том, кто снимает показания — тест или вы. Связка «testcontainers отладка» здесь разобрана инструмент за инструментом: от симптома до причины.

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

Красный тест с контейнером — это расследование, а не гадание. У assert'а всегда есть свидетель: контейнер, который успел поработать или так и не поднялся. Беда в том, что добросовестная уборка работает против следователя: контейнер удалён, и docker ps уже ничего не покажет. Поэтому отладка тестов Go строится на сборе улик до того, как их стёрли: журнал контейнера доступен и после смерти процесса, Exec отвечает на вопросы живому сервису, State фиксирует код выхода, а t.Logf выводит всё это в отчёт прямо в момент провала. Порядок простой: сначала факты — что написал сервис и чем закончил его процесс, потом интерпретация. Интерпретация без фактов — угадывание: переустановка версии образа наудачу тратит минуты, а верная строка журнала отвечает сразу. Механику Terminate и Cleanup мы разбирали в третьем уроке — сейчас она выступает в роли подозреваемого: уборщик прячет доказательства, и об этом полезно знать заранее.

Логи упавшего контейнера: Logs работает и после смерти#

Logs — первый инструмент, и у него приятное свойство: Docker хранит вывод процесса даже после его смерти, пока контейнер не удалён. Сигнатура короткая — Logs(ctx) возвращает io.ReadCloser и ошибку; внутри библиотека запрашивает у демона сразу и stdout, и stderr, а заодно разбирает мультиплексированный формат, в котором Docker склеивает потоки, если у образа не включён TTY. Дальше дело теста: io.ReadAll — и весь журнал в памяти. Логи читаются и у контейнера, упавшего прямо во время wait-стратегии: GenericContainer вернёт ошибку вида wait until ready, обернув причину, но сам контейнер ещё не удалён — это окно для чтения. Снимайте показания в этом окне: контейнер доживёт до уборки, но не дольше, а хвост ценнее целого — причина почти всегда в последних строках перед смертью процесса. Привычка, которая экономит часы: поднимать уровень логов сервиса в тестовом окружении через Env — дорожка улик станет длиннее уже на первом прогоне. Тонкость: журнал живёт в демоне, а не в тестовом процессе, поэтому снять его можно в любой момент жизни контейнера — и до, и после смерти процесса.

Дамп хвоста логов при провале

GO
// tailLogs печатает последние n строк логов контейнера в отчёт теста.
func tailLogs(t *testing.T, ctx context.Context, ctr testcontainers.Container, n int) {
	t.Helper()

	r, err := ctr.Logs(ctx)
	if err != nil {
		t.Logf("логи недоступны: %v", err)
		return
	}
	defer r.Close()

	all, err := io.ReadAll(r)
	if err != nil {
		t.Logf("чтение логов: %v", err)
		return
	}

	rows := strings.Split(strings.TrimRight(string(all), "\n"), "\n")
	if len(rows) > n {
		rows = rows[len(rows)-n:]
	}
	for _, row := range rows {
		t.Logf("ctr | %s", row)
	}
}

// регистрация: логи уйдут в отчёт, только если тест упал
t.Cleanup(func() {
	if t.Failed() {
		tailLogs(t, ctx, ctr, 30)
	}
})

Хелпер снимает журнал контейнера и печатает последние n строк через t.Logf — они попадут в отчёт, только если тест упал: регистрация в Cleanup с проверкой t.Failed не шумит в зелёном прогоне. Порядок на вашей стороне: дамп зарегистрирован позже CleanupContainer, а Cleanup исполняется в обратном порядке, поэтому печать сработает раньше Terminate. Ошибка Logs не роняет тест: контейнер мог быть уже удалён — хелпер отметит пропуск.

Exec: живая диагностика работающего контейнера#

Если контейнер жив, но ведёт себя странно, Exec — ваш ssh внутрь. Сигнатура: Exec(ctx, cmd []string, options ...ProcessOption), и три результата — код выхода int, io.Reader с выводом и ошибка. Ошибка означает проблему механизма: например, exec невозможен, потому что процесс контейнера уже умер. Код выхода — ответ самой команды: cat вернёт 1 на отсутствующем файле, ps отработает с нулём. Нулевой код тоже не гарантия правды: команда могла отработать, но не над тем файлом — вывод читайте вместе с кодом. Опции настраивают процесс: WithUser меняет пользователя, WithWorkingDir — рабочий каталог, WithEnv дописывает переменные окружения. Классическая связка для диагностики: ps aux, чтобы увидеть, какие процессы вообще живут и кто держит порт, и cat конфига — убедиться, что файл внутрь попал с тем содержимым, каким задумано. В библиотеке есть готовая обёртка RequireContainerExec: она выполняет команду, требует нулевой код выхода и возвращает вывод строкой — удобно для быстрых проверок. Момент, который легко упустить: минимальные образы не кладут ни ps, ни curl — отсутствующая команда вернётся ошибкой или кодом 127.

Exec: процессы и конфиг изнутри

GO
func TestDiagnoseInside(t *testing.T) {
	ctx := context.Background()
	// ctr запущен через GenericContainer и готов к работе

	// кто вообще живёт в контейнере
	code, out, err := ctr.Exec(ctx, []string{"sh", "-c", "ps aux | head -5"})
	if err != nil {
		t.Fatalf("exec ps: %v", err)
	}
	if code != 0 {
		t.Errorf("ps: код выхода %d", code)
	}
	dump(t, out) // чтение io.Reader и печать строк через t.Logf

	// какой конфиг реально увидел сервис
	code, out, err = ctr.Exec(ctx, []string{"cat", "/etc/app/config.yaml"})
	if err != nil {
		t.Fatalf("exec cat: %v", err)
	}
	if code != 0 {
		t.Errorf("cat: код выхода %d — файла нет?", code)
	}
	dump(t, out)
}

Обе команды исполняются внутри контейнера, пока его процесс жив. Конвейер ps aux | head -5 отвечает, какие процессы запущены; cat конфига показывает содержимое, которое реально прочитал сервис. Ошибка и код выхода проверяются раздельно: err говорит о проблеме механизма exec, code — ответ самой команды. Вывод приходит через io.Reader и печатается тем же способом, что и логи. В минимальных образах ps может отсутствовать — тогда вернётся ошибка или код 127.

Симптом → инструмент: с чего начать расследование#

СимптомПервый инструментЧто искать
Тест красный, контейнер удалён уборкойдамп логов в самом тестехвост журнала: t.Cleanup с t.Failed и печать через t.Logf
wait-стратегия упала по таймаутуLogs сразу после ошибки стартаошибки сервиса, кривой Env, не тот порт
Сервис жив, но отвечает ошибкойExec: ps aux и cat конфигакто слушает порт, какой конфиг реально прочитан
Контейнер умер сам во время тестаState и код выходаExitCode, OOMKilled, Error — причина смерти
Симптом воспроизводится только локальнофиксированное имя и осмотр рукамиdocker logs, docker exec, файлы внутри контейнера
После прогона висят чужие контейнерысписок по служебным меткамdocker ps --filter по меткам сессии; уборщик — тема урока про Ryuk

State и код выхода: диагноз по фактам#

State(ctx) — обёртка над docker inspect: возвращает *container.State, структуру демона с фактами о жизни процесса. Самое полезное поле — ExitCode: код, с которым завершился процесс. Ноль — чистый выход, 137 — SIGKILL, часто по памяти, 125 — ошибка самого демона при запуске, 127 — команда не найдена: эти числа мгновенно сужают круг подозреваемых. Рядом живёт Error — сообщение, которое демон записал для контейнера, и OOMKilled — флаг смерти из-за нехватки памяти. Status и Running дают строку и булев снимок состояния: running, exited, created. А вот IsRunning — не живая проверка: метод возвращает кэшированный атомарный флаг, который библиотека обновляет на собственных Start, Stop и Terminate, и молчит, когда процесс умирает сам. Живое состояние дают State и Inspect — полный ответ демона, где рядом со статусом лежат порты и сети; Inspect — тот самый docker inspect, только типизированный в InspectResponse. Ошибка из State — повод для отдельного assert'а: демон недоступен или контейнер исчез раньше времени, и это тоже улика.

Проверка: код выхода от Exec

ctr.Exec вернул err = nil, но код выхода 1. Что это значит?

Ассерты с уликами: логи в момент провала#

Красный тест без улик — потерянное время CI: прогон придётся повторять, чтобы просто увидеть симптом. Правило — успеть снять показания до уборки. Сообщения t.Fatalf пишите содержательно: имя образа, код выхода, ключевая строка журнала — деталь, которая позволит понять провал, не запуская прогон второй раз. Хвост логов дампите в двух местах: перед каждым t.Fatalf после старта и в Cleanup с проверкой t.Failed — Cleanup выполняется при любом исходе, флаг покажет, красный ли тест, и логи уйдут в отчёт только при провале. Подводный камень: CleanupContainer стоит вызывать первым — сразу после Run и до проверки ошибки; он регистрирует Terminate в Cleanup, и контейнер удаляется даже при красном тесте. Поведение правильное, но улики после него недоступны: docker logs по удалённому контейнеру не работает. На помощь приходит порядок Cleanup: функции исполняются в обратном порядке, поэтому дамп, зарегистрированный позже уборки, сработает раньше Terminate — по ещё живому контейнеру. Такой дамп превращает отчёт CI в самодостаточный протокол: причина видна без повторного прогона и без доступа к машине.

Ручной осмотр: фиксированное имя и выключенный Ryuk#

Когда улик из отчёта мало, контейнер стоит осмотреть руками — уже не из теста, а из терминала. Схема локальная: задайте контейнеру фиксированное имя — Name: billing-debug — и выключите уборщика сессии: TESTCONTAINERS_RYUK_DISABLED=true, переменная сравнивается со строкой true буквально. Имя делает контейнер находным: docker ps --filter name=billing-debug, дальше docker logs и docker exec -it billing-debug sh — живая диагностика руками. Пока идёт осмотр, контейнер в вашем распоряжении: открытые порты, запущенные процессы и файлы видны изнутри — всё то, что тесту показывать незачем. Здесь нужна честность о двух уборках. Первая — ваша: Terminate из t.Cleanup или CleanupContainer удаляет контейнер при штатном конце теста, красном или зелёном, и выключенный Ryuk этому не мешает; для ручного осмотра на время отладки закомментируйте уборку. Вторая — супервизор сессии: он подметает за процессами, умершими без уборки — kill, os.Exit, упавшая джоба; именно его выключение оставляет мусор в покое. Отладили — верните уборку и уберите имя: фиксированные имена конфликтуют при параллельных прогонах, а устройство уборщика разобрано в восемнадцатом уроке.

Что станет с контейнером после красного теста

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

// Тест упал на assert'е.
// Первой строкой после Run вызван:
//     testcontainers.CleanupContainer(t, ctr)
// Ryuk включён — настройка по умолчанию.

Соберите чтение логов

Восстановите хелпер чтения журнала: поток, закрытие, чтение до байтов.

r, err := ctr.(ctx) if err != nil { t.Fatalf("логи: %v", err) } defer r.() b, err := io.(r) if err != nil { t.Fatalf("чтение: %v", err) } t.Logf("хвост журнала: %s", b)

Сценарий «testcontainers отладка» собирается в дисциплину из четырёх привычек. Первая — улики до уборки: дамп хвоста журнала в Cleanup с проверкой t.Failed и осмысленные сообщения t.Fatalf. Вторая — живая диагностика: Exec с ps и cat отвечает на вопросы о процессах и конфигах, пока контейнер дышит. Третья — диагноз по цифрам: State и код выхода различают плохой конфиг, убийство по памяти и ошибку демона. Четвёртая — ручной осмотр для сложных случаев: фиксированное имя, выключенный Ryuk и терминал. Дальше по курсу приёмы пригодятся сами собой: тринадцатый урок — про подписку на логи в реальном времени и копирование файлов, двадцать третий соберёт грабли — порты, скорость и флейки — в один список с противоядиями, а ручной осмотр из этого урока станет запасным выходом, когда ни одно из них не сработает.

Типовые ошибки отладки#

  • ассерт без контекста: t.Fatal("fail") вместо сообщения с кодом выхода и строкой журнала
  • чтение логов после уборки: Terminate удалил контейнер — Logs вернёт not found
  • IsRunning как живая проверка: флаг кэширован, процесс мог умереть — берите State
  • код выхода свален с ошибкой: err про механизм exec, code — ответ команды
  • ps и curl в минимальном образе: инструментов нет — отсутствующая команда приходит ошибкой или кодом 127
  • фиксированное имя в общем прогоне: конфликт при параллельном старте — имя только для отладки
  • Ryuk выключен навсегда: мусор после убитых прогонов никто не подметёт
  • весь журнал вместо хвоста: тысячи строк в отчёте, а причина — в последних двадцати

Проверка: IsRunning после смерти процесса

Процесс контейнера умер сам — его убил OOM-менеджер. Тест не вызывал Stop и Terminate. Что вернёт ctr.IsRunning()?

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

Расставьте шаги: от красного assert'а до починенного теста.

Строки

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

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

Карточки: инструменты отладки

Термин

Logs(ctx)

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

Журнал контейнера: сразу stdout и stderr, мультиплексированный формат разбирается библиотекой. Работает и после смерти процесса, пока контейнер не удалён; вывод читается через io.ReadAll.

1 / 6

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

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

Вывод

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

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

  • Logs(ctx) отдаёт stdout и stderr одним журналом и работает после смерти процесса, пока контейнер не удалён: io.ReadAll — и улики в руках.
  • Exec(ctx, cmd, options...) возвращает код выхода, вывод и ошибку: err про механизм exec, code — ответ команды; живая диагностика — ps aux и cat конфига.
  • State(ctx) — docker inspect по-типизированному: ExitCode, OOMKilled и Error объясняют смерть; IsRunning — лишь кэшированный флаг, живое состояние даёт State.
  • Улики снимают до уборки: дамп хвоста журнала в t.Cleanup с проверкой t.Failed попадёт в отчёт только при провале — CleanupContainer удаляет контейнер в любом случае.
  • Для ручного осмотра: фиксированное имя и TESTCONTAINERS_RYUK_DISABLED=true — контейнер переживёт прогон; отладили — верните уборку.

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

Напишите хелпер tailLogs(t, ctx, ctr, n) и тест с заведомо битым Env, чтобы сервис падал при старте: дождитесь провала и найдите причину по дампу хвоста журнала в отчёте. Добавьте Cleanup с проверкой t.Failed и убедитесь, что зелёный прогон молчит. Затем опросите живой контейнер через Exec: ps aux и cat конфига, проверив код выхода отдельно от ошибки. Финал: запустите тест с TESTCONTAINERS_RYUK_DISABLED=true и фиксированным именем, закомментируйте уборку и осмотрите контейнер руками через docker exec -it.

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

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

Почему нельзя посмотреть контейнер руками после упавшего теста?

Потому что CleanupContainer уже удалил его: Cleanup выполняется и при провале, а docker logs и docker exec по удалённому контейнеру не работают. Улики собирают внутри теста: дамп хвоста журнала через t.Logf в Cleanup с проверкой t.Failed попадёт в отчёт. Если нужен ручной осмотр — на время отладки уберите уборку, задайте фиксированное имя и выключите Ryuk переменной TESTCONTAINERS_RYUK_DISABLED=true, а после разбора верните всё как было. Держите дамп в Cleanup: это главный источник улик в отчёте CI, где терминала с живым контейнером нет.

Чем Exec отличается от обычного docker exec?

По смыслу ничем: тест получает типизированную обёртку — код выхода числом, вывод через io.Reader и ошибку от механизма. Возврат значений вместо текста позволяет ассертить: сравнить код с нулём, проверить вывод на подстроку, упасть с контекстом. Опции задают пользователя, рабочий каталог и переменные окружения процесса. Готовый хелпер RequireContainerExec требует нулевой код сразу и возвращает вывод строкой — удобно для быстрых проверок внутри теста.

Что значит код выхода 137 и почему это не ошибка?

Это 128 плюс номер сигнала: 137 — SIGKILL, которым демон гасит процесс при нехватке памяти или по таймауту останова. Для теста это диагноз: State вернёт ExitCode 137 и, если виновата память, OOMKilled=true — смотреть нужно лимиты и потребление, а не конфиги. Ошибкой такое не считается: с точки зрения exec процесс завершился штатно, просто с ненулевым кодом. Тем же приёмом читается 127 — команда не найдена.

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

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

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

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

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

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

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