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

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

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

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

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

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

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

Контейнер в тесте — чёрный ящик, и оба провода к нему проходят мимо бизнес-API: файлы доставляются на уровень файловой системы, логи снимаются с вывода процесса. Так устроена универсальность: сервис без сети, база, брокер и подменный сервер общаются с тестом через одни и те же каналы — меняются только образы и порты. Без файлов не обходится ни один тест с конфигом: сервису нужен свой app.conf, сертификат, словарь или скрипт инициализации, и всё это кладётся внутрь декларацией Files или вызовом CopyToContainer в выбранный момент сценария. Логи же отвечают на главный вопрос упавшего прогона: почему сервис не поднялся. Docker хранит вывод процесса, и библиотека даёт к нему два доступа — срез через Logs() и живой поток через LogConsumer. Каркас контейнера из первого знакомства с testcontainers здесь предполагается знакомым: образ, порты, 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-стратегия из урока об ожиданиях проверяет уже настроенный сервис. Ошибка копирования — опечатанный путь на хосте, отсутствие файла — возвращается из 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 + ReaderReader главнее 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: строка пришла — проверяем содержимое; дедлайн истёк — падение с понятным сообщением.

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

Упавший контейнер — штатная ситуация интеграционного прогона, и логи — главный инструмент разбора. Типовая цепочка причин выглядит так: файл не скопировался — опечатан путь на хосте, и GenericContainer падает ещё до старта с ошибкой копирования; конфиг внутри не подошёл — процесс умирает мгновенно с сообщением в stderr; сервис жив, но wait-стратегия не дождалась порта за отведённое время. Разбор всегда один: GenericContainer вернул ошибку — прочитайте логи, пока контейнер жив; хелпер из примера выше вызывается прямо перед t.Fatalf. После Terminate логи уходят вместе с контейнером, и отладка усложняется — держите правило «диагностика до уборки». Для системной работы с провалами — автопечать логов в общий хелпер, сохранение вывода в артефакты CI — отведён двадцать второй урок про отладку. А если сервис молчит в логах, поднимите уровень логирования через Env или Cmd: декларация из третьего урока позволяет дописать и то и другое без пересборки образа. Проверка на собственной машине — отдельный навык: сломайте путь HostFilePath намеренно и посмотрите, на каком шаге тест расскажет о поломке раньше, чем сам сервис.

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

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

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

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

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

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

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

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

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

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, })

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

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

Строки

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

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

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

  • 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 — права. Валидация требует источник и путь; ридер главнее файла на диске.

1 / 6

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

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

Вывод

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

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

  • Файлы внутрь контейнера — 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 падает до старта процесса с ошибкой копирования, а логи хелпера при этом пусты.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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