← назад к разделу

Запустить один контейнер — просто. Но реальное приложение почти всегда состоит из нескольких: веб-сервис, база данных, кэш. Им нужно общаться между собой — и здесь важно понять, как Docker организует сетевое окружение контейнеров.

Ниже — один и тот же запрос из контейнера app в базу, записанный тремя способами: три адреса дают три разных исхода.

app и db на одном хосте, оба в сети backend-netхостсеть backend-net · свой DNSappслушает :8080dbpostgres:16 :5432 браузерlocalhost:8080-p 8080:8080у db своего -p нет — с хоста порт 5432 недоступен localhost:5432localhost внутри app — это сам appна 5432 внутри app никто не слушает → отказ 172.18.0.5:5432адрес выдан при старте — доходит, пока db живперезапустили db — адрес сменился, конфиг врёт db:5432DNS сети backend-net выдаёт текущий IP dbработает и после каждого перезапуска db Имя контейнера — адрес, который не протухаетВ bridge по умолчанию DNS нет — там остаётся только IP

Три написания одного адреса — три исхода: 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 сеть project-a подключили при запуске сеть project-b docker network connect контейнеры разных сетей друг друга не видят

Пользовательская сеть это список приглашённых: контейнер видит только соседей по своим сетям, а 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

Как это выглядит в связке: схема

Браузер стучится на хост по HTTP :8080, проброс -p 8080:8080 ведёт в контейнер app, а тот ходит в контейнер db по адресу jdbc:postgresql://db:5432/mydb

Клиент видит только порт хоста. База данных спрятана внутри сети 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 — декларативное описание многоконтейнерных приложений; сети создаются автоматически.
  • Тома и данные — как хранить данные за пределами контейнера.