---
title: "Логи и файлы контейнера"
description: "Файлы внутрь контейнера: Files с HostFilePath и Reader, CopyToContainer для живого сервиса, FileMode. Логи: Logs(), LogConsumer и тест на строку stdout."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/13-logi-i-fajly/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Логи и файлы контейнера

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

Интеграционный тест общается с контейнером по двум каналам вне API сервиса: файлы кладутся внутрь до старта процесса, а логи читаются из stdout и stderr самого контейнера. В этом уроке — оба. Файловый канал: поле Files в ContainerRequest с ContainerFile — HostFilePath или Reader, путь внутри и FileMode, — плюс методы CopyToContainer и CopyFileToContainer для уже работающего контейнера. Логовый: Logs() возвращает срез вывода одним ридером, а подписка LogConsumer через LogConsumerCfg стримит строки по мере их выхода. Соберём тест «сервис написал ожидаемую строку в лог» без единого сна и разберём упавший старт по логам самого контейнера. Сценарий «testcontainers логи» закрыт здесь целиком, а запрос «copyfile контейнер go» разобран метод за методом.

Контейнер в тесте — чёрный ящик, и оба провода к нему проходят мимо бизнес-API: файлы доставляются на уровень файловой системы, логи снимаются с вывода процесса. Так устроена универсальность: сервис без сети, база, брокер и подменный сервер общаются с тестом через одни и те же каналы — меняются только образы и порты. Без файлов не обходится ни один тест с конфигом: сервису нужен свой app.conf, сертификат, словарь или скрипт инициализации, и всё это кладётся внутрь декларацией Files или вызовом CopyToContainer в выбранный момент сценария. Логи же отвечают на главный вопрос упавшего прогона: почему сервис не поднялся. Docker хранит вывод процесса, и библиотека даёт к нему два доступа — срез через Logs() и живой поток через LogConsumer. Каркас контейнера из [первого знакомства с testcontainers](/courses/testcontainers/lessons/02-ustanovka-pervyj-kontejner/) здесь предполагается знакомым: образ, порты, Terminate в Cleanup.

### Files: файлы внутрь контейнера до старта

Поле Files в ContainerRequest — срез ContainerFile, где каждый элемент описывает одну копию: источник, путь внутри контейнера и права. Источник на выбор: HostFilePath ведёт к файлу на диске теста, Reader — к любому io.Reader, из которого содержимое читается байтами. Приоритет из исходников честный: если Reader задан, HostFilePath игнорируется — в декларации с обоими полями внутрь попадёт содержимое ридера. Валидация отсекает пустые комбинации: нужен либо HostFilePath, либо Reader, а ContainerFilePath обязателен всегда, иначе GenericContainer даже не дойдёт до Docker. FileMode — это int64 с правами в стиле chmod: 0o644 для конфига, 0o755 для скрипта, который придётся исполнять. Каталог testdata при этом остаётся обычным каталогом Go-проекта: файлы живут в системе контроля версий, и тест гоняет не «примерно такой конфиг», а точный его снимок. Копирование исполняет PostCreates-хуок: контейнер создан, процесс ещё не поднят, — и к моменту старта сервис видит файлы на своих путях, а wait-стратегия из [урока об ожиданиях](/courses/testcontainers/lessons/12-ozhidaniya-wait/) проверяет уже настроенный сервис. Ошибка копирования — опечатанный путь на хосте, отсутствие файла — возвращается из GenericContainer до старта процесса, и тест падает с точной причиной.

### Конфиг сервиса: файл с диска и Reader

```go
func TestServiceConfig(t *testing.T) {
	ctx := context.Background()

	// конфиг, собранный в памяти под конкретный тест
	buf := bytes.NewBufferString("log_level=debug\nworkers=2\n")

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			ExposedPorts: []string{"8080/tcp"},
			Files: []testcontainers.ContainerFile{
				{ // статичный файл из каталога testdata
					HostFilePath:      "testdata/service.conf",
					ContainerFilePath: "/etc/app/service.conf",
					FileMode:          0o644,
				},
				{ // Reader имеет приоритет над HostFilePath
					Reader:            buf,
					ContainerFilePath: "/etc/app/runtime.conf",
					FileMode:          0o644,
				},
			},
			WaitingFor: wait.ForListeningPort("8080/tcp"),
		},
		Started: true,
	})
	if err != nil {
		t.Fatalf("запуск: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(ctx) })

	// сервис читает оба конфига из /etc/app — без пересборки образа
}
```

Каждый элемент Files — одна копия с точным адресом внутри контейнера. Первый берёт содержимое с диска через HostFilePath, второй читает буфер через Reader: конфиг, собранный под конкретный тест, не требует отдельного файла в репозитории. FileMode задаёт права 0o644 обоим файлам. Копирование исполняется после create и до Start, поэтому сервис стартует уже с двумя конфигами, и wait-стратегия ждёт порт настроенного процесса, а не заглушки.

### CopyToContainer: файл в живой контейнер

Декларация Files срабатывает один раз — при создании. Когда файл нужен контейнеру, который уже живёт, сценарий «copyfile контейнер go» продолжают методы руками. CopyToContainer(ctx, fileContent []byte, containerFilePath string, fileMode int64) пишет байты в файл: конфиг, собранный на ходу, сгенерированный сертификат, выгрузку для проверки. CopyFileToContainer(ctx, hostFilePath, containerFilePath, fileMode) читает источник с диска и сам распознаёт каталог: для директории вызывается CopyDirToContainer, который кладёт содержимое каталога в родительский путь внутри контейнера — родитель обязан существовать, метод его не создаёт. Обратная дорога тоже есть: CopyFileFromContainer(ctx, filePath) возвращает io.ReadCloser с содержимым файла — так тест проверит, что сервис перезаписал конфиг или действительно написал отчёт. Под капотом все три метода заворачивают данные в tar-поток — тот же механизм Docker, что и у Files; разница в управлении моментом: файл подкладывается между шагами сценария, когда декларация уже отработала.

### Задача → метод

| Задача | Метод | Нюанс |
| --- | --- | --- |
| положить конфиг до старта сервиса | Files + HostFilePath | копия исполняется PostCreates-хуоком: после create, до Start |
| содержимое, собранное в памяти | Files + Reader | Reader главнее HostFilePath: файл на диске игнорируется |
| дописать файл в работающий контейнер | CopyToContainer | байты из []byte, момент выбирает тест |
| скопировать каталог целиком | CopyFileToContainer или CopyDirToContainer | метод распознаёт каталог сам; родительский путь обязан существовать |
| забрать файл из контейнера | CopyFileFromContainer | возвращает io.ReadCloser с содержимым файла |
| сделать файл исполняемым | FileMode: 0o755 | права в стиле chmod, тип int64 |

Таблица сводит выбор к простому правилу: декларация Files — для содержимого, известного до старта, методы — для момента, который выбирает тест. На практике оба канала живут в одном тесте и работают в паре: конфиг кладётся внутрь декларацией, а по логам тест судит, принял ли сервис этот конфиг. Весь сценарий testcontainers логи укладывается в три шага: срез Logs() для быстрого взгляда на прошлое, потребитель для живой подписки на настоящее и правило «диагностика до уборки» для упавших прогонов. Порядок важен и здесь: сначала подписка, потом действия сервиса, потом чтение результата — иначе строки уйдут мимо потребителя. Меняется от задачи к задаче только источник строк: база пишет порт и версию, брокер — топики и группы, ваш сервис — метки готовности. Дальше разберём каждый шаг по очереди, начиная со среза.

### Logs(): срез логов по требованию

Logs(ctx) возвращает io.ReadCloser со всем выводом контейнера: stdout и stderr идут одним потоком, и библиотека сама распарсивает мультиплексированный формат Docker — когда Tty выключен, восьмибайтовые заголовки кадров снимаются, и из ридера читаются чистые строки сервиса. Поток читается обычным bufio.Scanner построчно, до конца. Первый приём — отладка: тест упал, контейнер вот-вот будет убран, и вывод процесса остаётся единственным свидетелем; срез Logs() выводят в t.Logf целиком, пока контейнер жив. Второй приём — проверка содержимого: тест дожидается нужной строки и только потом делает вывод о поведении сервиса. Не путайте это с wait-стратегией ForLog из двенадцатого урока: она лишь решает, когда контейнер считать готовым, и содержимое строк наружу не отдаёт. Logs() же отдаёт всё, что процесс успел написать, — от первого байта до конца чтения, включая самый ранний вывод ещё не готового сервиса. На тяжёлых прогонах помните о цене: весь журнал копируется из демона целиком, поэтому для гигантских логов срез читают точечно или берут подписку.

### Отладка упавшего старта через логи

```go
func dumpLogs(t *testing.T, ctx context.Context, ctr testcontainers.Container) {
	t.Helper()

	if ctr == nil {
		return // контейнера нет — логи взять неоткуда
	}
	logs, err := ctr.Logs(ctx)
	if err != nil {
		t.Logf("логи недоступны: %v", err)
		return
	}
	defer logs.Close()

	for scanner := bufio.NewScanner(logs); scanner.Scan(); {
		t.Logf("контейнер: %s", scanner.Text())
	}
}

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

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			ExposedPorts: []string{"8080/tcp"},
			Files: []testcontainers.ContainerFile{
				{HostFilePath: "testdata/service.conf", ContainerFilePath: "/etc/app/service.conf", FileMode: 0o644},
			},
			WaitingFor: wait.ForListeningPort("8080/tcp"),
		},
		Started: true,
	})
	if err != nil {
		dumpLogs(t, ctx, ctr) // контейнер вернётся даже при ошибке старта
		t.Fatalf("запуск: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(ctx) })
}
```

GenericContainer при провале старта возвращает контейнер вместе с ошибкой: исходники прямо оставляют вызывающему шанс добить ресурс — и заодно прочитать его логи. Хелпер dumpLogs помечен t.Helper и пишет строки через t.Logf: в отчёт они попадут только при провале теста. Сканер читает поток построчно, defer закрывает ридер. Сервис, упавший на битом конфиге, покажет настоящую причину в первых же строках вывода.

### LogConsumer: живой поток строк

Срез Logs() статичен: он читает то, что процесс уже написал. Тесту «сервис написал ожидаемую строку» нужен поток — подписка на строки по мере их появления. Интерфейс LogConsumer — один метод Accept(Log), где Log несёт LogType со значением STDOUT или STDERR и Content с байтами сообщения. Исторический императивный путь — пара вызовов ctr.FollowOutput(consumer) и ctr.StartLogProducer(ctx): первый регистрирует потребителя, второй заводит горутину, читающую docker-логи и раздающую их всем подписчикам; гасит её StopLogProducer. Исходники помечают все три метода Deprecated: современный путь — поле LogConsumerCfg в ContainerRequest, где в LogConsumerConfig перечислены потребители и опции производства. Тогда производство стартует само в PostStart-хуоке сразу после запуска процесса и до wait-стратегии, а гасится при останове контейнера — вызывать руками ничего не нужно. Таймаут производства по умолчанию пять секунд, границы пять и шестьдесят: он регулирует, как часто горутина переподключается к потоку, а не как долго живёт подписка.

### Тест: сервис написал ожидаемую строку

```go
type lineConsumer struct {
	lines chan string
}

func (c *lineConsumer) Accept(log testcontainers.Log) {
	select {
	case c.lines <- string(log.Content):
	default: // канал полон — лишние строки выбрасываем
	}
}

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

	consumer := &lineConsumer{lines: make(chan string, 32)}

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			ExposedPorts: []string{"8080/tcp"},
			LogConsumerCfg: &testcontainers.LogConsumerConfig{
				Consumers: []testcontainers.LogConsumer{consumer},
			},
			WaitingFor: wait.ForListeningPort("8080/tcp"),
		},
		Started: true,
	})
	if err != nil {
		t.Fatalf("запуск: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(ctx) })

	select {
	case line := <-consumer.lines:
		if !strings.Contains(line, "listening on 8080") {
			t.Fatalf("неожиданная строка: %s", line)
		}
	case <-time.After(10 * time.Second):
		t.Fatal("строка listening on 8080 так и не появилась в логе")
	}
}
```

Потребитель реализует единственный метод Accept: строка уходит в буферизованный канал без блокировки — default-ветка защищает от зависания, если тест не успевает читать. LogConsumerCfg перечисляет подписчиков, и производство стартует сразу после запуска процесса, ещё до wait-стратегии, поэтому ранние строки не теряются. Тест читает канал в select с time.After: строка пришла — проверяем содержимое; дедлайн истёк — падение с понятным сообщением.

> **Живое место: тест на строку лога без снов.** Живое место урока: тест «сервис написал ожидаемую строку в лог» обязан обходиться без снов. Ошибка по невнимательности — time.Sleep перед чтением: сон либо съедает секунды прогона, либо заканчивается раньше строки, и тест флапает. Рабочая связка — потребитель, бросающий строки в буферизованный канал, и select с time.After: тест просыпается в момент появления строки и падает по дедлайну, если сервис молчит. Спорное решение с оговоркой: если поток шумный, из канала может прийти не та строка — сузьте условие сравнением содержимого или соберите несколько строк, прежде чем делать вывод. В паре с этим работает и обратный тест: ожидание отсутствия строки — сервис не должен переподключаться к базе, если конфиг верный.

### Отладка упавшего контейнера через логи

Упавший контейнер — штатная ситуация интеграционного прогона, и логи — главный инструмент разбора. Типовая цепочка причин выглядит так: файл не скопировался — опечатан путь на хосте, и GenericContainer падает ещё до старта с ошибкой копирования; конфиг внутри не подошёл — процесс умирает мгновенно с сообщением в stderr; сервис жив, но wait-стратегия не дождалась порта за отведённое время. Разбор всегда один: GenericContainer вернул ошибку — прочитайте логи, пока контейнер жив; хелпер из примера выше вызывается прямо перед t.Fatalf. После Terminate логи уходят вместе с контейнером, и отладка усложняется — держите правило «диагностика до уборки». Для системной работы с провалами — автопечать логов в общий хелпер, сохранение вывода в артефакты CI — отведён [двадцать второй урок про отладку](/courses/testcontainers/lessons/22-otladka-upavshih/). А если сервис молчит в логах, поднимите уровень логирования через Env или Cmd: декларация из [третьего урока](/courses/testcontainers/lessons/03-zhiznennyj-cikl/) позволяет дописать и то и другое без пересборки образа. Проверка на собственной машине — отдельный навык: сломайте путь HostFilePath намеренно и посмотрите, на каком шаге тест расскажет о поломке раньше, чем сам сервис.

> **Про песочницу: почему код не запустится в браузере.** Честная оговорка: в песочнице сайта урок не запустится — Files, Logs() и LogConsumerCfg требуют живого демона Docker, а браузерная среда исполняет тематические фрагменты без демонов. Рабочая схема локальная: скопируйте код в файл *_test.go рядом с go.mod, положите testdata/service.conf и запустите go test — весь путь от копии файла до строки лога пройдёт на вашей машине. Чтению отсутствие Docker не мешает: каждый вызов разобран построчно. Домашний эксперимент: docker logs -f на живом контейнере покажет тот же поток, что видит потребитель из урока. Второй эксперимент — сравнить вывод Logs() с docker logs руками: строки совпадут, и мультиплексирование перестанет выглядеть магией.

### Проверка: Reader и HostFilePath

**Вопрос:** В ContainerFile заполнены и HostFilePath, и Reader. Что попадёт в контейнер?

- Содержимое файла с HostFilePath: файл на диске главнее
- ✅ Содержимое Reader: при заданном ридере HostFilePath игнорируется
- Оба источника запишутся в один файл по очереди
- Ошибка валидации: указывать оба поля одновременно запрещено

Исходники читают байты из Reader и пишут их CopyToContainer, когда ридер задан; HostFilePath в этом случае игнорируется целиком. Ошибкой считается обратное — не заполнено ни то ни другое: валидация требует либо файл, либо ридер, а ContainerFilePath обязателен всегда.

