Содержание урока · 10 разделов
- Runner с Docker: что уже есть на ubuntu-latest
- Переменные окружения: RYUK и TESTCONTAINERS в CI
- Проблема в CI — решение
- Кэш Docker-образов: ускоряем прогон
- Флейки и таймауты: три слоя бюджета
- Сессия и уборщик: кто приберёт за убитой джобой
- Типовые ошибки CI
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Интеграционные тесты с testcontainers на CI не требуют особого пайплайна: им нужен лишь демон Docker и честный бюджет времени. Всё остальное — настройки окружения, кэши, таймауты, уборка — расставляется в workflow и переменных окружения, не трогая код тестов. Этот урок — карта таких настроек: что уже установлено на hosted-раннере, какие env-переменные читает библиотека, где выигрывать минуты на pull'е образов и почему уборщика нельзя глушить в CI. Каждый раздел заканчивается решением конкретной проблемы, а сводная таблица в середине заменяет чат с коллегой «а как у вас в CI?».
Runner с Docker: что уже есть на ubuntu-latest#
Хостед-раннер ubuntu-latest — виртуалка, где Docker уже установлен: демон запущен, CLI на месте, сокет /var/run/docker.sock доступен, — поэтому testcontainers подключается к нему без единой настройки, тем же провайдером, что и на вашей машине. Джоба — чистая машина: каждый прогон начинается с пустого состояния, зомби от прошлых прогонов тут не бывают, зато каждый pull идёт из сети. Self-hosted раннер устроен наоборот: демон один на все джобы, контейнеры и слои копятся, порты разбираются, диск забивается. Там дисциплина уборки из урока про Ryuk перестаёт быть страховкой и становится требованием: уборщика не глушить, прогоны помечать, остатки периодически вычищать по меткам org.testcontainers. Конфигурация библиотеки при этом везде одна: сначала читается файл ~/.testcontainers.properties, затем поверх перекрывается переменными окружения, — так окружение CI настраивается прямо в workflow, без правок кода. Отсюда и весь рецепт связки «testcontainers github actions»: три шага в workflow плюс аккуратный env-блок, а «тесты ci go» ведут себя одинаково у всех в команде.
Отдельно о том, как библиотека находит демон на раннере: порядок детекции фиксирован. Сначала свойство tc.host из ~/.testcontainers.properties, затем переменная DOCKER_HOST, затем значение из контекста Go — так библиотека передаёт хост уборщику, — затем путь по умолчанию /var/run/docker.sock, затем свойство docker.host из файла и, наконец, пути rootless-сокетов. Не нашлось ничего — паника с сообщением, что Docker-хост не обнаружен. На hosted-раннере срабатывает шаг по умолчанию: демон слушает стандартный сокет, и детекция сводится к его чтению. На self-hosted с нестандартной установкой достаточно DOCKER_HOST в env-блоке джобы. Отдельная переменная TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE задаёт путь сокета, который монтируется внутрь служебных контейнеров — уборщика и compose-стека: если демон живёт не там, где привык, укажите её, иначе уборщик запустится, но смотреть будет не в тот сокет. Практический вывод: паника о необнаруженном хосте в CI почти всегда означает, что джоба попала не на тот раннер — например, на образ без Docker вовсе, — а не то, что библиотека сломалась.
Минимальный workflow
GO# .github/workflows/ci.yml
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: go test
run: go test ./... -race -timeout 15mМинимальный каркас: hosted-раннер с Docker из коробки, checkout, setup-go — он же кэширует модули Go между прогонами, — и один шаг go test. Флаг -race ловит гонки, -timeout задаёт общий бюджет пакета и убивает зависший прогон раньше лимита джобы: вы получаете красный тест со стек-трейсом, а не серую джобу без вывода. Никаких services и compose: контейнеры testcontainers поднимет сам.
Переменные окружения: RYUK и TESTCONTAINERS в CI#
Все настройки библиотеки читаются из ~/.testcontainers.properties и перекрываются переменными окружения — в CI это главный канал: env-блок джобы настраивает прогон без правок кода. Базовый набор такой. TESTCONTAINERS_RYUK_DISABLED выключает уборщика; по умолчанию он включён, и выключать его в CI — риск, о котором ниже. RYUK_CONNECTION_TIMEOUT (дефолт — одна минута) и RYUK_RECONNECTION_TIMEOUT (дефолт — десять секунд) управляют связью с уборщиком; устаревшие имена TESTCONTAINERS_RYUK_CONNECTION_TIMEOUT и TESTCONTAINERS_RYUK_RECONNECTION_TIMEOUT ещё читаются, но новые предпочтительнее. TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED запускает контейнер уборщика в привилегированном режиме — понадобится там, где этого требует среда. TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX подставляет префикс к образам из Docker Hub — штатный путь для закрытых сетей с внутренним зеркалом. DOCKER_HOST указывает нестандартный демон, TESTCONTAINERS_SESSION_ID закрепляет идентификатор сессии. Значения — строки: true, false и длительности вида 2m; YAML-булевы значения в env превратятся в строки сами.
Проблема в CI — решение#
| Проблема в CI | Решение |
|---|---|
| первый прогон медленный: образы тянутся из реестра | шаг pre-pull до go test; тяжёлым наборам образов — actions/cache для каталога демона |
| джоба убита по лимиту: t.Cleanup не выполнился | уборщик Ryuk зачищает по меткам сессии; не задавать TESTCONTAINERS_RYUK_DISABLED=true |
| закрытая сеть: Docker Hub недоступен | TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX на внутреннее зеркало |
| сервис не успевает стартовать на медленном раннере | запас в wait-стратегии: WithStartupTimeout вместо sleep |
| параллельные джобы на общем self-hosted раннере плодят уборщиков | TESTCONTAINERS_SESSION_ID: прогоны с одним значением делят одного уборщика |
| модули Go собираются каждый прогон заново | setup-go с кэшем модулей и сборки |
| мусор от прошлых прогонов на self-hosted | периодическая чистка docker по меткам org.testcontainers; уборку не отключать |
go test -timeout не церемонится: зависший прогон процесс убивает, и ни t.Cleanup, ни defer не выполняются. В исходниках прямо сказано, зачем уборщик: он важен именно для тестов, упавших по таймауту, потому что те не выполняют очистку. Распространённый анти-совет — ставить TESTCONTAINERS_RYUK_DISABLED=true «чтобы ускорить CI»: на hosted-раннере мусор исчезнет вместе с виртуалкой, а на self-hosted останется навсегда. Отключайте уборщика только там, где среда гарантированно чистит сама. Пограничный случай: привилегированные контейнеры запрещены политикой — уборщик не стартует, и это повод договариваться с админами, а не молча глушить уборку. Отдельно коварный случай: переменную ставят только в одном из нескольких workflow одного репозитория — часть джоб чистит за собой, часть нет, и на общем раннере это выглядит как загадочный рой контейнеров без владельца.
Кэш Docker-образов: ускоряем прогон#
На hosted-раннере всё тянется из сети заново: образ базы, брокера, уборщика — и медленный первый прогон почти всегда про pull, а не про тесты. Слои кэша Docker — ресурс демона, и жить они начинают с шага прогрева: docker pull каждого нужного образа до go test кладёт их в локальный кэш, и testcontainers при старте находит образ на месте, без похода в реестр. Это самый дешёвый в обслуживании вариант и почти всегда достаточный. Вариант тяжелее — actions/cache для каталога демона /var/lib/docker: он сохраняет и восстанавливает весь каталог разом, но требует прав root и остановки демона на время восстановления — усложнение оправдано, только если образы по-настоящему гигантские. Для образов, собираемых из Dockerfile, отдельная история — кэш слоёв сборки у билдера, чтобы go build внутри образа не стартовал с нуля; механика сборки разобрана в пятнадцатом уроке. Образы сервисов меняются редко, поэтому прогрев окупается на каждом следующем прогоне. Единственная оговорка: pre-pull имеет смысл для образов с фиксированными тегами — прогрев плавающего latest экономит меньше, потому что однажды всё равно вытянет новый слой.
env-блок и прогрев образов
GOjobs:
test:
runs-on: ubuntu-latest
env:
RYUK_CONNECTION_TIMEOUT: 2m
RYUK_RECONNECTION_TIMEOUT: 30s
steps:
- uses: actions/checkout@v4
- name: прогрев кэша образов
run: |
docker pull postgres:alpine
docker pull redis:alpine
- name: go test
run: go test ./... -race -timeout 15menv-блок джобы — CI-настройка библиотеки: таймауты связи с уборщиком взяты с запасом под медленный раннер. Шаг прогрева тянет образы сервисов до тестов: pull ложится в кэш демона, и testcontainers стартует контейнеры без похода в реестр. Значения env — строки; длительности вида 2m библиотека разбирает сама.
Флейки и таймауты: три слоя бюджета#
Флейк в CI почти всегда означает, что какой-то таймаут истёк раньше, чем должен был. Таймаутов три слоя, и они обязаны соотноситься: внутренний — стартовый таймаут wait-стратегии из урока об ожиданиях, средний — go test -timeout, внешний — лимит времени джобы. Правило простое: внутренние истекают раньше внешних, иначе вместо красного теста со стек-трейсом вы получаете убитую джобу без диагностики. Второй источник флейков — теснота: пакеты go test стартуют параллельно на все ядра, и слабый раннер одновременно тянет несколько контейнеров, гонки -race и сборку — память и порты кончаются раньше тестов. Ограничьте параллелизм пакетов флагом -p, а тестовую параллельность внутри пакета настраивайте осознанно. Запас в wait-стратегии решает первую половину проблемы: WithStartupTimeout вместо sleep даёт сервису дорасти до готовности без искусственных задержек в удачном прогоне. Третий источник — кэш: setup-go сохраняет и GOCACHE, где лежат результаты тестов, поэтому интеграционный прогон честнее с -count=1. Повторы упавшего теста лечат симптом: флейк — сигнал пересмотреть таймауты, а не добавить retry.
Проверка: уборка после убитого прогона
go test превысил -timeout и убил процесс: t.Cleanup не выполнился. Джоба на hosted-раннере, TESTCONTAINERS_RYUK_DISABLED не задан. Что станет с контейнерами?
Сессия и уборщик: кто приберёт за убитой джобой#
Как уборщик устроен в CI: вместе с первым контейнером прогона поднимается контейнер-уборщик — он монтирует docker-сокет, слушает управляющий порт (по умолчанию 8080, переназначается переменной RYUK_PORT) и работает с ресурсами по сессионным меткам семейства org.testcontainers. Процесс-владелец умер — уборщик зачищает всё, что тот насоздавал, от контейнеров до сетей и томов. Строки reaper в логах теста — норма, а не ошибка: это уборщик отчитывается о подключении. Ошибка reaper not found — уже сигнал: уборщик не поднялся — образ недоступен в закрытой сети без зеркала либо запрещён привилегированный режим; чинить надо среду, а не глушить уборку. На общем self-hosted раннере у каждой джобы свой уборщик со своей сессией; если параллельные прогоны плодят их слишком охотно, TESTCONTAINERS_SESSION_ID закрепляет идентификатор: прогоны с одним значением делят одного уборщика. Охотиться за проблемами подключения помогает RYUK_VERBOSE: подробный лог уборщика попадает в вывод теста, и причина — медленный сокет, запрет privileged, недоступный образ — видна сразу, а не угадывается по красной джобе.
Соберите путь джобы
Расставьте шаги: от старта джобы до страховки уборщика.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Соберём маршрут целиком: джоба на ubuntu-latest уже с Docker, setup-go кэширует модули и сборку, pre-pull греет образы, go test ./... -race -timeout держит бюджет, env-переменные настраивают уборщика под медленный раннер, а метки и сессия гарантируют уборку даже после убитого процесса. Это тот же жизненный цикл, что и локально, — меняется только среда, поэтому настройки живут в workflow, а не в коде. Дальше маршрут ветвится: поведение уборщика и его отключение — тема урока про Ryuk, wait-стратегии разобраны в двенадцатом уроке, а типовые грабли портов, скорости и флейков собраны в отдельном уроке про грабли. Остальной путь по курсу testcontainers — от модулей баз до итогового стенда. И держите пайплайн скучным: скучный зелёный workflow — признак системы, а не отсутствия жизни. Когда появится желание ускорить прогон радикально, вспомните режим Reuse из шестнадцатого урока: он одинаково работает и на ноутбуке, и в джобе.
Честная оговорка: в песочнице сайта этот урок не запустится — Docker в браузерной среде недоступен, а workflow исполняет только GitHub Actions. Рабочая схема локальная и в CI: тесты из прошлых уроков идут на вашей машине с установленным Docker через go test, а workflow из этого урока заработает в репозитории с ubuntu-latest как есть. Чтению это не мешает: YAML и Go разобраны построчно. Домашний эксперимент: прогоните тест локально с TESTCONTAINERS_RYUK_DISABLED=true и без неё, сравнив docker ps сразу после прогона, — разница покажет, кто и когда убирает.
Проверка: образы из зеркала
Сеть CI закрыта: Docker Hub недоступен, образы лежат во внутреннем зеркале. Какая переменная перенаправит pull'ы библиотеки?
Что останется после убитой джобы
Прочитайте код и выберите, что он выведет:
// Self-hosted runner: демон Docker общий для всех джоб.
// В workflow задано:
// env:
// TESTCONTAINERS_RYUK_DISABLED: "true"
// Джоба превысила лимит времени: процесс go test убит,
// t.Cleanup не успел выполниться.Соберите каркас workflow
Восстановите минимальный workflow: раннер с Docker, ключ шага и гонки.
Типовые ошибки CI#
- TESTCONTAINERS_RYUK_DISABLED=true «для скорости»: на self-hosted мусор остаётся навсегда
- внутренний таймаут больше go test -timeout: красная джоба без диагностики вместо красного теста
- pull без прогрева: каждый прогон заново тянет образы из реестра
- actions/cache для /var/lib/docker без root и остановки демона: кэш пустой, шаг падает
- -race и параллельные пакеты без запаса в таймаутах: раннер захлёбывается
- sleep вместо wait-стратегии: на медленном раннере это гарантированный флейк
- устаревшие имена TESTCONTAINERS_RYUK_CONNECTION_TIMEOUT: ждёте эффекта от переменной, которая больше не читается
- падение без сохранённых логов контейнеров: разбор не с чего начать
Карточки: testcontainers в CI
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- ubuntu-latest уже с Docker: демон, CLI и сокет на месте — testcontainers работает в CI без настройки инфраструктуры.
- Конфиг читается из ~/.testcontainers.properties и перекрывается env: RYUK_DISABLED, таймауты RYUK, HUB_IMAGE_NAME_PREFIX, DOCKER_HOST, SESSION_ID.
- Уборщика в CI не глушить: он прибирает за тестами, убитыми по -timeout, когда t.Cleanup не успел выполниться.
- Прогрев образов pre-pull шагом дешевле кэша каталога демона; таймауты соотносимы: wait-стратегия внутри go test -timeout внутри лимита джобы.
Домашнее задание
Соберите минимальный workflow: checkout, setup-go, шаг прогрева двумя docker pull и шаг go test ./... -race -timeout 15m с env-блоком из RYUK_CONNECTION_TIMEOUT и RYUK_RECONNECTION_TIMEOUT. Локально повторите прогон с TESTCONTAINERS_RYUK_DISABLED=true и без неё, сравнив docker ps после каждого. Затем добавьте TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX на выдуманное зеркало и найдите в логах, как поменялись имена образов при pull'е.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли в workflow блок services с базой, как в старых гайдах?
Нет: сервисы дублируют то, что testcontainers делает лучше. Библиотека сама создаёт контейнер, честно ждёт готовности wait-стратегией и убирает за собой, а services поднимают контейнер до тестов, без гарантий готовности и с ручной чисткой. Одна джоба с go test проще в поддержке: вся инфраструктура описана в коде тестов, а не в YAML рядом, и локально прогон воспроизводится тем же кодом.
Где настраивать библиотеку — в properties-файле или в env?
Файл ~/.testcontainers.properties удобен для личных настроек на машине разработчика, но в CI его некому создать, и он не версионируется. Переменные окружения в env-блоке джобы живут рядом с workflow, видны в диффе и применяются к каждому прогону одинаково — для CI это надёжный канал. Формат значений тот же: true, false и длительности вида 2m; env-значения перекрывают файл, поэтому забытое свойство в файле не переиграет CI.
Почему интеграционные тесты на CI идут дольше, чем локально?
Раннер слабее ноутбука по ядрам и диску, образы тянутся из сети, -race замедляет каждый тест, а параллельные пакеты множат контейнеры. Добавьте холодные кэши: первый прогон всегда самый медленный. Планируйте бюджет от CI, а не от ноутбука: таймауты с запасом, параллелизм пакетов под нагрузку раннера, прогрев образов и честные wait-стратегии вместо sleep.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах