Сеть контейнеров: bridge, DNS по имени и ловушка localhost
Контейнеры общаются через сетевые драйверы — по умолчанию bridge. Пользовательский bridge добавляет DNS по имени контейнера; дефолтный — нет. Публикуй порты только для доступа снаружи и не верь localhost внутри контейнера.
Два контейнера, api и db, оба запущены, оба здоровы. На каждый запрос api пишет в лог ECONNREFUSED 127.0.0.1:5432. Разработчик клянётся, что база поднята — docker ps это подтверждает — и сжигает полдня, проверяя конфиг Postgres, файрволы и креды. Баг не в этом. В строке подключения стоит localhost, а внутри контейнера api localhost означает сам контейнер api, где на 5432 никто не слушает. База — в одном сетевом хопе, доступна по имени db — но только если оба контейнера в правильном типе network.
За десять минут ты поймёшь, почему localhost врёт внутри контейнера, какой драйвер выбрать и как связать сервисы так, чтобы они не теряли друг друга после рестарта.
Драйверы: как контейнер вообще получает сеть
Контейнер стартует в своём network namespace — со своими интерфейсами, таблицей маршрутизации и localhost. Сетевой драйвер решает, как этот namespace соединяется со всем остальным. Драйвер ты выбираешь, создавая network или запуская контейнер.
Драйвер bridge — это умолчание. Docker создаёт на хосте приватный виртуальный коммутатор (docker0 для дефолтной сети) и подключает к нему каждый контейнер виртуальной ethernet-парой. Контейнеры получают внутренние IP в приватной подсети (часто 172.17.0.0/16), общаются друг с другом через этот bridge и достигают внешнего мира через NAT — хост маскирует их трафик за своим IP. Снаружи хоста эти IP контейнеров невидимы, пока ты не опубликуешь порт.
Драйвер host убирает изоляцию полностью: контейнер делит network namespace хоста. Нет отдельного IP контейнера, нет NAT и нет проброса портов — если приложение биндится на :8080, оно на :8080 хоста напрямую. Это самый быстрый вариант (нет bridge, нет хопа через NAT) и причина использовать его для чувствительных к задержке или высоконагруженных ворклоадов, но ты теряешь изоляцию и не можешь запустить два контейнера, которым нужен один порт.
Драйвер none даёт контейнеру namespace только с loopback — вообще без внешней связности, для полностью изолированной работы. overlay охватывает несколько хостов (сервисы Swarm, мультихостовые кластеры), туннелируя трафик контейнеров между демонами. macvlan выдаёт контейнеру собственный MAC-адрес, так что он выглядит физическим устройством в LAN, — для легаси-систем, которые этого ждут. Повседневная рабочая лошадка — bridge; остальное ситуативно.
docker run -d --name web nginx # дефолтный bridge
docker run -d --network host nginx # делит стек хоста, -p не нужен
docker run -d --network none alpine sleep 1d # только loopback, изолирован
docker network ls # bridge, host, none + созданные тобойДефолтный bridge против пользовательского bridge
Вот ключевой сеньорский момент, на котором держится хук. Существуют два вида bridge-сети, и они не равнозначны.
Дефолтный bridge (тот, что зовётся bridge, к которому подключается каждый контейнер, если не сказано иное) не имеет встроенного DNS. Контейнеры на нём достают друг друга только по сырому IP-адресу — что хрупко, потому что IP назначаются динамически и меняются между перезапусками. Старым обходом был устаревший флаг --link.
Пользовательский bridge — любая сеть, которую ты создаёшь сам — добавляет встроенный DNS-сервер. Контейнеры на нём резолвят друг друга по имени контейнера автоматически. Запусти db и api в одной пользовательской network — и api сможет подключиться к db:5432 без IP, без link, без конфига. Это service discovery, и это единственная причина всегда помещать связанные контейнеры в созданную тобой network, а не в дефолтный bridge.
docker network create appnet # пользовательский bridge
docker run -d --name db --network appnet postgres:16
docker run -d --name api --network appnet my/api # api достаёт db по имени "db"Именно это делает за тебя docker compose: каждый проект Compose получает собственный пользовательский bridge, так что сервисы достают друг друга по имени сервиса из коробки. Руками network ты создаёшь редко, потому что Compose уже это сделал.
▸Почему это работает
На пользовательском bridge резолвинг имён динамичен: если db перезапустится и получит новый IP, встроенный DNS вернёт новый адрес при следующем запросе, так что api продолжит работать без перезапуска. У дефолтного bridge такого DNS нет — поэтому захардкоженный там IP контейнера ломается на следующем перезапуске. DNS Docker’а ещё и форвардит неизвестные ему имена резолверу хоста, так что внешние хостнеймы из контейнера резолвятся нормально.
Публикация портов против общения внутри сети
Два контейнера в одной пользовательской network общаются напрямую на собственном порту контейнера — api бьёт в db:5432 независимо от того, опубликован ли 5432. Публикация — только про доступ к контейнеру снаружи Docker-сети, обычно с хоста или из интернета.
-p host:container (--publish) маппит порт хоста на порт контейнера через NAT bridge’а. -p 8080:80 значит «отправь хостовый :8080 в этот контейнерный :80». Ты публикуешь сервис, обращённый наружу (веб-сервер, API-шлюз), а внутренние сервисы вроде базы оставляешь без публикации — им не нужен хостовый порт, потому что их клиенты в той же network.
Частая путаница: EXPOSE в Dockerfile ничего не публикует. Это документация — метаданные, объявляющие, какой порт слушает приложение. Он не открывает ни одного хостового порта и не меняет файрвол. Только -p (или ports: в Compose) реально публикует; EXPOSE лишь сообщает читателям и инструментам намерение.
docker run -d --network appnet -p 8080:80 --name web my/web # host:8080 → container:80
# у db НЕТ -p: с хоста недоступен, но web достаёт его на db:5432 внутри| Драйвер | Изоляция | DNS по имени | Проброс портов | Для чего |
|---|---|---|---|---|
bridge (пользовательский) | Свой namespace + IP | Да (по имени контейнера) | -p для доступа снаружи | Умолчание для многоконтейнерных приложений |
bridge (дефолтный) | Свой namespace + IP | Нет (только IP) | -p для доступа снаружи | Быстрые разовые запуски; не для связки приложения |
host | Нет (делит стек хоста) | Н/Д (резолвер хоста) | Нет {без -p} | Минимум задержки, один владелец порта |
none | Полная (только loopback) | Нет | Нет связности | Полностью изолированные задачи |
overlay | Свой namespace + IP | Да (сервисы Swarm) | Публикуется на сервис | Мультихостовые / Swarm-кластеры |
Ловушка localhost
Теперь хук разрешается. Внутри контейнера localhost (127.0.0.1) — это собственный loopback контейнера, не хост и не какой-либо другой контейнер. У каждого контейнера свой loopback-интерфейс в своём namespace. Поэтому api, подключаясь к localhost:5432, спрашивает Postgres у самого себя, ничего слушающего не находит и получает ECONNREFUSED.
Фиксы следуют из всего сказанного выше:
- Чтобы достать другой контейнер, используй его имя в общей пользовательской network:
db:5432. Никогдаlocalhost, никогда захардкоженный IP. - Чтобы достать сервис на самой хост-машине изнутри контейнера (например, базу, запущенную нативно на твоём ноутбуке), используй
host.docker.internal— специальное DNS-имя, которое Docker резолвит в IP хост-машины. (host-сеть — другой путь: тогдаlocalhostи есть хост, потому что отдельного namespace нет.) (host-сеть — другой путь: тогдаlocalhostи есть хост, потому что отдельного namespace нет.) - Чтобы достать контейнер с хоста, опубликуй порт через
-pи подключайся кlocalhost:<hostPort>на хосте.
Вместе эти три правила означают одно: localhost — только «этот же контейнер», и без явного имени первый же рестарт в проде тихо ломает связку. В тот миг, когда ты пересекаешь границу контейнера, тебе нужно имя — db, api или host.docker.internal.
Контейнер api подключается к localhost:5432, чтобы достать контейнер db, оба в одной пользовательской network, и получает ECONNREFUSED. Что не так?
В Dockerfile есть EXPOSE 80, но контейнер запущен без флага -p. Достанешь ли ты приложение из браузера на хосте?
Многоконтейнерное приложение (web + api + db) работает на одном хосте, и web-уровень должен быть доступен из интернета. Выбери сетевую схему.
- 01Почему пользовательский bridge даёт контейнерам доставать друг друга по имени, а дефолтный — нет, и что это меняет на практике?
- 02Что означает localhost внутри контейнера и как достать (а) другой контейнер, (б) сервис на хост-машине и (в) контейнер с хоста?
Контейнер стартует в своём network namespace, и сетевой драйвер решает, как он соединяется. Bridge — умолчание: приватный виртуальный коммутатор на хосте даёт каждому контейнеру внутренний IP и NAT-ит его исходящий трафик, тогда как host-сеть делит стек хоста без изоляции и без проброса портов, none даёт только loopback, а overlay и macvlan покрывают мультихостовые случаи и присутствие в LAN. Сеньорское различие — между дефолтным bridge, у которого нет DNS и который вынуждает к хрупкой адресации по IP, и пользовательским bridge, чей встроенный DNS позволяет контейнерам резолвить друг друга по имени контейнера — автоматический service discovery, и причина, по которой docker compose создаёт network на проект, чтобы сервисы доставали друг друга по имени. Публикация через -p маппит порт хоста на порт контейнера и нужна только для доступа снаружи Docker-сети; контейнеры в одной пользовательской network уже общаются на порту контейнера напрямую, а EXPOSE — лишь документация, не публикующая ничего. Наконец, localhost внутри контейнера — это собственный loopback этого контейнера, поэтому достань соседа по имени (db:5432), хост через host.docker.internal, а контейнер с хоста — через опубликованный порт; никогда не считай, что localhost пересекает границу контейнера. Теперь, когда видишь ECONNREFUSED на 127.0.0.1, первый вопрос — в каком network namespace на самом деле работает приложение?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.