Содержание урока · 10 разделов
- Логи упавшего контейнера: Logs работает и после смерти
- Exec: живая диагностика работающего контейнера
- Симптом → инструмент: с чего начать расследование
- State и код выхода: диагноз по фактам
- Ассерты с уликами: логи в момент провала
- Ручной осмотр: фиксированное имя и выключенный Ryuk
- Типовые ошибки отладки
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Красный тест с контейнером — это расследование, а не гадание. У 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: процессы и конфиг изнутри
GOfunc 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 в самодостаточный протокол: причина видна без повторного прогона и без доступа к машине.
Рабочий шаблон: 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, упавшая джоба; именно его выключение оставляет мусор в покое. Отладили — верните уборку и уберите имя: фиксированные имена конфликтуют при параллельных прогонах, а устройство уборщика разобрано в восемнадцатом уроке.
Что станет с контейнером после красного теста
Прочитайте код и выберите, что он выведет:
// Тест упал на assert'е.
// Первой строкой после Run вызван:
// testcontainers.CleanupContainer(t, ctr)
// Ryuk включён — настройка по умолчанию.Соберите чтение логов
Восстановите хелпер чтения журнала: поток, закрытие, чтение до байтов.
Сценарий «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, Exec и State требуют живого демона Docker, а браузерная среда его не поднимает. Рабочая схема локальная: сохраните тест рядом с go.mod и запустите go test на машине с Docker — в отчёте появятся настоящие журналы. Читать урок можно и без Docker: каждый фрагмент разобран построчно, а таблица симптомов работает как шпаргалка. Домашний эксперимент: поднимите контейнер с заведомо кривым Env, дождитесь красного теста и найдите причину по дампу журнала — не заглядывая в docker logs руками.
Карточки: инструменты отладки
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя посмотреть контейнер руками после упавшего теста?
Потому что 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 с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах