---
title: "CI: тесты в GitHub Actions"
description: "Testcontainers в CI: минимальный workflow GitHub Actions, env RYUK и TESTCONTAINERS, кэш Docker-образов, таймауты против флейков, уборка за убитой джобой."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/20-ci/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# CI: тесты в GitHub Actions

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

Локально тесты зелёные, а на CI красные или зависают: знакомая развилка, у которой есть системное решение. Этот урок собирает минимальный workflow для GitHub Actions: джоба на раннере с готовым Docker, кэш модулей и образов, переменные окружения RYUK и TESTCONTAINERS, дисциплина таймаутов против флейков. Сценарий «testcontainers github actions» здесь закрыт целиком: от checkout до уборки за убитой джобой, а связка «тесты ci go» разобрана на уровне конкретных настроек, а не общих слов. Строить инфраструктуру не придётся: testcontainers приносит в CI те же гарантии, что и на ноутбуке, — реальный сервис поднимается, отвечает и убирается. Меняется только окружение: чистая машина, ограниченный бюджет времени, медленная сеть и демон, который вы разделяете с остальными прогонами. К концу урока у вас будет workflow, который проходит на пустом раннере, держит бюджет и не оставляет за собой мусора.

Интеграционные тесты с 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](/courses/testcontainers/lessons/18-ryuk-i-resursy/) перестаёт быть страховкой и становится требованием: уборщика не глушить, прогоны помечать, остатки периодически вычищать по меткам 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-блок и прогрев образов

```go
jobs:
  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 15m
```

env-блок джобы — CI-настройка библиотеки: таймауты связи с уборщиком взяты с запасом под медленный раннер. Шаг прогрева тянет образы сервисов до тестов: pull ложится в кэш демона, и testcontainers стартует контейнеры без похода в реестр. Значения env — строки; длительности вида 2m библиотека разбирает сама.

### Флейки и таймауты: три слоя бюджета

