Публикация портов и встроенный DNS: DNAT, bind-адреса и 127.0.0.11
Публикация порта ставит правило iptables DNAT, переписывающее host:port в container:port, с областью по bind-адресу. Пользовательские сети добавляют встроенный DNS на 127.0.0.11. Большинство багов «работает локально, но не извне» и «имя не найдено» — это эти два механизма.
Демо работало на ноутбуке разработчика и падало в staging, и разница была в одном пропущенном двоеточии. Compose-файл публиковал API как 127.0.0.1:8080:8080. На ноутбуке разработчик стучался в localhost:8080, и всё было хорошо. В staging health-check балансировщика приходил с другого хоста, получал connection-refused и помечал сервис как упавший — поэтому он никогда не получал трафик, а дежурный видел аварию при зелёных логах контейнера. docker ps показывал порт «опубликованным», curl localhost:8080 на самой машине возвращал 200, и всё же порт был недостижим откуда-либо ещё. Bind-адрес 127.0.0.1 молча ограничил опубликованный порт только хостовым loopback: правило DNAT существовало, но матчило лишь пакеты, приходящие на интерфейс loopback. Поменяли на 0.0.0.0:8080:8080 (или просто 8080:8080), и health-check позеленел. Публикация порта — это правило iptables с областью по интерфейсу, а встроенный DNS — второй механизм, который с ним путают; оба стоит понимать точно, потому что вместе они объясняют большую часть путаницы в сети контейнеров.
Публикация порта — это DNAT
-p 8080:8080 не «открывает» порт так, как правило фаервола — он ставит правило DNAT (destination NAT) в iptables, переписывающее назначение пакетов, приходящих на порт 8080 хоста, на IP и порт контейнера. Правило оседает в цепочке DOCKER таблицы nat, достижимой из PREROUTING (для трафика снаружи) и OUTPUT (для трафика с самого хоста):
-A DOCKER ! -i docker0 -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:8080Спецификация опубликованного порта состоит из трёх частей: [bind-address:]host-port:container-port. Bind-адрес — это то, что кусает: опусти его (или используй 0.0.0.0), и порт достижим на каждом интерфейсе хоста; задай 127.0.0.1:8080:8080, и DNAT матчит лишь трафик, приходящий на loopback — достижимый с самого хоста, невидимый любой другой машине. Это и есть баг из вступления: правило существовало, docker ps гордо его перечислял, но область по интерфейсу исключала health-checker. Вторая тонкость: по умолчанию правило DNAT от Docker вставлено перед правилами INPUT хостового фаервола, поэтому опубликованный порт может быть достижим снаружи, даже если ufw/firewalld думают, что заблокировали его — Docker и хостовый фаервол редактируют разные цепочки, и люди удивляются, кто из них побеждает.
userland-proxy
На самом деле у трафика опубликованного порта два пути. Быстрый путь — DNAT iptables выше, целиком в ядре. Но Docker также запускает небольшой процесс docker-proxy в userland на каждый опубликованный порт как запасной вариант — для случаев hairpin и соединений по loopback на том же хосте, которые одно правило DNAT не покрывает. Путь через ядро стоит практически ничего; путь через userland-proxy копирует байты через процесс в пользовательском пространстве, добавляя единицы микросекунд задержки на пакет плюс процесс и удерживаемый сокет на каждый опубликованный порт. На хосте, публикующем сотни портов, это реальные накладные расходы, поэтому высоконагруженные конфигурации часто отключают его ("userland-proxy": false) и полагаются только на DNAT плюс ядровый hairpin-маршрут.
Контейнер публикует 127.0.0.1:5432:5432 для Postgres. С хоста psql подключается нормально. С другой машины в той же сети — connection refused. docker ps перечисляет порт как опубликованный. В чём дело?
▸Почему это работает
Почему 127.0.0.1:port вообще существует как опция, если вызывает такую путаницу? Потому что это верный дефолт для порта базы данных или админ-порта, достижимого лишь другим процессам на том же хосте (или reverse-proxy на нём), но никогда из сети. Грабли — использовать его рефлекторно и потом ждать удалённой достижимости. Дисциплина: бинди на loopback, когда потребитель рядом, бинди на 0.0.0.0, когда подключаться должно что-то вне машины, и никогда не давай «опубликованному» в docker ps подменять «достижимый оттуда, откуда мне надо».
Встроенный DNS на 127.0.0.11
Почему http://api:8080 просто работает внутри Compose-проекта, но перестаёт работать при обращении из другого проекта? Это и есть область DNS в действии — второй класс сбоев, который важно освоить.
На дефолтном мосту контейнеры достигают друг друга лишь по IP — разрешения имён нет. В пользовательской bridge-сети (которую создаёт docker network create или любая сеть сервиса в Compose) Docker запускает встроенный DNS-сервер, который каждый контейнер достигает по фиксированному адресу 127.0.0.11:53. Docker переписывает /etc/resolv.conf каждого контейнера, чтобы он указывал на него, и тот разрешает имена контейнеров и сетевые алиасы в текущие IP контейнеров, пересылая всё остальное на резолверы хоста. Поэтому в Compose-сети ты подключаешься к http://api:8080, и это просто работает — api это имя контейнера, известное встроенному DNS, и возвращаемый IP всегда актуален даже после рестарта, давшего контейнеру новый адрес.
Числа и режимы отказа, которые важны:
- Резолвер живёт на
127.0.0.11внутри netns каждого контейнера — loopback-адрес, поэтому он не сталкивается с DNS хоста и недостижим извне контейнера. Правила NAT iptables внутри netns перенаправляют этот loopback-трафик на DNS-слушатель демона. - Разрешение имён per-network: контейнер разрешает имена лишь контейнеров, подключённых к сети, которую он разделяет. Два контейнера в разных пользовательских сетях не могут разрешить друг друга по имени даже на одном хосте — классический баг «работает в одном compose-файле, не работает между двумя».
- Короткий TTL DNS плюс разрешение на каждом подключении делают рестарты прозрачными, но клиент, кэширующий разрешённый IP навсегда (некоторые дефолты JVM, наивные HTTP-клиенты), продолжает стучаться в старый IP после рестарта целевого контейнера — баг устаревшего соединения, который выглядит как сеть, но это кэш резолвера клиента.
Сервис A достигает сервис B по имени 'b' в одном Compose-проекте. Сервис C из второго проекта вообще не может разрешить 'b', хотя оба работают на одном хосте. Почему?
- 01Объясни, что именно ставит '-p 127.0.0.1:8080:8080' и почему тот же контейнер достижим с хоста, но не с другой машины.
- 02Опиши встроенный DNS: адрес, область, что возвращает и два режима отказа.
Два отдельных механизма объясняют большую часть путаницы в сети контейнеров, и senior-навык — держать их порознь. Публикация порта (-p) ставит правило iptables DNAT в цепочке DOCKER таблицы nat, переписывающее назначение трафика, приходящего на порт хоста, на IP и порт контейнера; это сантехника достижимости, а не открытие фаервола. Спецификация опубликованного порта — [bind-address:]host-port:container-port, и bind-адрес ограничивает правило интерфейсом: 127.0.0.1: делает порт только-loopback — достижимым с хоста, отклонённым с любой другой машины — хотя docker ps всё ещё гордо печатает ‘опубликован’. Docker вставляет этот DNAT перед правилами INPUT хостового фаервола, поэтому опубликованный порт может быть достижим снаружи, даже когда ufw или firewalld считают, что заблокировали его. Процесс docker-proxy в userland на каждый порт — запасной вариант для случаев hairpin и loopback на том же хосте, которые DNAT в одиночку упускает; он добавляет задержку в единицы микросекунд на пакет и удерживаемый сокет на порт, поэтому высоконагруженные хосты часто ставят userland-proxy false. Второй механизм — разрешение имён: пользовательская bridge-сеть (у дефолтного моста его нет) запускает встроенный DNS на фиксированном loopback 127.0.0.11:53 внутри каждого контейнера, разрешая имена контейнеров и алиасы в текущие IP и пересылая остальное вверх — поэтому http://api:8080 работает в Compose-сети и переживает рестарты, переназначающие IP. Его область per-network, так что две отдельные сети не разрешат имена друг друга без общей сети, а клиент, кэширующий IP навсегда, продолжает подключаться к мёртвому адресу после рестарта. Когда дебажишь, сначала реши, симптом ли это достижимости (bind-адрес, DNAT, фаервол) или разрешения (область DNS, устаревший кэш) — тогда чинишь верный механизм.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.