---
title: "Сети: несколько контейнеров вместе"
description: "Сети Docker в тестах testcontainers: network.New, Networks и NetworkAliases, связь контейнеров по алиасам без MappedPort, изоляция тестов своей сетью и уборка."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/14-seti/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Сети: несколько контейнеров вместе

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

Один контейнер в тесте — разминка, настоящий интеграционный стенд — это сервис, база и порой брокер, которым нужно найти друг друга. Библиотека закрывает сценарий «testcontainers network» целиком: собственная сеть на тест, подключение контейнеров парой полей, общение по алиасам без публикации портов и уборка сети в конце. Связка «docker network go» разбирается здесь на двух рабочих примерах: nginx и пробный клиент, затем сервис и база. Главный водораздел урока — доступ снаружи и изнутри: тест с хоста берёт Host и MappedPort, а контейнеры внутри сети ходят друг к другу по именам. Своя сеть на каждый тест даёт изоляцию: параллельные прогоны с одинаковыми алиасами не пересекаются. Под конец — таблица моделей доступа, список типовых ошибок и карточки для повторения.

Первые уроки жили на маппнутом порту: тест брал Host и MappedPort и стучался к контейнеру снаружи. Как только в стенде появляется второй участник — база под сервисом, брокер под двумя сервисами, — дублировать порты наружу становится расточительно: каждая публикация — лишний мост через границу сети, потенциальное пересечение с соседним прогоном и лишняя строка в декларации. Docker решает задачу на своём уровне: пользовательские сети соединяют контейнеры напрямую, а встроенный DNS резолвит имена-алиасы во внутренние адреса. Testcontainers даёт этому типизированный путь: сеть создаёт network.New, подключают контейнер поля Networks и NetworkAliases или опция network.WithNetwork, а уборка ложится на Remove. В этом уроке — обе модели доступа, каркас сети, два рабочих примера и таблица, которая подскажет инструмент под каждый маршрут трафика. Важно, что модели не конкурируют: MappedPort остаётся главным инструментом урока про контейнер-одиночку, сети лишь добавляют второй маршрут — между контейнерами.

### Модель доступа: хост снаружи, сеть внутри

У теста и у контейнеров разные позиции в сети, и инструменты у них разные. Тест — процесс на хосте: он видит контейнер только через публикацию портов, поэтому его маршрут — ExposedPorts в декларации, затем Host и MappedPort после старта. Контейнеры между собой ходят иначе: внутри пользовательской сети Docker резолвит алиасы во внутренние адреса, и трафик идёт напрямую, минуя хост. Сценарий «testcontainers network» и построен на этом разделении: то, что раньше выглядело как «публикуй все порты подряд», распадается на две честные модели — наружу публикуется то, к чему прикасается тест, внутрь сети уходит всё остальное. Выигрыш мгновенный: база без публикации недостижима с хоста и от соседних прогонов, но прекрасно видна сервису по алиасу; у сервиса при этом один опубликованный порт вместо россыпи. Заодно исчезает целый класс флейков: прогон, публикующий всё подряд, борется за порты хоста, а сетевые адреса Docker раздаёт без ставок и конфликтов.

### Каркас: сеть и два контейнера

```go
func TestContainersTalkByAlias(t *testing.T) {
	ctx := context.Background()

	net, err := network.New(ctx)
	if err != nil {
		t.Fatalf("создание сети: %v", err)
	}
	t.Cleanup(func() { _ = net.Remove(ctx) })

	web, err := testcontainers.Run(ctx, "nginx:alpine",
		testcontainers.WithExposedPorts("80/tcp"),
		network.WithNetwork([]string{"web"}, net),
	)
	testcontainers.CleanupContainer(t, web)
	if err != nil {
		t.Fatalf("запуск nginx: %v", err)
	}

	probe, err := testcontainers.Run(ctx, "curlimages/curl:latest",
		testcontainers.WithEntrypoint("sleep", "infinity"),
		network.WithNetwork([]string{"probe"}, net),
	)
	testcontainers.CleanupContainer(t, probe)
	if err != nil {
		t.Fatalf("запуск пробы: %v", err)
	}

	// curl http://web/ резолвит алиас web внутри сети
	code, _, err := probe.Exec(ctx, []string{"curl", "-sf", "http://web/"})
	if err != nil {
		t.Fatalf("exec curl: %v", err)
	}
	if code != 0 {
		t.Fatalf("запрос по алиасу вернул %d", code)
	}
}
```

network.New создаёт сеть со случайным именем, и Remove регистрируется в t.Cleanup сразу за созданием. Оба контейнера подключены опцией network.WithNetwork: nginx — под алиасом web, пробный контейнер curl — под probe. Пробе публикация портов не нужна: она ничего не отдаёт наружу, а только делает запросы. Exec выполняет curl http://web/ внутри сети — имя web резолвит встроенный DNS Docker в адрес nginx.

### network.New: сеть со случайным именем

Сеть делает одна функция — network.New из пакета network: принимает контекст и вариативные опции, возвращает *DockerNetwork и ошибку. По умолчанию создаётся мост с драйвером bridge и случайным именем: библиотека генерирует его сама, вручную имя задать нельзя — читайте поле Name из ответа. Рядом лежат поля ID и Driver: идентификатор нужен Docker-командам и уборке, драйвер — диагностике, когда тест ведёт себя по-разному на разных хостах. Метки уборщика проставляются автоматически: сеть несёт те же служебные метки сессии, что и контейнеры, поэтому супервизор зачистит её даже при смерти процесса теста. Опции меняют поведение точечно: WithDriver задаёт драйвер, WithInternal делает сеть полностью внутренней, WithLabels добавляет свои метки поверх служебных, WithIPAM настраивает адресацию, WithEnableIPv6 включает шестую версию протокола. В исходниках рядом живёт и старый путь — метод провайдера CreateNetwork с декларацией NetworkRequest, помеченный как deprecated в пользу network.New: новый вызов короче, а имена сетей придумывать не нужно.

### Кто с кем говорит: модель доступа — инструмент

| Модель доступа | Инструмент | Что объявить |
| --- | --- | --- |
| тест или хост → контейнер | Host и MappedPort | ExposedPorts у контейнера |
| контейнер → контейнер в своей сети | алиас как имя хоста | Networks и NetworkAliases, порты не нужны |
| контейнер → несколько сетей | список Networks или WithNetwork на каждую | свой алиас каждой сети |
| контейнер → хост | HostAccessPorts и host.testcontainers.internal | порты хоста в HostAccessPorts |
| сеть без выхода наружу | network.WithInternal | Internal: true у сети |
| адрес контейнера для отладки | ContainerIP или ContainerIPs | ничего, читается после старта |

### Подключение: Networks и NetworkAliases

Контейнер подключается к сети парой полей декларации: Networks — список имён сетей, NetworkAliases — карта «сеть → список алиасов». Оба поля доступны и в ContainerRequest внутри GenericContainer, и через опции: network.WithNetwork(aliases, net) дописывает сеть и выставляет алиасы одним вызовом, WithNewNetwork создаёт сеть на месте и сразу подключает контейнер, WithBridgeNetwork присоединяет дефолтный мост без алиасов. Нюанс, который ловит почти каждый: алиасы живут только в пользовательских сетях. Попросите алиас в дефолтной bridge — опция вернёт ошибку ещё до обращения к Docker: сетевые алиасы поддерживаются только там, где сеть создана явно. Контейнер может стоять в нескольких сетях сразу: первая подключается при создании, остальные Docker прикрепляет по одной, каждая со своим списком алиасов; сколько сетей — столько и адресов, их отдают ContainerIP и ContainerIPs. Опции читаются линейно, как список аргументов docker run, а карта NetworkAliases требует помнить имя сети — на длинных декларациях опция обычно нагляднее. Поля и опции ведут к одному результату — выбирайте по стилю стенда из [урока про GenericContainer](/courses/testcontainers/lessons/11-generic-container/).

