---
title: "Установка и первый контейнер"
description: "Установка testcontainers в Go-проект: go get, требования к Docker и первый тест с GenericContainer — Redis, ожидание порта, Host, MappedPort и Terminate."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/02-ustanovka-pervyj-kontejner/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Установка и первый контейнер

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

Testcontainers установка умещается в одну команду, но первый контейнер — это ещё и первый разговор теста с Docker: демон, сокет, образ из реестра, динамический порт на хосте. В этом уроке мы поставим библиотеку в Go-модуль, проверим окружение и соберём первый интеграционный тест: GenericContainer поднимет Redis, wait-стратегия дождётся готовности, а связка Host и MappedPort отдаст адрес для подключения. Функцию GenericContainer Go-код вызывает одинаково для любого образа — то, что вы соберёте здесь, переедет на Postgres и Kafka почти без правок. Для системной базы по контекстам и модулям рядом есть [продвинутый трек Go](/courses/go-advanced/) — он пригодится, когда одиночных тестов станет мало.

Перед установкой зафиксируем, что вообще нужно. Первый ингредиент — работающий Docker: не CLI-клиент сам по себе, а демон, до которого можно достучаться. На Linux это запущенный сервис docker и сокет /var/run/docker.sock, на macOS и Windows — включённый Docker Desktop. Быстрая проверка: docker info в терминале отвечает версией демона — значит, всё на месте; зависла или ругается — интеграционные тесты не поедут, и никакая библиотека это не обойдёт. Второй ингредиент — установленный Go и созданный модуль: контейнерные тесты живут в обычных *_test.go-файлах вашего проекта, отдельного инструмента или плагина к тестовому фреймворку не появится. Третий — доступ к реестру образов: при первом запуске Docker скачает образ с Docker Hub, и если корпоративный прокси режет реестр, скачайте образ заранее вручную командой docker pull. Права на сокет — тоже часть проверки: если пользователь не добавлен в группу docker, демон ответит отказом в доступе, и вступление в группу с перелогином решает вопрос быстрее любых правок кода. Сама библиотека — обычный Go-пакет, который говорит с демоном через официальный Docker API-клиент: она создаёт контейнер, следит за его состоянием и портами ровно так же, как это делает docker run из командной строки, только внутри вашего теста и с уборкой за собой. Никаких агентов внутрь контейнера библиотека не ставит — всё управление идёт снаружи, через API, поэтому от теста до демона ничего не должно перекрывать сокет.

### Testcontainers установка: go get и проверка окружения

Testcontainers установка в существующий модуль — это go get github.com/testcontainers/testcontainers-go из каталога проекта: команда допишет require-строку в go.mod, обновит go.sum, и пакеты станут доступны по импорту. Их два. Корневой github.com/testcontainers/testcontainers-go — это GenericContainer, ContainerRequest и всё управление контейнером. Вложенный github.com/testcontainers/testcontainers-go/wait — семейство wait-стратегий готовности, из которого сегодня понадобится ForListeningPort. После установки потратьте минуту на диагностику цепочки: docker info — жив ли демон; go version — какой тулчейн; docker run --rm hello-world — умеет ли демон тянуть образы из реестра, потому что именно этим займётся тест. Если тесты запускаются из Windows-терминала, а демон живёт в WSL2, или у вас несколько версий Go в системе, первые падения почти всегда сидят в этом слое окружения, а не в коде: тест до библиотеки просто не доходит. Проверять окружение полезно и потом — когда через месяц тест перестал запускаться, первым подозреваемым будет всё тот же демон, а не ваши последние коммиты.

### Три команды: модуль, зависимость, запуск

```go
go mod init example.com/redistest
go get github.com/testcontainers/testcontainers-go
go test ./... -run TestRedisContainer -v
```

go mod init создаёт модуль, если проекта ещё нет; в существующем модуле шаг не нужен. go get дописывает в go.mod зависимость на testcontainers-go и обновляет go.sum — конкретную версию команда выбирает сама, а зафиксировать её можно позже той же командой. Третья строка — как будет запускаться тест из урока: точечно, по имени, с подробным логом -v.

### Первый тест: GenericContainer поднимает Redis

