Learning Platform
Глоссарий Troubleshooting
Урок 11.02 · 24 мин
Средний
dockernetworkingbridgeiptablesports

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:

  1. Создаёт пару veth (virtual ethernet): два связанных интерфейса, что один пишет — другой читает.
  2. Один конец veth ставит в network namespace контейнера и переименовывает в eth0.
  3. Второй конец оставляет в хост-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 внутри одного контейнера.

Анатомия bridge network
Container A: eth0 172.17.0.2Внутри namespace контейнера интерфейс называется eth0. Это один конец veth-пары. IP выдан Docker'ом из 172.17.0.0/16
veth
vethA on hostПарный интерфейс на стороне хоста. Не имеет IP. Подключен к docker0 как порт моста
Container B: eth0 172.17.0.3Второй контейнер. Тоже свой netns, тоже eth0, IP из той же подсети
veth
vethB on hostСвой veth, подключен к тому же docker0. На bridge все vethX друг друга 'видят' через L2-broadcast
docker0 bridge 172.17.0.1Linux bridge в ядре хоста. Имеет IP 172.17.0.1 — это default gateway для всех контейнеров на дефолтной bridge-сети
iptables MASQUERADE -> eth0Когда контейнер обращается во внешний мир, пакет идёт через docker0, далее iptables-правило MASQUERADE заменяет source IP на host-IP. Контейнер виден как хост

Контейнеры 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 сделал:

  1. Создал контейнер на bridge с veth-парой.
  2. Получил container IP 172.17.0.2 (или другой из subnet).
  3. Создал в iptables правило DNAT: tcp dport 8080 -> 172.17.0.2:80.
  4. (опционально) 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’у:

  1. Ядро видит DNAT-правило, переписывает destination на 172.17.0.2:80.
  2. Пакет идёт через docker0, попадает в veth контейнера.
  3. Внутри netns пакет приходит на eth0:80, где слушает nginx.
  4. Ответ идёт обратно с реверс-NAT, клиент видит ответ от host:8080.
Путь HTTP-запроса через -p 8080:80
curl http://host:8080Клиент (браузер, curl) шлёт TCP-пакет на host:8080. Хост ловит это в input хук
iptables PREROUTING (DNAT)Docker создал правило: tcp dport 8080 -> 172.17.0.2:80. Ядро переписывает destination IP+port в пакете
Маршрут к docker0Теперь пакет destined to 172.17.0.2 — это локально на docker0. Идёт через bridge на нужный veth
veth -> Container eth0:80Пакет попадает в netns контейнера, прилетает на eth0:80 где слушает nginx
nginx отвечаетBackend response через тот же путь обратно. iptables держит state-tracking (conntrack), знает что это reply, делает реверс-NAT
Client видит ответ от host:8080Клиент не знает, что пакет ходил в namespace и обратно — для него это обычный HTTP-ответ от хоста

Синтаксис -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
WARNING

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
TIP

В 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 этого добивается.


Проверка знанийKnowledge check
Запустил docker run -d -p 8080:80 nginx . Объясни последовательность того, что произошло на уровне ядра/iptables, и почему EXPOSE 80 в Dockerfile самого nginx не отменяет необходимость флага -p .
ОтветAnswer
Последовательность: (1) Docker создал контейнер на default bridge с veth-парой, назначил контейнеру IP вроде 172.17.0.2; (2) запустил процесс nginx внутри netns, который слушает 0.0.0.0:80 в этом namespace; (3) добавил iptables-правило в chain DOCKER (cм. iptables -t nat -L DOCKER): -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80; (4) опционально стартовал userspace docker-proxy на host:8080 как fallback. При входящем пакете на host:8080: PREROUTING hook применяет DNAT, destination становится 172.17.0.2:80, пакет идёт через docker0 в veth контейнера, прилетает на eth0:80 где слушает nginx. EXPOSE 80 в Dockerfile — это metadata-инструкция: говорит "образ слушает на 80 внутри своего netns". Никаких iptables-правил EXPOSE не создаёт. Чтобы пакет с хоста дошёл до контейнера, нужен DNAT — это и есть -p (или -P для авто-mapping всех EXPOSE). Без -p порт виден только из контейнеров на той же сети.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Что произойдёт на уровне ядра, когда ты запустил docker run -d -p 8080:80 nginx?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4