> **Живое место: базе не нужен опубликованный порт.** Рефлекс из docker run: запустил базу — опубликуй 5432. В сетевом стенде это лишняя работа: сервис ходит в базу по алиасу внутри сети, и публикация порта в этом трафике не участвует вовсе. Проверяйте себя по декларации: ExposedPorts у базы появляется только тогда, когда с базой работает сам тест с хоста — сеет данные или сверяет состояние. Каждый лишний опубликованный порт — потенциальное пересечение с соседним прогоном и лишняя точка входа для чужого трафика. Меньше мостов наружу — стабильнее параллельная сборка. Таблица выше — шпаргалка: найдите свой маршрут трафика, и станет ясно, чей порт где публиковать.

### Сервис и база: адрес по алиасу

Классический стенд: сервис и его база в одном тесте. Базе — алиас db и ни одного опубликованного порта: с хоста она недостижима, и это фича, а не недосмотр. Сервис получает адрес базы через окружение: DB_HOST со значением db — тем самым алиасом, который Docker резолвит внутри сети. Приложению не важно, что на другом конце контейнер: конфигурация выглядит как в развёрнутой среде, где сервис тоже ходит в базу по имени. Тест при этом общается только с сервисом: один ExposedPorts, один MappedPort, запрос к /health. Готовность у стенда двухуровневая: база дожидается своей стратегии до возврата GenericContainer, сервис — своей, и лишь затем тест стучится в /health. Если тесту нужен прямой доступ к базе — сеять данные или сверять состояние SQL-запросами, пригодится и [курс про pgx](/courses/pgx/), — опубликуйте порт базе осознанно: каждый опубликованный порт — потенциальное пересечение в параллельном прогоне. Фиксированные адреса и общая сеть на весь пакет не нужны: сеть у каждого теста своя, имена внутри — свои.

### Сервис и база в GenericContainer

```go
func TestServiceAndDatabase(t *testing.T) {
	ctx := context.Background()

	net, err := network.New(ctx)
	if err != nil {
		t.Fatalf("создание сети: %v", err)
	}
	t.Cleanup(func() { _ = net.Remove(ctx) })

	db, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image: "postgres:16-alpine",
			Env:   map[string]string{"POSTGRES_PASSWORD": "secret"},
			Networks: []string{net.Name},
			NetworkAliases: map[string][]string{
				net.Name: {"db"}, // сервис найдёт базу по имени db
			},
			// ExposedPorts нет: снаружи базу не публикуем
		},
		Started: true,
	})
	testcontainers.CleanupContainer(t, db)
	if err != nil {
		t.Fatalf("база: %v", err)
	}

	app, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image: "myapp/service:it",
			Env: map[string]string{
				"DB_HOST": "db", // алиас базы внутри сети
				"DB_PORT": "5432",
			},
			Networks: []string{net.Name},
			NetworkAliases: map[string][]string{
				net.Name: {"app"},
			},
			ExposedPorts: []string{"8080/tcp"},
			WaitingFor:   wait.ForListeningPort("8080/tcp"),
		},
		Started: true,
	})
	testcontainers.CleanupContainer(t, app)
	if err != nil {
		t.Fatalf("сервис: %v", err)
	}

	// тест снаружи: Host и MappedPort нужны только сервису
	host, err := app.Host(ctx)
	if err != nil {
		t.Fatalf("хост сервиса: %v", err)
	}
	port, err := app.MappedPort(ctx, "8080/tcp")
	if err != nil {
		t.Fatalf("порт сервиса: %v", err)
	}

	resp, err := http.Get("http://" + host + ":" + port.Port() + "/health")
	if err != nil {
		t.Fatalf("запрос к сервису: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("health: %d", resp.StatusCode)
	}
}
```

Здесь поля декларации вместо опций: Networks перечисляет сети, NetworkAliases привязывает алиасы к конкретной сети по её имени. База объявлена без ExposedPorts — снаружи её нет; сервис носит алиас app и ждёт 8080/tcp через wait-стратегию. Тесту доступны только Host и MappedPort сервиса: port.Port() отдаёт строку для адреса. Стенд воспроизводит боевую топологию: база невидима, у сервиса одна дверь.

Путь «docker network go» укладывается в пять шагов. Первый: network.New — сеть создана, имя случайно, метки уборщика на месте. Второй: контейнеры подключаются через Networks и NetworkAliases или опцию network.WithNetwork — по ходу декларации, рядом с Env и ExposedPorts. Третий: внутри сети сервисы находят друг друга по алиасам, а готовность каждого проверяет своя wait-стратегия — их выбор разбирает [урок про ожидания](/courses/testcontainers/lessons/12-ozhidaniya-wait/). Четвёртый: тест забирает Host и MappedPort того контейнера, к которому должен прикоснуться снаружи, и делает свои запросы. Пятый: Remove в t.Cleanup снимает сеть немедленно. Шаги не зависят от образов: те же пять ходов работают и для брокеров, и для кэшей — меняются лишь имена алиасов. В общей картине [курса testcontainers](/courses/testcontainers/) сети стоят рядом с модулями баз и брокеров — тем же декларативным языком, без ручных docker-команд.

### Соберите сетевой стенд

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

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

1. `network.New создаёт сеть: драйвер bridge, случайное имя, метки уборщика`
2. `Networks и NetworkAliases подключают контейнер к сети с алиасом`
3. `первая сеть подключается при создании, остальные — по одной следом`
4. `алиас резолвится внутри сети: сервис ходит в базу по имени db`
5. `тест снаружи забирает Host и MappedPort сервиса`
6. `ContainerIP и ContainerIPs показывают адреса контейнера в сетях`
7. `net.Remove в t.Cleanup убирает сеть сразу после теста`
8. `уборщик зачистит сеть по меткам, даже если процесс теста умер`

### Проверка: алиас в дефолтной сети

**Вопрос:** Тест подключает контейнер к дефолтной сети bridge и задаёт алиас через network.WithNetworkName. Что произойдёт?

- Алиас сохранится: Docker принимает алиасы в любой сети
- ✅ Опция вернёт ошибку: сетевые алиасы поддерживаются только в пользовательских сетях
- Контейнер не запустится: bridge не принимает новые контейнеры
- Алиас будет работать, но только для соседних контейнеров на том же хосте

WithNetworkName проверяет имя сети и на bridge сразу возвращает ошибку: сетевые алиасы живут только в пользовательских сетях. Встроенный DNS резолвит имена лишь там, где сеть создана явно, — поэтому под стенд со связью по именам заводят собственную сеть через network.New, а к bridge подключаются без алиасов.

### Проверка: порты базы

**Вопрос:** Сервисный контейнер ходит в базу по алиасу db внутри общей сети. Что нужно базе из публикации портов?

- 5432/tcp обязателен: без публикации сервис не достучится до базы
- ✅ Порт нужен, только если тест ходит в базу с хоста; для связи внутри сети публикация не требуется
- Нужны оба поля: и ExposedPorts, и фиксированный порт хоста
- Ничего не нужно: база сама опубликует порт при старте

Связь между контейнерами одной сети идёт по внутренним адресам — публикация портов на хост в этом трафике не участвует. ExposedPorts и MappedPort нужны ровно для доступа снаружи: тесту на хосте, ручной проверке, сеянию данных. Меньше опубликованных портов — меньше шансов пересечься с соседним прогоном.

### Что будет с параллельными сетями

```go
// Тест A и тест B помечены t.Parallel().
// В каждом:
//     net, _ := network.New(ctx)
//     ... контейнер postgres подключён к своей сети
//     с алиасом «db» через network.WithNetwork
```

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

- Второй тест упадёт: алиас db уже занят первым тестом
- ✅ Оба стенда поднимутся: сети изолированы, имена случайны, алиасы живут внутри своей сети
- Тесты поделят одну сеть: New вернёт существующую сеть по служебным меткам
- Docker объединит обе сети: алиас db станет общим для двух контейнеров