Сердце урока — один тест, который поднимает Redis из образа redis:7-alpine. Зачем именно Redis? Он стартует за доли секунды, весит копейки и идеален для знакомства: если что-то сломается, виновата почти наверняка будет ваша настройка, а не сервис. Функцию GenericContainer Go-код вызывает с двумя аргументами — контекстом и GenericContainerRequest; внутри запроса лежит ContainerRequest с полями конфигурации и флаг Started. Image задаёт образ в формате docker run. ExposedPorts перечисляет порты контейнера в формате «порт/протокол» — для Redis это `6379/tcp`, и протокол обязателен. WaitingFor сообщает библиотеке, что считать готовностью: `wait.ForListeningPort("6379/tcp")` ждёт, пока Redis начнёт принимать соединения на своём порту, — без этого тест получил бы контейнер, в котором сервис ещё даже не инициализировался. Наконец, Started: true просит не просто создать контейнер, а сразу запустить его и дождаться готовности: GenericContainer вернёт управление только после того, как стратегия увидит живой порт. Разница между «создан» и «запущен и готов» спрятана в одном булевом поле, и это главный источник путаницы первого дня.

### Полный тест: от нуля до PONG

```go
import (
    "context"
    "fmt"
    "io"
    "net"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

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

    ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "redis:7-alpine",
            ExposedPorts: []string{"6379/tcp"},
            WaitingFor:   wait.ForListeningPort("6379/tcp"),
        },
        Started: true,
    })
    if err != nil {
        t.Fatalf("контейнер: %v", err)
    }
    t.Cleanup(func() {
        if err := ctr.Terminate(ctx); err != nil {
            t.Logf("остановка: %v", err)
        }
    })

    host, err := ctr.Host(ctx)
    if err != nil {
        t.Fatalf("host: %v", err)
    }
    port, err := ctr.MappedPort(ctx, "6379/tcp")
    if err != nil {
        t.Fatalf("mapped port: %v", err)
    }
    addr := net.JoinHostPort(host, port.Port())

    requirePong(t, addr)
}

func requirePong(t *testing.T, addr string) {
    conn, err := net.Dial("tcp", addr)
    if err != nil {
        t.Fatalf("dial: %v", err)
    }
    defer conn.Close()

    fmt.Fprintf(conn, "*1\r\n$4\r\nPING\r\n")
    buf := make([]byte, 7)
    if _, err := io.ReadFull(conn, buf); err != nil {
        t.Fatalf("read: %v", err)
    }
    if string(buf) != "+PONG\r\n" {
        t.Fatalf("ответ: %q", buf)
    }
}
```

Тест держит один контекст на всё: он же уходит в GenericContainer, в Host, в MappedPort и в Terminate, — и живёт до конца теста. Контейнер создаётся и запускается одним вызовом, и пока wait-стратегия не увидит порт, управление из GenericContainer не вернётся. t.Cleanup регистрирует остановку сразу после успешного старта, поэтому уборка сработает даже при падении последующих шагов. requirePong шлёт сырой RESP-PING и читает семь байт ответа: плюсы настоящей проверки без клиентских библиотек.

### Разбор теста построчно

Пройдём по коду сверху вниз. Импорта два: корневой пакет даёт GenericContainer и ContainerRequest, пакет wait — стратегии готовности; net и fmt досклеивают адрес и PING-проверку. ctx := context.Background() — контекст-родоначальник: один на тест, он же уйдёт в Terminate. Сам запрос устроен вложенно: у GenericContainerRequest есть встроенное поле ContainerRequest типа ContainerRequest — поэтому имя повторяется дважды, и это не опечатка, а композиция структур. Внутри: Image в формате реестра, ExposedPorts списком строк вида «порт/протокол», WaitingFor стратегией. Started: true — просим создать и сразу запустить; после возврата из GenericContainer контейнер гарантированно готов принимать соединения. Если что-то не взлетело, err окажется не nil, и t.Fatalf уронит тест с сообщением — это правильное поведение: без контейнера проверять нечего. t.Cleanup(func() { ctr.Terminate(ctx) }) регистрирует уборку сразу после успешного старта. Дальше тест читает адрес: Host даёт хост, MappedPort — внешний порт, net.JoinHostPort склеивает их аккуратно. Наконец, requirePong шлёт голый RESP-PING и ждёт «+PONG»: проверка, что перед нами настоящий Redis, без единой клиентской библиотеки — соединение, четыре байта, чтение ответа.

> **Живое место: красный тест при верном коде.** Первый запуск на свежей машине почти всегда падает не на логике, а на окружении. Типовая сцена: тест висит несколько секунд и краснеет с сообщением вроде Is the docker daemon running? — а виноват Docker Desktop, который не завёлся после перезагрузки. Код при этом правильный, и разработчик начинает подозревать библиотеку, кэш и версии Go — а надо было docker info. Вторая классика: вчера тест был зелёным, сегодня MappedPort возвращает not found — кто-то убрал из ExposedPorts протокол или сам порт, и мапить стало нечего. Спорное мнение с условием: прочитайте ошибку вслух, прежде чем гуглить. Звучит нелепо, но в сообщениях Docker и testcontainers почти всегда лежит прямая инструкция, что именно чинить.

### Адрес из двух частей: Host и MappedPort

Redis внутри контейнера слушает 6379, но подключаться вы будете не к нему — и вот почему. Docker публикует порт контейнера на случайном свободном порту хоста: сегодня это 55312, завтра — 40001. Так устроена изоляция запусков: два теста параллельно поднимут два Redis, и ни один не наткнётся на занятый порт; CI гоняет джобы друг за другом без плясок с освобождением портов. Поэтому адрес собирают из двух частей. Host возвращает хост, где доступен порт: на локальной машине, когда демон отвечает по unix-сокету, это localhost; если тест сам бежит внутри контейнера, библиотека вернёт IP шлюза сети — разруливает она это сама, поэтому один и тот же тест работает одинаково на ноутбуке и в CI: адрес меняется, код нет. MappedPort(`6379/tcp`) читает из инспекции контейнера внешний порт, соответствующий 6379/tcp, и возвращает его объектом с протоколом; метод .Port() отдаёт номер строкой. Склейка через net.JoinHostPort, а не через двоеточие руками: строка корректно соберётся и для IPv6-хостов. Полученный адрес — единственная легальная точка входа в сервис: хардкод localhost:6379 в интеграционных тестах живёт ровно до первого параллельного запуска.

### Чтение адреса: три строки

```go
host, err := ctr.Host(ctx) // localhost на локальной машине
if err != nil {
    t.Fatalf("host: %v", err)
}
mport, err := ctr.MappedPort(ctx, "6379/tcp") // внешний порт хоста
if err != nil {
    t.Fatalf("mapped port: %v", err)
}
addr := net.JoinHostPort(host, mport.Port()) // например localhost:55312
```

Оба вызова принимают тот же ctx, что и GenericContainer: ошибка вернётся в тест, а не зависнет. Host дешёвый — значение кэшируется после первого чтения. MappedPort ходит в инспекцию контейнера, поэтому вызывать его имеет смысл после запуска: до старта списка маппингов нет, и вместо порта вы получите not found. mport.Port() — номер строкой, готовый для JoinHostPort.

### Остановка: t.Cleanup и Terminate

Terminate — это остановить и удалить: метод гасит контейнер и убирает его вместе с рухлядью, включая собранный образ, если контейнер строился из Dockerfile. В тесте его оборачивают в t.Cleanup — регистрация уборки через test-API, а не defer, и у этого есть причины. Cleanup-функции выполняются после завершения теста в обратном порядке регистрации, срабатывают и при падении теста по Fatal, и внутри хелперов, откуда defer до конца теста не дотянется. Регистрируйте уборку сразу после успешного старта: строкой ниже уже может случиться t.Fatalf, и поздняя регистрация не спасёт. Ошибку остановки логируйте, а не глотайте: если Terminate не удался, вы должны узнать об этом из лога теста, а не обнаружить контейнер-зомби в docker ps через неделю. А если процесс тестов убили совсем — kill -9 или обрыв CI-раннера, — Cleanup не выполнится; на такой случай у библиотеки есть страховка с говорящим именем Ryuk, о ней — отдельный урок. Жизненный цикл контейнера — создание, старт, ожидание, остановка — разобран [в следующем уроке](/courses/testcontainers/lessons/03-zhiznennyj-cikl/): там же станет ясно, что происходит между Started и первым байтом вашего клиента.

> **Про песочницу.** В браузерной песочнице этот урок не выполнить честно: тестам нужен локальный Docker с живым демоном и настоящий go test на вашей машине. Здесь код разбирается построчно, а интерактивы проверяют знание API — и это осознанный формат, потому что песочница не даёт ни сокета Docker, ни реестра образов. План первого локального прогона: создайте модуль, вставьте тест из урока, убедитесь, что docker info отвечает, и запускайте точечно командой go test из третьего блока. Первая загрузка redis:7-alpine займёт секунды, следующие прогоны пойдут из локального кэша.

### Типовые ошибки первого запуска

| Симптом | Причина | Лечение |
| --- | --- | --- |
| Is the docker daemon running? | демон не запущен: Docker Desktop выключен, сервис docker не поднят | запустить демона, проверить docker info |
| ошибка формата порта или порт не мапится | в ExposedPorts написали "6379" без протокола | формат «порт/протокол»: "6379/tcp" |
| контейнеры висят в docker ps после прогона | забыли Terminate — уборка не зарегистрирована | t.Cleanup(func() { ctr.Terminate(ctx) }) сразу после старта |
| тест падает на GenericContainer по таймауту | wait-стратегия не увидела готовый порт | сверить порт и протокол в ForListeningPort с ExposedPorts |
| MappedPort возвращает not found | порт не в ExposedPorts или вызов до запуска контейнера | добавить порт в запрос, вызывать после Started: true |
| ошибка загрузки образа при первом запуске | нет сети до реестра или прокси режет Docker Hub | docker pull redis:7-alpine заранее, руками |