Флейк в CI почти всегда означает, что какой-то таймаут истёк раньше, чем должен был. Таймаутов три слоя, и они обязаны соотноситься: внутренний — стартовый таймаут wait-стратегии из [урока об ожиданиях](/courses/testcontainers/lessons/12-ozhidaniya-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 не задан. Что станет с контейнерами?

- Останутся навсегда: уборка была только в t.Cleanup, других механизмов нет
- ✅ Уборщик заметит смерть процесса-владельца и зачистит ресурсы по меткам сессии
- go test перед смертью выполнит все отложенные t.Cleanup
- Docker остановит контейнеры, как только истечёт таймаут go test

Уборщик следит за процессом-владельцем: смерть процесса — его прямой сценарий. Ресурсы прогона находятся по сессионным меткам и удаляются, включая сети и тома. Отключать уборщика в CI нельзя именно поэтому: убийство по таймауту — штатный конец долгого прогона, и без уборщика мусор остаётся за пределами t.Cleanup.

### Сессия и уборщик: кто приберёт за убитой джобой

Как уборщик устроен в CI: вместе с первым контейнером прогона поднимается контейнер-уборщик — он монтирует docker-сокет, слушает управляющий порт (по умолчанию 8080, переназначается переменной RYUK_PORT) и работает с ресурсами по сессионным меткам семейства org.testcontainers. Процесс-владелец умер — уборщик зачищает всё, что тот насоздавал, от контейнеров до сетей и томов. Строки reaper в логах теста — норма, а не ошибка: это уборщик отчитывается о подключении. Ошибка reaper not found — уже сигнал: уборщик не поднялся — образ недоступен в закрытой сети без зеркала либо запрещён привилегированный режим; чинить надо среду, а не глушить уборку. На общем self-hosted раннере у каждой джобы свой уборщик со своей сессией; если параллельные прогоны плодят их слишком охотно, TESTCONTAINERS_SESSION_ID закрепляет идентификатор: прогоны с одним значением делят одного уборщика. Охотиться за проблемами подключения помогает RYUK_VERBOSE: подробный лог уборщика попадает в вывод теста, и причина — медленный сокет, запрет privileged, недоступный образ — видна сразу, а не угадывается по красной джобе.

### Соберите путь джобы

Расставьте шаги: от старта джобы до страховки уборщика.

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

1. `джоба на ubuntu-latest: Docker, CLI и сокет уже на месте`
2. `checkout: код репозитория попадает на раннер`
3. `setup-go: тулчейн и кэш модулей Go`
4. `pre-pull: образы сервисов ложатся в кэш демона`
5. `go test ./... -race -timeout: интеграционные тесты`
6. `testcontainers поднимает контейнеры и ждёт wait-стратегии`
7. `t.Cleanup с Terminate убирает за каждым тестом`
8. `уборщик Ryuk зачищает остатки, если процесс убит`

Соберём маршрут целиком: джоба на ubuntu-latest уже с Docker, setup-go кэширует модули и сборку, pre-pull греет образы, go test ./... -race -timeout держит бюджет, env-переменные настраивают уборщика под медленный раннер, а метки и сессия гарантируют уборку даже после убитого процесса. Это тот же жизненный цикл, что и локально, — меняется только среда, поэтому настройки живут в workflow, а не в коде. Дальше маршрут ветвится: поведение уборщика и его отключение — тема урока про Ryuk, wait-стратегии разобраны в двенадцатом уроке, а типовые грабли портов, скорости и флейков собраны в [отдельном уроке про грабли](/courses/testcontainers/lessons/23-grabli/). Остальной путь по [курсу testcontainers](/courses/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'ы библиотеки?

- DOCKER_HOST — указать адрес зеркала вместо демона
- RYUK_PORT — порт зеркала, на котором лежат образы
- ✅ TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX — префикс зеркала, который библиотека подставит к образам из Hub
- TESTCONTAINERS_SESSION_ID — идентификатор сессии зеркала

HubImageNamePrefix — штатный механизм для сред с ограниченным интернетом: библиотека дописывает префикс к именам образов из Hub перед pull'ом. DOCKER_HOST адресует демона, а не реестр; RYUK_PORT — управляющий порт уборщика; SESSION_ID к образам отношения не имеет.

### Что останется после убитой джобы

```go
// Self-hosted runner: демон Docker общий для всех джоб.
// В workflow задано:
//   env:
//     TESTCONTAINERS_RYUK_DISABLED: "true"
// Джоба превысила лимит времени: процесс go test убит,
// t.Cleanup не успел выполниться.
```

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

- Ryuk всё равно зачистит: переменная читается только в локальной конфигурации
- Docker удалит контейнеры автоматически при завершении джобы
- ✅ Контейнеры останутся в общем демоне, пока их не уберут руками или внешняя чистка
- Следующая джоба подхватит эти контейнеры по меткам и переиспользует

Уборщик выключен, cleanup не выполнен — убирать некому: контейнеры и сети останутся в общем демоне. На hosted-раннере их бы стёрла виртуалка, но self-hosted память длинная: мусор переживёт любую джобу. TESTCONTAINERS_RYUK_DISABLED=true оправдан только там, где чистка гарантированно реализована на уровне среды.

### Соберите каркас workflow

Восстановите минимальный workflow: раннер с Docker, ключ шага и гонки.

```go
# .github/workflows/ci.yml
name: tests
on: push
jobs:
  test:
    runs-on: ___
    steps:
      - uses: actions/checkout@v4
      - name: go test
        ___: go test ./... -___ -timeout 15m
```

Подсказка: Хостед-раннер с предустановленным Docker — ubuntu-latest; команда шага задаётся ключом run; гонки ловит флаг -race.

Правильные ответы в пропусках: `ubuntu-latest`, `run`, `race`

### Типовые ошибки 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

- **ubuntu-latest** — Хостед-раннер GitHub Actions с предустановленным Docker: демон запущен, CLI и сокет на месте. Джоба стартует с чистой машиной, поэтому уборка прошлогодних зомби не нужна, а pull всегда из сети.
- **TESTCONTAINERS_RYUK_DISABLED** — Env-переменная, выключающая уборщика (по умолчанию включён). В CI оставляйте выключенной само отключение: именно уборщик прибирает за тестами, убитыми по -timeout, когда t.Cleanup не выполнился.
- **TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX** — Префикс-зеркало для образов из Docker Hub: в закрытых сетях pull'ы идут через внутренний реестр. Перекрывается env поверх ~/.testcontainers.properties.
- **RYUK_CONNECTION_TIMEOUT** — Таймаут подключения к уборщику (дефолт — 1m); переподключение — RYUK_RECONNECTION_TIMEOUT (дефолт — 10s). Старые имена TESTCONTAINERS_RYUK_CONNECTION_TIMEOUT и TESTCONTAINERS_RYUK_RECONNECTION_TIMEOUT устарели.
- **pre-pull** — Отдельный шаг docker pull до go test: образы ложатся в кэш демона, и testcontainers стартует контейнеры без похода в реестр. Дешевле кэша каталога /var/lib/docker в обслуживании.
- **TESTCONTAINERS_SESSION_ID** — Фиксированный идентификатор сессии: прогоны с одним значением не плодят отдельных уборщиков. Актуально для self-hosted раннеров с параллельными джобами на общем демоне.

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

- 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.