Алиасы ограничены сетью: встроенный DNS резолвит их только для участников этой сети. network.New каждый раз создаёт новую сеть со случайным именем — никакого переиспользования и конфликтов имён нет. Именно поэтому своя сеть на тест — дешёвый способ изоляции параллельных прогонов.

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

Восстановите три вызова: создание сети, уборка и подключение контейнера с алиасом.

```go
net, err := network.___(ctx)
if err != nil {
	t.Fatalf("создание сети: %v", err)
}
t.Cleanup(func() { _ = net.___(ctx) })

web, err := testcontainers.Run(ctx, "nginx:alpine",
	testcontainers.WithExposedPorts("80/tcp"),
	network.___([]string{"web"}, net),
)
```

Подсказка: Сеть создаёт network.New, уборка — net.Remove в t.Cleanup, а сеть с алиасами подключает опция network.WithNetwork.

Правильные ответы в пропусках: `New`, `Remove`, `WithNetwork`

Отдельные случаи стоят одного абзаца. Фиксированная подсеть через WithIPAM полезна, когда тест проверяет адресную логику, но в параллельном прогоне две одинаковые подсети столкнутся — создание сети упадёт; держите адресацию на откуп Docker, если тест не про адреса. Контейнер → хост — отдельная модель: декларация HostAccessPorts публикует порты хоста внутрь, и из контейнера они видны на имени host.testcontainers.internal — пригодится, когда сервис должен достучаться до заглушки, поднятой самим тестом на хосте. Полностью внутренняя сеть WithInternal отсекает и выход в интернет: участникам доступен только трафик внутри сети. Для отладки многоконтейнерных стендов ContainerIPs честно покажет по адресу на каждую сеть. И помните: алиас не глобален — это имя внутри конкретной сети, в другой сети тем же именем зовут другой контейнер. Отсюда привычка: алиасы короткие и говорящие — db, cache, broker, — а уникальность им нужна только внутри своей сети, где они и живут.

> **Про песочницу: нужен локальный Docker.** Честная оговорка: в песочнице сайта сетевой стенд не поднимется — network.New и контейнеры требуют живого демона Docker, а браузерная среда исполняет код без демонов. Рабочая схема локальная: положите примеры в файл *_test.go рядом с go.mod и запустите go test — сеть, алиасы и проба по имени отработают на вашей машине. Код разобран построчно, так что чтение без Docker тоже приносит пользу. Домашний эксперимент: docker network ls до и после теста — сеть обязана исчезнуть.

### Уборка: Remove, CleanupNetwork и изоляция

Сеть — такой же ресурс прогона, как контейнер, и уборка у неё двухслойная. Первый слой ваш: net.Remove(ctx) в t.Cleanup или готовая обёртка testcontainers.CleanupNetwork(t, net), которая делает ту же регистрацию за вас; внутри Remove сигналит уборщику и просит Docker удалить сеть по идентификатору. Второй слой страховочный: метки сессии, проставленные network.New, позволяют супервизору зачистить сеть, если процесс теста умер. Убирать сразу — не педантизм: сети расходуют адресное пространство Docker, и скопление неубранных сетей к концу пакета бьёт по следующим тестам. На CI это заметнее всего: длинный пакет, где сети заводятся на каждый тест без уборки, упирается в лимиты адресации раньше, чем в лимиты памяти. Взамен вы получаете главную награду урока — изоляцию: своя сеть на тест означает, что два параллельных прогона с одинаковыми алиасами db и web не видят друг друга в упор, и никакой тест не достучится до чужой базы. Изоляция через сети дополняет изоляцию через случайные порты — вместе они делают параллельность скучной в хорошем смысле.

### Типовые ошибки сетей в тестах

- алиас на дефолтной bridge: не работает — заводите свою сеть через network.New
- поиск алиаса из теста с хоста: алиас резолвится внутри сети, снаружи нужны Host и MappedPort
- ExposedPorts базе, к которой ходит только сервис: публикация портов нужна лишь хосту
- фиксированная подсеть через WithIPAM в параллельном прогоне: пересечение адресов роняет создание сети
- забытая net.Remove: сеть висит до уборки, а адресное пространство Docker не резиновое
- один алиас на все сети: алиасы привязаны к сети, у каждой сети свой список
- контейнер в нескольких сетях с одним сетевым именем: сверяйтесь с ContainerIPs
- общая сеть на весь пакет: сети связывают тесты, своя сеть на тест дешевле и предсказуемее

### Карточки: сети в тестах

- **DockerNetwork** — Структура, которую возвращает network.New: поля ID, Driver и Name. Имя генерируется случайно и не задаётся вручную; Remove удаляет сеть — обычно из t.Cleanup или через CleanupNetwork.
- **NetworkAliases** — Карта «сеть → список алиасов» в ContainerRequest. Алиас — имя хоста внутри конкретной сети; работает только в пользовательских сетях, не в дефолтной bridge.
- **network.WithNetwork** — Опция подключения: дописывает сеть в Networks и выставляет алиасы. Вариант WithNewNetwork создаёт сеть на месте, WithBridgeNetwork подключает bridge без алиасов.
- **MappedPort против алиаса** — MappedPort — доступ с хоста к контейнеру: тест читает внешний порт по ExposedPorts. Алиас — доступ контейнера к контейнеру внутри сети; публикация портов не нужна.
- **WithInternal** — Опция network.New для полностью внутренней сети: участники общаются между собой, наружу сеть не выходит. Пригодится, когда стенду не нужен внешний мир.
- **CleanupNetwork** — Хелпер testcontainers: регистрирует Remove сети в t.Cleanup — уборка сработает при провале и панике, как CleanupContainer для контейнеров.

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

- Своя сеть через network.New и алиасы закрывают общение контейнеров: сервис находит базу по имени db без публикации портов.
- Доступ снаружи — Host и MappedPort к ExposedPorts; доступ изнутри — Networks и NetworkAliases; модели разные и не взаимозаменяемы.
- Алиасы живут только в пользовательских сетях: на дефолтной bridge опции подключения вернут ошибку.
- Уборка — net.Remove в t.Cleanup или CleanupNetwork: своя сеть на тест даёт изоляцию параллельных прогонов.

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

Соберите стенд: сеть через network.New, nginx с алиасом web и контейнер curlimages/curl с Exec-пробой http://web/. Затем поднимите сервис и базу через GenericContainer с Networks и NetworkAliases и убедитесь, что у базы нет ExposedPorts, а сервис отвечает на /health через Host и MappedPort. Финал: подключите один контейнер к двум сетям и сравните вывод ContainerIPs.

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

### Почему для связи контейнеров не нужен MappedPort?

MappedPort читает маппинг порта на хост — таблицу, которую Docker ведёт для доступа извне. Трафик между контейнерами одной сети в эту таблицу не попадает: пакеты идут по внутренним адресам сети, а имя-алиас резолвит встроенный DNS Docker. Публикация портов — мост между хостом и контейнером; когда оба конца трафика внутри сети, мост не нужен, достаточно Networks и NetworkAliases. Отсюда и экономия: десять контейнеров в сети могут иметь ноль опубликованных портов, если тест трогает лишь один.

### Алиас или ContainerIP: что использовать в тесте?

Алиас. IP живёт, пока жив контейнер, меняется от прогона к прогону и известен только после старта — зашивать его в конфиг сервиса нельзя. Алиас вы придумываете до старта, подставляете в Env, и встроенный DNS сети разрулит адреса сам; адреса меняются даже внутри одного прогона после перезапуска контейнера — ещё один аргумент в пользу имён. ContainerIP и ContainerIPs оставьте для отладки: проверить, что контейнер получил адрес в каждой из своих сетей.

### Что случится с сетью, если тест упал и не вызвал Remove?

Слой за слоем: t.Cleanup исполнится и при провале, и при панике — Remove сработает. Если процесс теста убит, вступает уборщик: сеть помечена служебными метками, которые network.New проставляет автоматически, и в конце сессии он зачистит и её, и контейнеры. Уборка не потеряется, но до конца сессии сеть будет зря держать адреса — поэтому Remove в Cleanup пишут всегда.
