Bridge network и публикация портов
Bridge — самый частый сетевой режим Docker, и без понимания того, как он работает, дебаг сетевых проблем превращается в чёрный ящик. В этом уроке разбираемся: что такое docker0, как veth-пара соединяет контейнер с хостом, как -p 8080:80 физически появляется на хосте через iptables, и почему EXPOSE в Dockerfile — это документация, а не команда «открой порт».
TCP 3-way handshake — SYN, SYN-ACK, ACK и sequence numbers
docker0: виртуальный мост на хосте
После установки Docker на Linux появляется интерфейс docker0:
ip addr show docker0
# docker0: <BROADCAST,MULTICAST,UP> mtu 1500
# inet 172.17.0.1/16 scope global docker0
Это Linux bridge — программный L2-switch внутри ядра. Когда ты запускаешь контейнер на default bridge, Docker:
- Создаёт пару
veth(virtual ethernet): два связанных интерфейса, что один пишет — другой читает. - Один конец
vethставит в network namespace контейнера и переименовывает вeth0. - Второй конец оставляет в хост-namespace и подключает к
docker0.
docker run -d --name demo alpine sleep 1000
# На хосте видим vethXXX:
ip link | grep veth
# 47: vethbf8c2a3@if46: <BROADCAST,MULTICAST,UP,LOWER_UP> master docker0
# Внутри контейнера видим eth0:
docker exec demo ip link show eth0
# eth0@if47: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
Заметь номер if47 в выводе контейнера и 47 в выводе хоста — это пара. Каждый веthXXX на хосте «парный» к eth0 внутри одного контейнера.
Контейнеры A и B на одном bridge могут пинговать друг друга по IP. Если bridge — user-defined, ещё и по имени (default bridge не имеет DNS).
Subnet и IP-адресация
Default bridge получает 172.17.0.0/16. User-defined bridges получают подсети из пула, который можно увидеть через docker network inspect:
docker network create my-net
docker network inspect my-net | grep -A 3 IPAM
# "IPAM": {
# "Config": [
# { "Subnet": "172.18.0.0/16", "Gateway": "172.18.0.1" }
# ]
Можно задать subnet явно:
docker network create --subnet 10.42.0.0/24 small-net
Это полезно, если 172.x пересекается с корпоративной LAN (бывает в офисе с VPN). Тогда контейнерные IP не конфликтуют с маршрутами хоста.
EXPOSE — это не публикация
Часто бывает путаница: в Dockerfile есть EXPOSE 5432, и думают, что это «открывает порт». На самом деле — нет. EXPOSE в Dockerfile — чисто metadata-инструкция:
EXPOSE 5432
Что она делает:
- Записывает в metadata образа «образ ожидает, что слушать будут на порту 5432».
- Влияет на
docker run -P(большая P) — запустится с автоматическим mapping всех EXPOSE на случайные high-ports хоста. - Никак не открывает порт на хосте — это нужно делать через
-pявно.
# Образ объявил EXPOSE 5432 (postgres)
docker run -d --name pg postgres:17
# Контейнер слушает 5432 внутри своего netns, но снаружи (на хосте) этот порт НЕ открыт.
# Проверка:
ss -tlnp | grep 5432
# (пусто) — нет listener'а на хосте.
# Но другой контейнер на том же bridge видит:
docker run --rm --network <same-net> alpine nc -zv pg 5432
# pg port 5432 [tcp/postgresql] succeeded.
EXPOSE — это «открыто наружу из namespace», но не наружу из хоста. Чтобы хост видел — нужен -p.
-p host:container — что физически происходит
docker run -d --name web -p 8080:80 nginx:1.27-alpine
Что Docker сделал:
- Создал контейнер на bridge с veth-парой.
- Получил container IP
172.17.0.2(или другой из subnet). - Создал в iptables правило DNAT:
tcp dport 8080 -> 172.17.0.2:80. - (опционально) bind userspace-процесс
docker-proxyна 0.0.0.0:8080 как fallback для не-iptables-средств.
Посмотреть правило:
sudo iptables -t nat -L DOCKER -n
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
Когда снаружи прилетает пакет на tcp/8080 к host’у:
- Ядро видит DNAT-правило, переписывает destination на
172.17.0.2:80. - Пакет идёт через
docker0, попадает в veth контейнера. - Внутри netns пакет приходит на
eth0:80, где слушает nginx. - Ответ идёт обратно с реверс-NAT, клиент видит ответ от host:8080.
Синтаксис -p — варианты
-p 8080:80 # host 0.0.0.0:8080 -> container 80
-p 127.0.0.1:8080:80 # только localhost (не наружу) -> container 80
-p 8080:80/udp # UDP вместо TCP
-p 8080-8090:80-90 # диапазон портов
-p 8080:80 -p 8081:81 # несколько mapping'ов
Важно: -p 8080:80 без явного IP биндится на 0.0.0.0 — порт открыт на ВСЕХ интерфейсах хоста, включая публичный. На dev-машине это часто проблема: запустил Postgres -p 5432:5432 — и Postgres засветился наружу. Правильно:
docker run -d -p 127.0.0.1:5432:5432 postgres:17
Docker по умолчанию обходит UFW и большинство host-firewall’ов на Linux, потому что DOCKER chain в iptables вставляется до правил firewall. Это значит, что ufw deny 5432 НЕ блокирует контейнер с -p 5432:5432. Для prod-машин с публичными IP это серьёзная проблема. Решение — -p 127.0.0.1:5432:5432 или собственные DOCKER-USER правила. С Docker 28 есть опция --ip-routed-mode (через daemon.json), но включается осознанно.
—publish-all (-P)
docker run -d -P nginx:1.27-alpine
# Все EXPOSE-порты nginx мапятся на случайные high-ports хоста.
docker port <container>
# 80/tcp -> 0.0.0.0:32768
Удобно для quick experiments. Не для production: порт каждый раз новый.
Где смотреть, кто что слушает
# Что мапится:
docker ps --format 'table {{.Names}}\t{{.Ports}}'
# NAMES PORTS
# web 0.0.0.0:8080->80/tcp
# Конкретного контейнера:
docker port web
# 80/tcp -> 0.0.0.0:8080
# На хосте кто слушает:
sudo ss -tlnp | grep docker
# LISTEN 0 4096 *:8080 *:* users:(("docker-proxy",pid=...,fd=4))
Попробуй сам
# 1. -p в действии.
docker run -d --name web -p 8080:80 nginx:1.27-alpine
curl -s -o /dev/null -w "Status: %{http_code}\n" http://localhost:8080
# Status: 200
# 2. EXPOSE без -p — снаружи закрыто.
docker run -d --name pg postgres:17 -e POSTGRES_PASSWORD=x
docker port pg
# (пусто) — порт 5432 EXPOSE, но не опубликован.
nc -zv localhost 5432
# nc: connect to localhost port 5432 (tcp) failed: Connection refused
# 3. Только localhost — безопасный default для dev.
docker rm -f pg
docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-p 127.0.0.1:5432:5432 \
postgres:17
nc -zv 127.0.0.1 5432
# succeeded
# С другой машины в LAN:
# nc -zv <твой-IP> 5432
# Connection refused — недоступно извне.
# 4. -P — все EXPOSE на random ports.
docker rm -f pg web
docker run -d --name web -P nginx:1.27-alpine
docker port web
# 80/tcp -> 0.0.0.0:32768 (port может быть другой)
# Cleanup.
docker rm -f web
В compose ports: работает идентично -p. Если ты пишешь ports: ["5432:5432"] — Postgres засветится наружу. Чтобы оставить только локально: ports: ["127.0.0.1:5432:5432"]. Или вообще без публикации, если только сервисы compose’а должны видеть Postgres — тогда секция ports: не нужна, контейнеры общаются через bridge без хоста.
В следующем уроке — DNS и service discovery в user-defined bridge. Почему ping pg работает в compose, и как Docker этого добивается.