### Чек-лист первого запуска

- docker info отвечает — демон жив и доступен из терминала
- go get github.com/testcontainers/testcontainers-go дописал зависимость в go.mod
- тест вызывает GenericContainer с GenericContainerRequest и Started: true
- ExposedPorts с протоколом: "6379/tcp", не голое число
- WaitingFor: wait.ForListeningPort("6379/tcp") — готовность, а не надежда
- адрес читается из контейнера: Host плюс MappedPort, без хардкода
- t.Cleanup с Terminate зарегистрирован сразу после успешного старта

### Проверка: Started и готовность

**Вопрос:** Started: true выставлен, но wait-стратегия так и не увидела готовый порт за отведённое время. Что произойдёт?

- GenericContainer вернёт контейнер и nil ошибки: готовность — на совести теста
- ✅ GenericContainer вернёт ошибку таймаута ожидания, и тест упадёт на t.Fatalf
- Библиотека молча перезапустит контейнер и продолжит ждать
- Контейнер вернётся в статусе «создан» без запуска

При Started: true библиотека запускает контейнер и ждёт срабатывания wait-стратегии; если готовность не наступила, запуск завершается ошибкой таймаута, и GenericContainer возвращает её вместо контейнера. Тест падает на первом же t.Fatalf — и это честно: сервис не готов, проверять дальше нечего. Ошибка укажет на стратегию ожидания, поэтому порт и протокол сверяются первыми.

### Проверка: уборка после теста

**Вопрос:** В тесте нет t.Cleanup с Terminate — контейнер поднялся, тест прошёл. Зачем всё-таки убираться явно?

- Без Terminate тест не скомпилируется: метод обязан быть вызван
- t.Cleanup быстрее стандартного defer в десять раз
- ✅ Cleanup удаляет контейнер сразу после теста: Docker чист, ресурсы и логи не размазываются по прогонам
- Terminate нужен только в CI: локально Docker чистит себя сам

Явный Terminate через t.Cleanup убирает контейнер сразу после завершения теста — и при успехе, и при падении. Страховка на аварийное завершение процесса у библиотеки есть, но рассчитывать на неё как на норму — плохая привычка: до уборки контейнер ест память, а его логи путаются с вашими. Compile-ошибки от пропущенного Terminate не бывает, а скорость defer ни при чём.

### Что выведет адрес

```go
// контейнер один, тест запущен локально,
// демон доступен по unix-сокету
host, _ := ctr.Host(ctx)
port, _ := ctr.MappedPort(ctx, "6379/tcp")
fmt.Println(net.JoinHostPort(host, port.Port()))
```

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

- localhost:6379
- ✅ localhost со случайным портом, например localhost:55312
- 0.0.0.0:6379
- redis:7-alpine:6379

Host для unix-сокета возвращает localhost, а вот порт — не 6379: MappedPort отдаёт динамический внешний порт, который Docker выбрал при публикации. Именно поэтому подключение идёт через этот адрес, а не через localhost:6379. Такое поведение и делает возможными параллельные тесты: у каждого контейнера свой порт, конфликтов нет.

### Соберите запрос на Redis

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

```go
ctr, err := testcontainers.___(ctx, testcontainers.GenericContainerRequest{
    ContainerRequest: testcontainers.ContainerRequest{
        Image:        "redis:7-alpine",
        ExposedPorts: []string{"___"},
        WaitingFor:   wait.___("6379/tcp"),
    },
    Started: true,
})
```

Подсказка: Функция — GenericContainer, порт всегда пишется с протоколом через слэш, готовность слушающего порта — ForListeningPort.

Правильные ответы в пропусках: `GenericContainer`, `6379/tcp`, `ForListeningPort`

### Соберите скелет первого теста

Расставьте по порядку: контекст, запрос, запуск, уборка, адрес.

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

1. `ctx := context.Background()`
2. `ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{`
3. `  ContainerRequest: testcontainers.ContainerRequest{ Image: "redis:7-alpine", ExposedPorts: []string{"6379/tcp"} },`
4. `  Started: true,`
5. `})`
6. `if err != nil { t.Fatalf("контейнер: %v", err) }`
7. `t.Cleanup(func() { _ = ctr.Terminate(ctx) })`
8. `host, err := ctr.Host(ctx)`
9. `port, err := ctr.MappedPort(ctx, "6379/tcp")`
10. `addr := net.JoinHostPort(host, port.Port())`

