---
title: "Отладка упавших тестов"
description: "Отладка упавших тестов: логи после смерти контейнера, Exec для живой диагностики, State и код выхода, дамп логов при провале и осмотр контейнера руками."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/22-otladka-upavshih/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

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

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

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

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

### Логи упавшего контейнера: 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. Что это значит?

- Exec сломался: при любой проблеме внутри контейнера возвращается ошибка
- ✅ Механизм exec отработал, а команда завершилась с кодом 1 — например, cat не нашёл файл
- Ничего страшного: код выхода от Exec всегда ноль
- Контейнер остановлен: ненулевой код означает смерть процесса контейнера

Ошибка из Exec — про механизм: контейнер жив, exec-канал открыт. Код выхода — ответ самой команды: cat с отсутствующим файлом вернёт 1, sh с опечаткой — 127. Проверяйте оба результата раздельно: t.Fatalf на err, сравнение с нулём на code. Хелпер RequireContainerExec делает ровно это: require.NoError и require.Zero на коде.

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

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

> **Живое место: дамп, который не шумит.** Рабочий шаблон: t.Cleanup(func() { if t.Failed() { tailLogs(t, ctx, ctr, 30) } }) — зарегистрируйте сразу после запуска контейнера. Держите хвост коротким: тридцати строк обычно хватает, а огромные простыни никто дочитывает. Обратный порядок Cleanup выводит ваш дамп раньше Terminate из CleanupContainer, то есть по ещё не удалённому контейнеру. В зелёном прогоне t.Failed() возвращает false — отчёт остаётся чистым. Второй слой: t.Helper в хелперах, чтобы строки отчёта указывали на сам тест, а не на утилиту. Третий: не доклеивайте stderr руками — Logs уже отдаёт оба потока одним журналом.

### Ручной осмотр: фиксированное имя и выключенный 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, упавшая джоба; именно его выключение оставляет мусор в покое. Отладили — верните уборку и уберите имя: фиксированные имена конфликтуют при параллельных прогонах, а устройство уборщика разобрано в [восемнадцатом уроке](/courses/testcontainers/lessons/18-ryuk-i-resursy/).

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

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

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

- ✅ Контейнер удалён: Terminate из Cleanup отработал и при провале — docker ps его уже не покажет
- Контейнер остался жить: уборка запускается только после зелёного теста
- Контейнер удалит уборщик мгновенно в момент падения assert'а, не дожидаясь конца теста
- Контейнер зависнет в статусе created: тест не вызвал Terminate явно

CleanupContainer регистрирует Terminate в tb.Cleanup, а Cleanup выполняется всегда — и при провале тоже. Nil-контейнер для него no-op, безопасные ошибки вроде not found проглатываются, остальное валит прогон с сообщением. Поэтому улики собирают до конца теста, а не после: docker logs по удалённому контейнеру ничего не вернёт.

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

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

```go
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)
```

Подсказка: Журнал возвращает метод контейнера с тем же именем, что у docker logs; поток закрывает Close, а до байтов доводит io.ReadAll.

Правильные ответы в пропусках: `Logs`, `Close`, `ReadAll`

Сценарий «testcontainers отладка» собирается в дисциплину из четырёх привычек. Первая — улики до уборки: дамп хвоста журнала в Cleanup с проверкой t.Failed и осмысленные сообщения t.Fatalf. Вторая — живая диагностика: Exec с ps и cat отвечает на вопросы о процессах и конфигах, пока контейнер дышит. Третья — диагноз по цифрам: State и код выхода различают плохой конфиг, убийство по памяти и ошибку демона. Четвёртая — ручной осмотр для сложных случаев: фиксированное имя, выключенный Ryuk и терминал. Дальше по курсу приёмы пригодятся сами собой: [тринадцатый урок](/courses/testcontainers/lessons/13-logi-i-fajly/) — про подписку на логи в реальном времени и копирование файлов, [двадцать третий](/courses/testcontainers/lessons/23-grabli/) соберёт грабли — порты, скорость и флейки — в один список с противоядиями, а ручной осмотр из этого урока станет запасным выходом, когда ни одно из них не сработает.

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

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

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

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

- false: метод отслеживает смерть процесса в реальном времени
- ✅ true: это кэшированный флаг библиотеки, а живое состояние даёт State(ctx)
- панику: у мёртвого контейнера нет состояния
- false, но только со второго вызова

IsRunning читает атомарный флаг, который библиотека выставляет на собственных Start, Stop и Terminate; самопроизвольную смерть процесса он не замечает. Живую картину дают State и Inspect — они спрашивают демона: Status exited и ExitCode 137 подскажут про нехватку памяти, а OOMKilled=true поставит точку в диагнозе.

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

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

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

1. `прочитать сообщение упавшего assert'а и весь вывод t.Logf`
2. `снять хвост журнала контейнера: Logs работает до удаления`
3. `спросить State: Status, ExitCode, OOMKilled и Error`
4. `если контейнер жив — Exec: ps aux и cat конфига`
5. `сформулировать гипотезу: конфиг, порт, память или скорость старта`
6. `воспроизвести локально: фиксированное имя и TESTCONTAINERS_RYUK_DISABLED=true`
7. `осмотреть руками: docker logs, docker exec -it, файлы внутри`
8. `починить, вернуть уборку и убедиться, что тест зелёный`

> **Про песочницу: Docker не запускается в браузере.** Честная оговорка: в песочнице сайта эти примеры не исполнятся — Logs, Exec и State требуют живого демона Docker, а браузерная среда его не поднимает. Рабочая схема локальная: сохраните тест рядом с go.mod и запустите go test на машине с Docker — в отчёте появятся настоящие журналы. Читать урок можно и без Docker: каждый фрагмент разобран построчно, а таблица симптомов работает как шпаргалка. Домашний эксперимент: поднимите контейнер с заведомо кривым Env, дождитесь красного теста и найдите причину по дампу журнала — не заглядывая в docker logs руками.

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

- **Logs(ctx)** — Журнал контейнера: сразу stdout и stderr, мультиплексированный формат разбирается библиотекой. Работает и после смерти процесса, пока контейнер не удалён; вывод читается через io.ReadAll.
- **Exec(ctx, cmd, ...)** — Команда внутри живого контейнера: код выхода int, io.Reader с выводом и ошибка. Ошибка — про механизм exec, код — ответ команды. Опции: WithUser, WithWorkingDir, WithEnv.
- **State(ctx)** — Обёртка над docker inspect: Status, Running, ExitCode, OOMKilled, Error. Диагноз по цифрам: 137 — SIGKILL и часто память, 127 — команда не найдена, 125 — ошибка демона.
- **IsRunning()** — Кэшированный атомарный флаг библиотеки: обновляется на Start, Stop и Terminate, самопроизвольную смерть процесса не замечает. Живое состояние — State или Inspect.
- **CleanupContainer** — Регистрирует Terminate в Cleanup теста: nil-контейнер — no-op, безопасные ошибки проглатываются, остальное валит прогон. Удаляет контейнер и при красном тесте — дампите журнал до конца.
- **TESTCONTAINERS_RYUK_DISABLED** — Выключатель уборщика сессии: сравнивается со строкой true буквально. Контейнеры переживают смерть тестового процесса — режим ручного осмотра; на постоянной основе включать нельзя, мусор останется.

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

- 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.

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

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

Потому что 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 — команда не найдена.
