Контейнер в тесте — чёрный ящик, и оба провода к нему проходят мимо бизнес-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
GOfunc 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() же отдаёт всё, что процесс успел написать, — от первого байта до конца чтения, включая самый ранний вывод ещё не готового сервиса. На тяжёлых прогонах помните о цене: весь журнал копируется из демона целиком, поэтому для гигантских логов срез читают точечно или берут подписку.
Отладка упавшего старта через логи
GOfunc 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-стратегии, а гасится при останове контейнера — вызывать руками ничего не нужно. Таймаут производства по умолчанию пять секунд, границы пять и шестьдесят: он регулирует, как часто горутина переподключается к потоку, а не как долго живёт подписка.
Тест: сервис написал ожидаемую строку
GOtype 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 — отведён двадцать второй урок про отладку. А если сервис молчит в логах, поднимите уровень логирования через Env или Cmd: декларация из третьего урока позволяет дописать и то и другое без пересборки образа. Проверка на собственной машине — отдельный навык: сломайте путь 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. Что попадёт в контейнер?
Проверка: подписка на живой поток
Какой способ подписаться на поток логов современный?
Что увидит сервис на старте
Прочитайте код и выберите, что он выведет:
// ContainerRequest:
// Files: []testcontainers.ContainerFile{
// {HostFilePath: "testdata/app.conf",
// ContainerFilePath: "/etc/app/app.conf",
// FileMode: 0o644},
// },
// WaitingFor: wait.ForListeningPort("9090/tcp"),
// 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
Карточки: логи и файлы
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 падает до старта процесса с ошибкой копирования, а логи хелпера при этом пусты.
Личные заметкиПусто
Вопросы и ответы по уроку
Когда брать Reader, а когда файл с диска?
Содержимое в памяти не оставляет следов в репозитории и собирается под конкретный тест: подставьте уникальное значение, уровень логов или ключ — без временных файлов и гонок за общий testdata. Файл с диска удобен для больших статичных конфигов и для отладки: его видно глазами, его можно переиспользовать в нескольких тестах. Оба пути заканчиваются одинаково — байты пишутся тем же tar-механизмом Docker, разница только в источнике и в управлении моментом. Третий вариант — Files с Reader от os.Open: содержимое с диска, но момент чтения и обработка ошибок остаются на вашей стороне.
FollowOutput ещё работает — зачем LogConsumerCfg?
Работает: исходники помечают пару FollowOutput со StartLogProducer Deprecated, но не удаляют — старые тесты продолжают собираться. Разница в управлении: ручная пара требует следить, когда завести производство и когда его остановить, а декларативное поле встраивается в жизненный цикл — старт после запуска процесса, останов при гашении контейнера. В новом коде объявляйте потребителей рядом с декларацией: конфиг читается как одна строка, и уборка подписки не теряется.
Логи ушли вместе с контейнером — как разбирать падение?
Диагностируйте до уборки: Terminate стирает контейнер вместе с логами, поэтому вывод Logs() в t.Logf нужен до выхода из теста — хелпер из урока вызывается прямо перед t.Fatalf. В CI сохраняйте логи в артефакты, если тест падает. Альтернатива для долгих разборов — закомментировать Terminate и рассмотреть контейнер руками: docker logs по идентификатору покажет тот же вывод, пока ресурс не прибран уборщиком.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах