Запустить один контейнер — просто. Но реальное приложение почти всегда состоит из нескольких: веб-сервис, база данных, кэш. Им нужно общаться между собой — и здесь важно понять, как Docker организует сетевое окружение контейнеров.
Ниже — один и тот же запрос из контейнера app в базу, записанный тремя способами: три адреса дают три разных исхода.
Три написания одного адреса — три исхода: localhost остаётся внутри самого app, IP живёт до перезапуска db, имя db резолвит встроенный DNS пользовательской сети.
Зачем контейнерам своя сеть
Приложение в контейнере обращается к localhost:5432 — а базы там нет: она в соседнем контейнере, у которого свой собственный localhost. Так выходит потому, что по умолчанию каждый контейнер запускается в изолированном сетевом пространстве имён (network namespace). Это означает, что у контейнера есть собственный сетевой стек: свой адрес, свои порты, свой петлевой интерфейс (lo). Процессы внутри контейнера не видят сетевые интерфейсы хоста напрямую.
Такая изоляция даёт два важных свойства:
- Безопасность: контейнер не может случайно занять порт хоста или соседнего контейнера без явного разрешения.
- Предсказуемость: приложение внутри контейнера всегда видит одинаковое окружение — вне зависимости от того, что запущено на хосте рядом.
Короткая формула: контейнер = отдельная машина в сети, которую Docker сам соединяет с другими.
Bridge-сеть по умолчанию
Когда вы запускаете контейнер командой docker run без дополнительных флагов, Docker подключает его к bridge-сети по умолчанию — виртуальному коммутатору с именем bridge (на хосте виден как интерфейс docker0).
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# abc123def456 bridge bridge local
# ...
Контейнеры в сети bridge могут достучаться друг до друга по IP-адресу, но не по имени. Адреса назначаются динамически при каждом запуске, а значит жёстко прописать их нельзя.
Пример: запускаем два контейнера и пробуем связь по имени:
docker run -d --name app alpine sleep 1000
docker run -d --name db -e POSTGRES_PASSWORD=secret postgres:16
docker exec app ping -c 1 db
# ping: bad address 'db' — имя не резолвится в стандартной bridge-сети
Именно здесь лежит типичная ошибка: разработчик пишет в конфиге приложения localhost в расчёте достучаться до базы данных в соседнем контейнере. Но localhost внутри контейнера — это петлевой интерфейс самого контейнера, а не хоста. База данных там не слушает.
User-defined bridge и DNS по имени
Решение — создать пользовательскую bridge-сеть (user-defined bridge). В такой сети Docker автоматически поднимает встроенный DNS-резолвер: контейнеры обращаются друг к другу по имени контейнера или псевдониму сервиса.
# Создаём сеть
docker network create backend-net
# Запускаем базу в этой сети
docker run -d \
--name db \
--network backend-net \
postgres:16
# Запускаем приложение в той же сети
docker run -d \
--name app \
--network backend-net \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydb \
myapp:1.0
Теперь app может обратиться к db по имени — Docker сам разрешит имя db в нужный IP-адрес. Это работает, потому что пользовательские сети имеют встроенный DNS, которого нет в сети bridge по умолчанию.
Преимущества user-defined bridge перед стандартной
| Свойство | bridge (по умолчанию) | user-defined bridge |
|---|---|---|
| Связь по IP | да | да |
| DNS по имени контейнера | нет | да |
| Кто оказывается рядом | все контейнеры, запущенные без --network | только те, кого подключили вы |
| Возможность переподключить контейнер | нет | да |
Строчка про соседей — не формальность. Сеть bridge по умолчанию тоже отделена от пользовательских, дело не в этом. Дело в том, что в неё попадает всё, запущенное без --network: контейнеры соседнего проекта, чей-то давно забытый Redis, тестовая база коллеги. И все они видят друг друга по IP. Пользовательская сеть — это список приглашённых, который составляете вы.
DNS в сети по умолчанию выключен не случайно: это старейшая сеть Docker, и связь по имени там делали через устаревший флаг --link. Когда появились пользовательские сети со встроенным DNS, старую оставили как есть ради совместимости.
Публикация портов наружу: флаг -p
Контейнер изолирован от хоста. Чтобы внешний мир (браузер, клиент, другой сервис вне Docker) мог достучаться до порта контейнера, нужно опубликовать порт через флаг -p:
docker run -d \
--name app \
--network backend-net \
-p 8080:8080 \
myapp:1.0
# ^^^^ ^^^^
# хост контейнер
Формат: -p <порт_на_хосте>:<порт_в_контейнере>.
Теперь запрос на http://localhost:8080 хоста попадёт в порт 8080 контейнера.
Здесь прячется неприятный сюрприз. Написав -p 8080:8080, вы открыли порт не «на своей машине», а на всех сетевых интерфейсах — это то же самое, что -p 0.0.0.0:8080:8080. Если у сервера есть внешний адрес, порт торчит наружу. Хуже того, Docker прописывает свои правила в iptables раньше правил брандмауэра, так что настроенный ufw этот порт не закроет — он про него просто не узнает. Чтобы порт был виден только с самой машины, адрес указывают явно:
docker run -d -p 127.0.0.1:8080:8080 --network backend-net myapp:1.0
Важно: базу данных (db) публиковать наружу не нужно — app обращается к ней через внутреннюю сеть Docker, а пробрасывать порт PostgreSQL в открытый интерфейс — риск безопасности.
# db остаётся только во внутренней сети — наружу порт не пробрасываем
docker run -d \
--name db \
--network backend-net \
postgres:16
А если до базы всё-таки нужно дотянуться с ноутбука — клиентом посмотреть таблицы, — привязывайте её к петлевому интерфейсу: -p 127.0.0.1:5432:5432. Тогда база доступна вам и недоступна всем остальным.
Достучаться до хоста: host.docker.internal
Обратная задача встаёт почти сразу: не «пустить в контейнер снаружи», а «вызвать из контейнера то, что работает на самом хосте». База, запущенная не в Docker, приложение, поднятое из среды разработки, отладочный прокси на ноутбуке.
localhost внутри контейнера — это сам контейнер, поэтому такой вызов никуда не приходит. Готовый ответ — специальное имя:
# приложение в контейнере ходит в PostgreSQL, запущенный на хосте
docker run -e SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/app my-app:1.0
Имя host.docker.internal разрешается в адрес хоста с точки зрения контейнера. Три вещи, которые надо про него знать.
На macOS и Windows оно работает само. Docker Desktop добавляет его в каждый контейнер, и это основной способ дотянуться до хоста при разработке.
На Linux по умолчанию его нет. Там Docker работает прямо на хосте, без промежуточной виртуальной машины, и такого имени в контейнере не появляется. Добавляют его явно при запуске:
docker run --add-host=host.docker.internal:host-gateway my-app:1.0
host-gateway — особое значение, которое Docker подменяет на адрес шлюза (обычно 172.17.0.1). В Compose то же пишут через extra_hosts. Можно обойтись и без имени, указав адрес шлюза напрямую, но тогда файл настроек перестаёт работать на другой машине.
Служба на хосте должна слушать не только localhost. Частая половина проблемы: имя разрешилось, а соединение отвергнуто. Причина в том, что PostgreSQL или приложение на хосте слушает 127.0.0.1, а запрос приходит с адреса контейнерной сети. Лечится это тем, что служба слушает 0.0.0.0 (и тогда стоит подумать о том, кто ещё к ней дотянется), либо тем, что её тоже переносят в контейнер — обычно это и есть правильный ответ для локальной разработки.
Как проверить, что сеть работает
Когда контейнеры «не видят друг друга», гадать не нужно: всё проверяется тремя командами.
Кто в сети и с какими адресами. Сначала смотрят состав сети:
docker network inspect backend-net
В выводе есть подсеть, шлюз и раздел Containers со списком подключённых контейнеров и их адресами. Здесь сразу видно две частые причины: нужного контейнера в сети нет вовсе (запустили без --network) или он подключён к другой сети с похожим именем.
Разрешается ли имя изнутри. Проверять надо из того контейнера, который жалуется:
docker exec -it my-app getent hosts postgres # есть в любом образе с libc
docker exec -it my-app nslookup postgres # если в образе есть утилиты DNS
Ответ с адресом означает, что имя разрешается и дело не в DNS. Пустой ответ — контейнеры в разных сетях или имя написано иначе. Если утилит в образе нет (минимальные образы), подключают отладочный контейнер к той же сети: docker run --rm -it --network backend-net nicolaka/netshoot sh — и уже оттуда проверяют и разрешение имени, и доступность порта (nc -zv postgres 5432).
Кто вообще отвечает за имена. Внутри контейнера в пользовательской сети в /etc/resolv.conf стоит адрес 127.0.0.11 — это встроенный разрешитель имён Docker. Он знает имена контейнеров текущей сети, а всё остальное пересылает наружу, к разрешителю хоста. Из этого следует несколько практических вещей: свои настройки DNS для контейнера задают флагом --dns (правка /etc/resolv.conf внутри бессмысленна), кэширование чужих имён происходит на хосте, а в сети по умолчанию (bridge) этого разрешителя для имён контейнеров нет — потому там имена и не работают.
И типичный порядок разбора, который экономит время: сначала docker network inspect (в одной ли сети), потом разрешение имени, потом доступность порта, и только после этого — настройки приложения. Три четверти случаев заканчиваются на первом шаге.
Псевдонимы и имя, по которому обращаются в Compose
Имя контейнера — не единственное имя, по которому он доступен. Контейнеру можно задать сетевые псевдонимы:
docker run -d --name postgres-16-primary --network backend-net \
--network-alias postgres --network-alias db postgres:16
Теперь тот же контейнер отвечает на postgres-16-primary, postgres и db. Зачем это нужно: имя контейнера содержит версию и роль (это удобно в docker ps), а приложению в настройках лучше видеть простое postgres — тогда замена версии не требует правки настроек. Псевдоним задаётся отдельно в каждой сети, и в разных сетях он может быть разным.
И главное для тех, кто пришёл сюда из статьи про Compose. Compose даёт контейнеру имя вида <проект>-<сервис>-1 (например, shop-postgres-1), но обращаться в настройках надо не к нему, а к имени сервиса из YAML-файла:
services:
postgres: # вот это имя и есть имя хоста
image: postgres:16
app:
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/app
Compose добавляет имя сервиса сетевым псевдонимом каждому контейнеру — именно поэтому postgres работает, хотя контейнер называется иначе. Отсюда два следствия. Настройки переносимы: имя сервиса не зависит от имени проекта и от номера экземпляра. И при масштабировании (docker compose up --scale app=3) одно имя разрешается в несколько адресов — Docker отдаёт их в разном порядке, и получается примитивная балансировка без всяких дополнительных средств.
Изоляция сетей
Контейнеры, подключённые к разным пользовательским сетям, не видят друг друга — даже если работают на одном хосте. Это удобно, когда нужно запустить несколько независимых стеков (например, два проекта) на одной машине без конфликтов.
docker network create project-a
docker network create project-b
# Контейнеры в project-a и project-b изолированы друг от друга
Один контейнер можно подключить к нескольким сетям сразу. Скажем, контейнер gateway запущен в project-a и должен видеть ещё и сервисы из project-b; его подключают ко второй сети, не перезапуская:
docker run -d --name gateway --network project-a nginx
docker network connect project-b gateway
Пользовательская сеть это список приглашённых: контейнер видит только соседей по своим сетям, а gateway подключён сразу к двум и потому видит обе.
Исходящие соединения: как контейнер выходит в интернет
Вся статья до этого места — про входящие и внутренние соединения. Осталась третья сторона: контейнер сам обращается наружу, за библиотекой в реестр, к платёжному провайдеру, к внешнему API.
Это работает без всяких настроек, и механизм стоит понимать. У контейнера адрес из частной подсети (172.17.0.x), которая в интернете не маршрутизируется. Когда пакет уходит наружу, хост подменяет обратный адрес на свой — это преобразование адресов, тот же механизм, что в домашнем роутере. Для внешнего сервера запрос выглядит пришедшим с адреса хоста, и ответ возвращается на хост, а тот переправляет его контейнеру.
Практические следствия, о которых обычно узнают по неприятному поводу.
Внешний сервис видит адрес хоста, а не контейнера. Все контейнеры одной машины для него — один клиент. Это важно там, где считают запросы по адресу: ограничения реестра образов, лимиты чужого API, чёрные списки. Пятнадцать параллельных сборок с одной машины бьют в один лимит.
Исходящий трафик не изолирован. Пользовательская сеть отделяет контейнеры друг от друга, но не запрещает им выходить в интернет: сеть с базой данных по умолчанию имеет доступ наружу. Если это нежелательно (а для базы это обычно так), сеть создают с флагом --internal — тогда выхода за пределы хоста у неё нет.
Корпоративный прокси нужно передавать внутрь. Контейнер не наследует ваши настройки прокси: переменные HTTP_PROXY, HTTPS_PROXY, NO_PROXY передают ему явно (или прописывают в настройках клиента Docker, чтобы он подставлял их сам). Это первая причина, по которой в корпоративной сети «сборка не может скачать зависимости», хотя браузер на той же машине всё открывает.
DNS берётся от хоста. Имена, которых нет среди контейнеров, разрешаются через разрешитель хоста. Поэтому внутренние корпоративные имена работают внутри контейнера тогда и только тогда, когда они работают на хосте — и ломаются вместе с подключением к корпоративной сети.
Подсети контейнеров и конфликт с корпоративной сетью
У каждой сети Docker своя подсеть, и берёт он их из частных диапазонов: 172.17.0.0/16 для сети по умолчанию, дальше 172.18, 172.19 и так далее — обычно до 172.31. Иногда используется и 192.168.x.
Отсюда самая частая производственная поломка, которая выглядит мистически: подключились к корпоративной сети или к VPN — и контейнеры перестали видеть внутренние сервисы, или наоборот, перестал работать сам VPN. Причина в том, что корпоративные сети очень часто живут в тех же диапазонах 172.16–172.31, и маршруты пересеклись: пакет, который должен идти в туннель, уходит на мост Docker, или наоборот.
Признак именно этой проблемы: перестаёт работать конкретная подсеть, а не всё сразу, и всё возвращается в норму после отключения VPN или после docker network prune, когда лишние сети исчезают.
Лечится это тем, что диапазоны, из которых Docker берёт подсети, задают явно в настройках службы (/etc/docker/daemon.json, в Docker Desktop — то же поле в интерфейсе):
{
"default-address-pools": [
{ "base": "10.200.0.0/16", "size": 24 }
]
}
Здесь Docker будет нарезать сети по /24 из диапазона 10.200.x, который с корпоративным не пересекается. Настройка применяется к новым сетям, поэтому существующие придётся пересоздать; заодно полезно задать подсеть сети по умолчанию ("bip"). Конкретный диапазон для своей сети можно указать и при создании: docker network create --subnet 10.201.0.0/24 backend-net.
Договориться об этом с администраторами сети стоит заранее: вопрос «какие диапазоны у нас заняты» решается один раз и снимает целый класс необъяснимых поломок у всей команды.
Режимы host и none
Кроме bridge, Docker поддерживает ещё два режима, которые стоит знать:
--network host — контейнер использует сетевой стек хоста напрямую, без изоляции. За ним тянутся, когда контейнер должен видеть порты хоста как свои — например, агент мониторинга, который слушает всё, что происходит на машине, — или когда накладные расходы bridge на сотнях тысяч соединений в секунду становятся заметны.
По-настоящему этот режим работает на Linux: там хост — это и есть ваша машина. В Docker Desktop 4.34 и новее его тоже можно включить (Settings → Resources → Network), но слово «хост» там означает не ноутбук, а ту самую внутреннюю Linux-машину, внутри которой Docker Desktop держит контейнеры. Поведение поэтому всё равно отличается от линуксового, и переносить выводы с ноутбука на сервер не стоит.
docker run --network host nginx
# Nginx слушает на портах хоста напрямую, без -p
--network none — контейнер полностью изолирован от сети. За ним тянутся, когда сеть не нужна и лучше, чтобы её не было вовсе: обработка выгрузки из чужой базы, генерация ключей — если в код проберётся что-то лишнее, наружу оно не позвонит.
docker run --network none alpine sh
Как это выглядит в связке: схема
Клиент видит только порт хоста. База данных спрятана внутри сети Docker — снаружи недоступна.
Глубже: Docker на macOS и Windows: всё внутри Linux-ВМрасширенное
«Контейнер использует ядро хоста» из первой статьи раздела верно для Linux. На ноутбуке с macOS или Windows ядра Linux нет, и Docker Desktop поднимает виртуальную машину с Linux, внутри которой и живут контейнеры. Ядро хоста из той статьи это ядро этой ВМ, и отсюда четыре странности, которые ловят все.
Медленный bind mount. Папка проекта лежит на диске ноутбука, а читает её процесс внутри ВМ, и каждое обращение к файлу пересекает границу двух систем. mvn package с .m2 из смонтированной папки или npm install с node_modules на bind mount идут в разы медленнее, чем на Linux. Лечится тем, что горячие каталоги (node_modules, .m2, target) кладут в именованные тома, которые живут внутри ВМ, а bind mount оставляют для исходников. В Windows с WSL2 проект держат в файловой системе Linux (\\wsl$), а не на диске C:.
host.docker.internal. Контейнеру нужно достучаться до базы, которая запущена прямо на ноутбуке. localhost внутри контейнера это сам контейнер, а адрес ноутбука из ВМ не очевиден, поэтому Docker Desktop даёт имя host.docker.internal, которое указывает на хост. На Linux этого имени нет по умолчанию, и его добавляют флагом --add-host=host.docker.internal:host-gateway, чтобы конфигурация была одинаковой.
--network host ведёт себя иначе. На Linux контейнер с сетью хоста слушает порты самого хоста. На macOS и Windows «хост» для контейнера это ВМ, и порт, открытый в режиме host, с ноутбука не виден; в новых версиях Docker Desktop есть отдельная настройка, которая это включает, но по умолчанию режим host там не работает так, как ожидают. Публикация через -p работает везде одинаково, и для локальной разработки берут её.
Ресурсы это ресурсы ВМ. Память и процессоры, которые видит Docker, заданы в настройках Docker Desktop, а не равны ноутбуку; контейнер без лимита упирается в ВМ, а не в машину. Диск ВМ (Docker.raw) растёт до предела из настроек и не уменьшается при удалении образов, о чём статья про тома. И архитектура: ноутбук с Apple Silicon собирает образы arm64, о чём статья про Spring Boot в контейнере.
Альтернативы Docker Desktop на macOS (Colima, OrbStack, Rancher Desktop) устроены так же, с ВМ внутри, и отличаются скоростью файловой системы и лицензией. Всё это не относится к серверу: там Linux, контейнер и ядро одни, и странности исчезают.
Коротко
- Каждый контейнер работает в изолированном сетевом пространстве: у него свой
localhost, и он не видит соседей без явной настройки. - Стандартная
bridge-сеть даёт связь по IP, но не по имени — для продакшн-стека она не подходит. Пользовательская bridge-сеть — правильный выбор: DNS по имени контейнера, изоляция, гибкое подключение. - Порты публикуются наружу через
-p хост:контейнер; внутренние сервисы (БД, кэш) публиковать не нужно. localhostвнутри контейнера — это сам контейнер, не хост и не соседний сервис.--network hostубирает изоляцию,--network noneполностью отключает сеть.- На macOS и Windows контейнеры живут в Linux-ВМ: bind mount медленный (горячие каталоги в тома), хост доступен как
host.docker.internal,--network hostне даёт портов ноутбука, ресурсы и диск это ресурсы ВМ. - До службы на хосте из контейнера ходят по
host.docker.internal: на macOS и Windows оно есть само, на Linux добавляется через--add-host=host.docker.internal:host-gateway, а сама служба должна слушать не только127.0.0.1. - Разбор «контейнеры не видят друг друга»:
docker network inspect(в одной ли сети), затем разрешение имени изнутри, затем доступность порта; за имена отвечает встроенный разрешитель127.0.0.11. - В Compose обращаются к имени сервиса, а не к имени контейнера: Compose добавляет его сетевым псевдонимом, и при масштабировании одно имя разрешается в несколько адресов.
- Наружу контейнер выходит через подмену адреса на хостовый: внешний сервис видит один клиент на всю машину, прокси передают переменными, а сеть без выхода наружу создают с
--internal. - Подсети Docker из диапазона
172.17–172.31регулярно конфликтуют с корпоративной сетью и VPN: диапазон задают черезdefault-address-poolsв настройках службы.
Что почитать дальше
- Запуск контейнеров — флаги
docker run, переменные окружения, режимы работы. - Docker Compose — декларативное описание многоконтейнерных приложений; сети создаются автоматически.
- Тома и данные — как хранить данные за пределами контейнера.