### Проверка: подписка на живой поток

**Вопрос:** Какой способ подписаться на поток логов современный?

- ctr.FollowOutput(consumer) и ctr.StartLogProducer(ctx) до Terminate
- ✅ Поле LogConsumerCfg в ContainerRequest со списком потребителей
- Чтение файлов из /var/log через Exec внутри контейнера
- docker logs снаружи в отдельном терминале во время теста

FollowOutput, StartLogProducer и StopLogProducer помечены в исходниках Deprecated: их заменило поле LogConsumerCfg — потребители стартуют в PostStart-хуоке и гасятся при останове сами. Ручная пара вызовов продолжает работать, но в новом коде декларация из полей предпочтительнее: она читается как одна строка и не теряет уборку подписки.

### Что увидит сервис на старте

```go
// ContainerRequest:
//   Files: []testcontainers.ContainerFile{
//     {HostFilePath: "testdata/app.conf",
//      ContainerFilePath: "/etc/app/app.conf",
//      FileMode: 0o644},
//   },
//   WaitingFor: wait.ForListeningPort("9090/tcp"),
// Started: true
// Вопрос: что произойдёт при запуске?
```

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

- Файл появится только после старта процесса: сервис его не увидит
- ✅ Файл скопируется после create и до Start: процесс стартует уже с /etc/app/app.conf
- Копирование запустится параллельно со стартом процесса: гонка
- Файл попадёт в контейнер, только если путь /etc/app существует на хосте

Копирование Files исполняет PostCreates-хуок: контейнер создан, процесс ещё не поднят. К моменту ContainerStart файл уже лежит внутри с правами 0o644, и сервис читает его при старте. Существование /etc/app на хосте роли не играет — важен путь внутри контейнера, а гонки здесь нет: хуки идут строго по порядку.

### Соберите декларацию

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

```go
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
	ContainerRequest: testcontainers.ContainerRequest{
		Image:        "myapp/service",
		ExposedPorts: []string{"8080/tcp"},
		Files: []testcontainers.ContainerFile{
			{
				Reader:            bytes.NewBufferString("log_level=debug"),
				___: "/etc/app/runtime.conf",
				FileMode:          0o644,
			},
		},
		___: &testcontainers.LogConsumerConfig{
			Consumers: []testcontainers.LogConsumer{consumer},
		},
	},
	Started: true,
})
```

Подсказка: Путь внутри контейнера задаёт ContainerFilePath, а подписка на поток логов — поле LogConsumerCfg с LogConsumerConfig и списком потребителей.

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

### Соберите конвейер: файл внутрь, строка наружу

Расставьте шаги от декларации файла до проверки строки лога в тесте.

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

1. `ContainerFile с Reader и ContainerFilePath попадает в Files`
2. `GenericContainer создаёт контейнер: процесс ещё не поднят`
3. `PostCreates-хуок копирует файлы внутрь по путям ContainerFilePath`
4. `ContainerStart поднимает процесс: сервис читает свой конфиг`
5. `PostStart-хуок запускает производство логов для LogConsumerCfg`
6. `потребитель бросает строки в канал по мере их выхода`
7. `wait-стратегия дожидается готовности порта`
8. `select в тесте читает канал и проверяет ожидаемую строку`

### Чек-лист логов и файлов

- Files с HostFilePath — статичный файл с диска; с Reader — содержимое из памяти
- Reader главнее HostFilePath: при обоих полях в контейнер попадает содержимое ридера
- FileMode — права в стиле chmod: 0o644 для конфига, 0o755 для исполняемого скрипта
- копирование Files исполняется после create и до Start: сервис стартует уже с файлами
- CopyToContainer и CopyFileToContainer — для работающего контейнера; CopyFileFromContainer — обратно
- Logs(ctx) — срез stdout и stderr: bufio.Scanner построчно, надёжен для упавшего старта
- LogConsumer.Accept(Log) — подписка на живой поток; современный путь — LogConsumerCfg в декларации
- тест на строку лога — канал и select с time.After, не time.Sleep