### Карточки: первый контейнер

- **GenericContainer** — Вход в библиотеку: создаёт контейнер из GenericContainerRequest, при Started: true — сразу запускает и ждёт готовности. Возвращает Container и ошибку.
- **ContainerRequest** — Конфигурация контейнера: Image, ExposedPorts в формате «порт/протокол», Env, Cmd, WaitingFor. Вкладывается в GenericContainerRequest вместе с флагом Started.
- **wait.ForListeningPort** — Стратегия готовности: ждать, пока порт начнёт принимать соединения. Порт пишется с протоколом — "6379/tcp"; без готовности тест получает полумёртвый сервис.
- **Host и MappedPort** — Две части адреса: Host — хост демона (локально — localhost), MappedPort — динамический внешний порт. Адрес: net.JoinHostPort(host, port.Port()).
- **Terminate** — Остановить и удалить контейнер (и собранный образ, если был). В тестах — через t.Cleanup сразу после успешного старта.
- **Динамический порт** — Docker публикует порт на случайном свободном порту хоста — ради параллельных запусков без конфликтов. Поэтому хардкод localhost:6379 в интеграционных тестах запрещён.

Дальше по курсу: жизненный цикл контейнера и всё, что происходит между созданием и удалением, — следующий урок; затем модули Postgres и Redis, где GenericContainer заменится готовыми конструкторами. Здесь же остаётся фундамент: запрос, ожидание готовности, адрес из Host и MappedPort, уборка через Terminate — этот каркас не меняется до конца курса. Весь маршрут целиком живёт на [странице курса](/courses/testcontainers/), оттуда удобно прыгать к нужной теме без поиска. Домашняя проверка на честность: сломайте тест трижды, как в таблице выше — демон, порт без протокола, убранный Cleanup, — и запишите собственную таблицу первых ошибок. С ней отладка первого дня превращается из мистики в чек-лист, а каждый следующий контейнер — хоть Kafka, хоть Postgres — поднимается по уже знакомому шаблону.

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

- Установка — go get github.com/testcontainers/testcontainers-go: нужен живой Docker-демон, сокет и Go-модуль, проверка — docker info.
- Первый тест: GenericContainer с redis:7-alpine, ExposedPorts "6379/tcp", WaitingFor wait.ForListeningPort("6379/tcp") и Started: true.
- Адрес читают из контейнера: Host плюс MappedPort — порт динамический, поэтому хардкод localhost:6379 ломает параллельные прогоны.
- Убираются через t.Cleanup с Terminate сразу после старта: контейнер остановлен и удалён и при успехе, и при падении теста.

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

Соберите тест из урока локально: поднимите redis:7-alpine, получите адрес через Host и MappedPort и добейтесь PONG по TCP. Затем сломайте три вещи по очереди — остановите демон, уберите /tcp из ExposedPorts, удалите t.Cleanup — и запишите, как меняется ошибка и на какой строке тест падает.

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

### Тесты бегут внутри CI-раннера, который сам живёт в контейнере. Это сломает Host?

Библиотека различает ситуации: когда тесты сами исполняются в контейнере, Host вернёт не localhost, а IP шлюза сети — так контейнер с тестами дотянется до соседнего контейнера с сервисом. Демон при этом всё равно нужен: сокет монтируют внутрь раннера или используют удалённый демон. Что именно видит библиотека, проще всего проверить логом: печать Host одной строкой в начале теста отвечает на вопрос до первого падения. Сам тест при этом не меняется — те же GenericContainer, Host и MappedPort.

### Почему бы не использовать готовый модуль Redis вместо GenericContainer?

Модуль — надстройка над тем же GenericContainer: он собирает ContainerRequest за вас и отдаёт готовую строку подключения, что удобно. Но GenericContainer — базовый механизм, который работает с любым образом из реестра, и понимать его стоит раньше модулей: в шестом уроке курса Redis поднимается именно модулем, а здесь — руками, чтобы видеть, что под капотом.

### Почему порт не 6379, хотя внутри контейнера именно Redis?

Порт внутри контейнера и порт на хосте — разные вещи. Docker публикует порт сервиса на случайном свободном порту хоста, чтобы параллельные тесты не воевали за 6379. Реальный внешний порт возвращает MappedPort на каждый прогон, поэтому клиент подключается по адресу, прочитанному из контейнера, а не по запомненному числу.
