DNS и service discovery в user-defined bridge
Когда ты пишешь в compose host: postgres для подключения к БД — это работает не магией, а через встроенный DNS-сервер Docker. На user-defined bridge каждому контейнеру доступен резолвер по адресу 127.0.0.11, который отвечает на запросы по container name, по compose service name и по сетевому alias. На default bridge этого нет — там только IP. В этом уроке разбираем механику.
DNS-резолюция — путь от example.com до IP
Default bridge vs user-defined: главное отличие
Запустим два контейнера на default bridge:
docker run -d --name a alpine sleep 1000
docker run -d --name b alpine sleep 1000
docker exec a ping -c 2 b
# ping: bad address 'b'
DNS-резолв по имени не работает. На default bridge нет embedded DNS. Это сохранено для совместимости с очень старыми скриптами, написанными до того, как Docker добавил DNS-сервер.
Теперь на user-defined bridge:
docker network create mynet
docker run -d --name a --network mynet alpine sleep 1000
docker run -d --name b --network mynet alpine sleep 1000
docker exec a ping -c 2 b
# PING b (172.18.0.3): 56 data bytes
# 64 bytes from 172.18.0.3: seq=0 ttl=64 time=0.07 ms
Резолв работает. Внутри контейнера в /etc/resolv.conf:
docker exec a cat /etc/resolv.conf
# nameserver 127.0.0.11
# search ...
127.0.0.11 — embedded DNS resolver Docker’а. Он слушает только внутри netns контейнера, наружу не виден.
Embedded DNS: что он умеет
Docker daemon держит карту network -> {container_name, aliases} -> IP. Когда контейнер делает DNS-запрос:
- Запрос на
127.0.0.11(через резолвер контейнера). - Docker daemon проверяет: знает ли он это имя в той же сети?
- Если знает — возвращает IP контейнера.
- Если нет — форвардит запрос наверх (как правило, к DNS хоста, например
8.8.8.8или корпоративному DNS).
Что резолвится
Embedded DNS отвечает на несколько вариантов имени:
- Container name. Имя из
--name. Если контейнер--name db, тоdbрезолвится. - Container ID short. Первые 12 символов ID тоже работают как имя.
- Network alias. Заданный через
--network-alias=foo(или в composealiases: [foo]). - Compose service name. В compose-стеке имя сервиса в YAML — это alias на сети.
docker network create mynet
docker run -d --name db --network mynet --network-alias primary-db --network-alias master alpine sleep 1000
docker run --rm --network mynet alpine sh -c 'getent hosts db; getent hosts primary-db; getent hosts master'
# 172.18.0.2 db
# 172.18.0.2 primary-db
# 172.18.0.2 master
Один контейнер — несколько имён. Полезно, если в коде приложения захардкожен один host, а ты хочешь подменить, на какой контейнер он попадает.
Compose: автоматический setup
В compose YAML каждый сервис — это alias на default network проекта. Минимальный пример:
services:
postgres:
image: postgres:17
environment:
POSTGRES_PASSWORD: secret
app:
image: python:3.13-slim
command: python -c "import socket; print(socket.gethostbyname('postgres'))"
depends_on: [postgres]
docker compose up создаёт сеть <project>_default, оба сервиса подключаются туда. В app команда socket.gethostbyname('postgres') вернёт IP контейнера postgres. Никаких -p или DNS-конфигов руками не пишем.
docker compose up -d
docker compose logs app
# app-1 | 172.18.0.2
postgres — это имя сервиса. На сети-default оно резолвится в IP. Если ты переименуешь сервис в db — в коде надо тоже db. Поэтому имена сервисов — это контракт между сервисами.
Multi-network: контейнер в нескольких сетях
Контейнер может быть подключен к нескольким сетям одновременно. Полезный паттерн — frontend/backend сегрегация:
services:
proxy:
image: nginx
networks: [frontend, backend]
app:
image: myapp
networks: [backend]
postgres:
image: postgres:17
networks: [backend]
networks:
frontend:
backend:
proxyвидитappиpostgresчерезbackend, плюс наружу черезfrontend.appиpostgresдруг друга видят черезbackend.appне достучится во frontend-network — изоляция.
Embedded DNS работает per-network: app может резолвить postgres (общая backend), но не резолвит контейнеры из frontend, к которому не подключен.
Кастомные DNS-настройки
docker run --rm \
--dns 8.8.8.8 \
--dns-search example.com \
alpine sh -c 'cat /etc/resolv.conf'
# search example.com
# nameserver 8.8.8.8
Полезно, если корпоративный DNS перехватывает запросы и ты хочешь обойти. В compose:
services:
app:
image: myapp
dns:
- 8.8.8.8
- 1.1.1.1
dns_search:
- corp.example.com
Также --add-host name:IP — добавить строку в /etc/hosts контейнера:
docker run --rm --add-host host.docker.internal:host-gateway alpine \
getent hosts host.docker.internal
# 172.17.0.1 host.docker.internal
host.docker.internal — специальное имя для доступа к хосту изнутри контейнера. На macOS и Windows работает из коробки, на Linux — нужно явно --add-host=host.docker.internal:host-gateway.
TTL и кэш
Внутри контейнера glibc-resolver кэширует DNS-ответы. Если контейнер db пересоздался с новым IP, а приложение уже сделало lookup — оно будет долбиться на старый IP. Поэтому:
- Если возможно, не пересоздавай контейнеры в long-running компонентах compose-стенда без перезапуска dependent-сервисов.
- В production-сценариях с динамическими endpoint’ами обычно перед DNS стоит service mesh с health-checking.
Embedded DNS Docker’а отдаёт TTL = 600 сек. Если контейнер db упал и заменился (другой IP) — приложение, которое заresolvило db минуту назад, ещё 9 минут может ходить на мёртвый IP. На уровне Junior DE достаточно знать о существовании этой проблемы: при странных «иногда не подключается» дебажить через getent hosts db внутри приложения.
Попробуй сам
# 1. User-defined bridge + DNS.
docker network create demo
docker run -d --name redis --network demo redis:7-alpine
docker run -d --name app --network demo \
--network-alias api alpine sleep 1000
docker exec app ping -c 2 redis
# работает
# Alias тоже резолвится:
docker exec redis ping -c 2 api
# работает
# 2. Контейнер вне сети — не видит.
docker run --rm alpine ping -c 2 redis
# ping: bad address — потому что не в demo network.
# 3. Подключим существующий контейнер к demo on-the-fly.
docker run -d --name observer alpine sleep 1000
docker exec observer ping -c 1 redis
# fail
docker network connect demo observer
docker exec observer ping -c 1 redis
# success!
# Цена: контейнер теперь в двух сетях, у него два eth.
docker exec observer ip addr | grep eth
# eth0 — изначальный bridge
# eth1 — demo
# 4. host.docker.internal на Linux.
docker run --rm --add-host host.docker.internal:host-gateway \
alpine sh -c 'getent hosts host.docker.internal; echo "---"; nc -zv host.docker.internal 22'
# Видишь IP хоста + sshd если он есть на 22.
# Cleanup.
docker rm -f redis app observer
docker network rm demo
Compose: чтобы дать сервису кастомное имя, помимо service name можно использовать container_name: pg — тогда вне compose видно как pg. Но это убивает scale (--scale postgres=2 не сработает, потому что container_name уникален). В compose-сценариях лучше не задавать container_name без необходимости.
В следующем уроке — troubleshooting сетей: как читать docker network inspect, что значит localhost внутри контейнера, и почему Postgres из compose доступен по postgres:5432, а не localhost:5432.