### Карточки: логи и файлы

- **ContainerFile** — Элемент Files в декларации: HostFilePath или Reader как источник, ContainerFilePath — путь внутри, FileMode — права. Валидация требует источник и путь; ридер главнее файла на диске.
- **CopyToContainer** — Метод живого контейнера: пишет байты из []byte в файл по пути внутри с правами FileMode. Для файлов с диска есть CopyFileToContainer, для каталогов — CopyDirToContainer, обратно — CopyFileFromContainer.
- **Logs(ctx)** — Срез вывода контейнера: stdout и stderr одним io.ReadCloser, мультиплексированный формат Docker распарсен. Читается bufio.Scanner; работает и для контейнера, чей старт провалился.
- **LogConsumer** — Интерфейс подписки: один метод Accept(Log), где Log несёт LogType со значением STDOUT или STDERR и Content — байты строки. Строки приходят по мере выхода из процесса.
- **LogConsumerCfg** — Поле ContainerRequest с LogConsumerConfig: список потребителей и опции производства. Производство стартует после запуска процесса и до wait-стратегии, гасится при останове — вручную вызывать нечего.
- **FileMode** — Права файла внутри контейнера, int64 в стиле chmod: 0o644 для конфига, 0o755 для скрипта. Копирование устанавливает их в момент записи файла.

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

- Файлы внутрь контейнера — Files в ContainerRequest: ContainerFile с HostFilePath или Reader, ContainerFilePath и FileMode; копирование исполняется после create и до Start.
- Reader главнее HostFilePath: при обоих полях в контейнер попадает содержимое ридера; валидация требует источник и ContainerFilePath.
- Для живого контейнера — CopyToContainer с байтами, CopyFileToContainer с диска и каталогами, CopyFileFromContainer обратно.
- Logs(ctx) возвращает срез stdout и stderr одним ридером — базовый инструмент отладки упавшего старта.
- Живой поток — LogConsumer.Accept через LogConsumerCfg в декларации; тест на строку лога строится на канале и select с time.After.

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

Соберите тест с Files: один файл из testdata через HostFilePath и один из bytes.NewBufferString через Reader, FileMode 0o644. Затем добавьте LogConsumerCfg с потребителем-каналом и дождитесь в select с time.After строки, которую пишет ваш сервис. Финал: сломайте путь HostFilePath и убедитесь, что GenericContainer падает до старта процесса с ошибкой копирования, а логи хелпера при этом пусты.

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

### Когда брать Reader, а когда файл с диска?

Содержимое в памяти не оставляет следов в репозитории и собирается под конкретный тест: подставьте уникальное значение, уровень логов или ключ — без временных файлов и гонок за общий testdata. Файл с диска удобен для больших статичных конфигов и для отладки: его видно глазами, его можно переиспользовать в нескольких тестах. Оба пути заканчиваются одинаково — байты пишутся тем же tar-механизмом Docker, разница только в источнике и в управлении моментом. Третий вариант — Files с Reader от os.Open: содержимое с диска, но момент чтения и обработка ошибок остаются на вашей стороне.

### FollowOutput ещё работает — зачем LogConsumerCfg?

Работает: исходники помечают пару FollowOutput со StartLogProducer Deprecated, но не удаляют — старые тесты продолжают собираться. Разница в управлении: ручная пара требует следить, когда завести производство и когда его остановить, а декларативное поле встраивается в жизненный цикл — старт после запуска процесса, останов при гашении контейнера. В новом коде объявляйте потребителей рядом с декларацией: конфиг читается как одна строка, и уборка подписки не теряется.

### Логи ушли вместе с контейнером — как разбирать падение?

Диагностируйте до уборки: Terminate стирает контейнер вместе с логами, поэтому вывод Logs() в t.Logf нужен до выхода из теста — хелпер из урока вызывается прямо перед t.Fatalf. В CI сохраняйте логи в артефакты, если тест падает. Альтернатива для долгих разборов — закомментировать Terminate и рассмотреть контейнер руками: docker logs по идентификатору покажет тот же вывод, пока ресурс не прибран уборщиком.
