Путь пакета из контейнера наружу: мост, NAT и куда девается исходный IP
Контейнер стучится наружу, а в логах внешнего сервиса светится IP вашего хоста, а не самого контейнера — и это не баг, а прямое следствие того, как Docker строит сеть по умолчанию. Если вы разбираетесь, почему в access-логе API-провайдера видна одна и та же «серая» точка входа для десятка разных контейнеров, или почему за обратным прокси все клиенты выглядят одинаково, — разберём весь путь пакета от процесса внутри контейнера до конца провода, и что с этим делать на практике.
Содержание
- Свой сетевой интерфейс: veth-пара и network namespace
- Мост docker0: изолированная подсеть контейнеров
- NAT на выходе: что происходит с исходным IP
- Почему внешний сервер видит IP хоста, а не контейнера
- Входящие соединения: проброс портов и DNAT
- Многослойное проксирование: как не потерять реальный IP клиента
Свой сетевой интерфейс: veth-пара и network namespace
Когда вы запускаете docker run, движок не просто «подключает» контейнер к сети хоста — он создаёт для него отдельный network namespace, изолированное сетевое пространство со своими интерфейсами, таблицей маршрутизации и правилами iptables. Внутри этого namespace контейнер видит eth0 с собственным IP, обычно из диапазона 172.17.0.0/16 для сети по умолчанию.
Физически этот eth0 — одна половина виртуальной пары veth. Вторая половина остаётся в network namespace хоста и подключается к мосту. Пара работает как виртуальный патч-корд: что вошло с одного конца, выходит с другого. Убедиться в этом можно изнутри контейнера и на хосте одновременно:
# внутри контейнера
docker exec -it myapp ip addr show eth0
# 12: eth0@if13: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
# inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0
# на хосте — ищем вторую половину пары по индексу if13
ip link show | grep -A1 "veth"
# 13: veth9a3f21@if12: <BROADCAST,MULTICAST,UP,LOWER_UP> ... master docker0
Число после @if — это индекс интерфейса на другом конце пары. Так можно точно сопоставить, какой vethXXXXXX на хосте относится к конкретному контейнеру: без этого при десятке контейнеров интерфейсы на хосте выглядят как безымянный список.
Мост docker0: изолированная подсеть контейнеров
Все veth-интерфейсы контейнеров одной Docker-сети подключаются не напрямую к физическому интерфейсу хоста, а к виртуальному Linux-мосту (bridge). Для сети по умолчанию это docker0, для пользовательских сетей (docker network create) — отдельный мост на каждую сеть с собственной подсетью.
Мост работает как обычный L2-свитч: у него есть собственный IP (шлюз для контейнеров, обычно первый адрес подсети), и он пересылает кадры между подключёнными veth по MAC-адресам. Контейнеры в одной сети видят друг друга напрямую через мост — трафик между ними не выходит за пределы хоста и не проходит через NAT.
# список сетей и их подсетей
docker network ls
docker network inspect bridge --format '{{json .IPAM.Config}}'
# [{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]
# состояние моста и его портов
ip addr show docker0
bridge link show
Важный практический момент: контейнеры в разных пользовательских сетях по умолчанию друг друга не видят — у каждой сети свой мост и своя изолированная подсеть, соединить их можно только явно, подключив контейнер к обеим сетям (docker network connect). Подробнее о том, чем bridge-сеть отличается от host и overlay, разобрано в статье про типы сетей Docker.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверNAT на выходе: что происходит с исходным IP
Мост решает пересылку внутри подсети контейнеров, но подсеть 172.17.0.0/16 — приватная и не маршрутизируется в интернете. Когда пакет из контейнера идёт наружу, хост должен подменить его исходный адрес на собственный, иначе ответ просто не найдёт дороги назад. Эта подмена — Network Address Translation, и на Linux-хосте она реализована правилом MASQUERADE в таблице nat, которое Docker добавляет автоматически при старте демона:
sudo iptables -t nat -L POSTROUTING -n -v
# Chain POSTROUTING (policy ACCEPT)
# pkts bytes target prot opt in out source destination
# 0 0 MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
Правило читается так: любой пакет из подсети 172.17.0.0/16, уходящий не через docker0 (то есть наружу, через физический или внешний виртуальный интерфейс), проходит маскарадинг — исходный IP пакета переписывается на IP исходящего интерфейса хоста. MASQUERADE — частный случай SNAT (Source NAT), удобный тем, что не требует знать заранее конкретный внешний IP: он берёт его динамически с интерфейса, через который пакет реально уходит. Это важно на серверах с несколькими внешними адресами или там, где IP присваивается не статически.
Ядро при этом не просто переписывает заголовок и забывает — оно ведёт таблицу трансляций в подсистеме conntrack, сопоставляя каждое соединение контейнера с конкретной парой (внешний IP хоста, случайный исходящий порт). Посмотреть состояние можно так:
sudo conntrack -L | grep 172.17.0.3
# tcp 6 431999 ESTABLISHED src=172.17.0.3 dst=1.2.3.4 sport=44120 dport=443 \
# src=1.2.3.4 dst=203.0.113.10 sport=443 dport=51422 [ASSURED]
Здесь видно обе стороны трансляции: контейнер (172.17.0.3:44120) и то, как его видит внешний сервер (хостовый 203.0.113.10:51422). Именно эта запись позволяет обратному пакету от внешнего сервера найти дорогу назад — ядро смотрит на порт назначения 51422, находит в conntrack, что за ним стоит 172.17.0.3:44120, и переписывает адрес обратно перед тем, как отдать пакет в мост.
Почему внешний сервер видит IP хоста, а не контейнера
Из механики NAT прямо следует ответ на вопрос, который чаще всего и приводит к этой теме: сервер на другом конце соединения физически не может увидеть внутренний IP контейнера, если между ними стоит трансляция адресов. Он видит только то, что осталось после MASQUERADE — внешний IP хоста. Пакет с адресом 172.17.0.3 просто не смог бы дойти обратно: этот адрес приватный и в интернете не маршрутизируется, ни один провайдер его не пропустит.
Это не специфика Docker — та же логика работает для любого NAT, включая домашний роутер за одним внешним IP. Разница в масштабе: на хосте с NAT-контейнерами через один и тот же внешний адрес может проходить трафик от изолированных друг от друга сервисов, и снаружи все они неотличимы по исходному IP. Если на одном сервере крутится десяток контейнеров, каждый из которых ходит к одному и тому же внешнему API, в логах провайдера вы увидите один IP и совокупный трафик — различить, какой контейнер сколько запросов сделал, по IP не получится, только по другим признакам (User-Agent, API-ключам, временным меткам в собственных логах).
Отдельно стоит развеять частую путаницу: MASQUERADE не имеет отношения к X-Forwarded-For и другим HTTP-заголовкам. NAT работает на уровне IP/TCP и ничего не знает о протоколе поверх — он в принципе не может «положить» исходный адрес в заголовок, потому что оперирует пакетами, а не HTTP-запросами. Любая передача исходного IP клиента через NAT-границу — это отдельный, добровольный механизм на уровне приложения, а не побочный эффект трансляции адресов. Об этом — в разделе про многослойное проксирование ниже.
Входящие соединения: проброс портов и DNAT
Всё описанное выше — про исходящий трафик. С входящими соединениями (когда контейнер публикует порт через -p 8080:80) работает зеркальная трансляция — Destination NAT (DNAT). Docker добавляет правило в цепочку DOCKER таблицы nat, которое перехватывает пакеты, пришедшие на хост по порту 8080, и переписывает адрес назначения на внутренний IP и порт контейнера:
sudo iptables -t nat -L DOCKER -n -v
# Chain DOCKER (2 references)
# pkts bytes target prot opt in out source destination
# 0 0 DNAT tcp -- !docker0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.3:80
Здесь IP клиента, инициировавшего соединение, при DNAT не подменяется — переписывается только адрес назначения. Контейнер в своих логах видит настоящий IP клиента, который стучался на порт 8080 хоста. Это принципиально другая ситуация по сравнению с исходящим трафиком: DNAT не «прячет» источник, он только перенаправляет назначение. Именно поэтому веб-сервер, работающий прямо в контейнере с проброшенным портом и без дополнительного обратного прокси, обычно и так видит реальный IP клиента — до тех пор, пока перед ним нет ещё одного слоя проксирования.
Многослойное проксирование: как не потерять реальный IP клиента
Проблема с исходным IP возникает не из-за NAT-выхода контейнера в интернет (это отдельное, однонаправленное соединение), а из-за цепочки проксирования на входе: клиент → nginx на хосте → контейнер с приложением, или ещё длиннее — CDN → балансировщик → nginx-контейнер → бэкенд-контейнер. На каждом шаге, где трафик проксируется (а не просто перенаправляется через DNAT), TCP-соединение обрывается и открывается заново от имени прокси. С точки зрения следующего звена цепочки клиентом становится сам прокси — его IP.
Единственный способ пронести реальный IP клиента через такую цепочку — явно передавать его в HTTP-заголовке, обычно X-Forwarded-For, и на каждом следующем звене этот заголовок читать и доверять ему, а не выбирать данные из TCP-соединения. На уровне nginx, стоящего перед контейнером, это выглядит так:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Директива $proxy_add_x_forwarded_for не перезаписывает заголовок, а добавляет текущий $remote_addr к уже существующему значению — так на каждом хопе накапливается цепочка адресов через запятую, и по ней в принципе можно восстановить весь путь запроса. Но здесь есть практическая ловушка: если между клиентом и вашим первым прокси есть звено, которое клиент не контролирует (сам браузер этого заголовка не шлёт, но недобросовестный клиент может отправить его руками), значение X-Forwarded-For от внешнего мира нельзя считать доверенным без проверки — иначе клиент подделает себе любой «источник».
Правильная модель — доверять этому заголовку только с той точки цепочки, где вы физически контролируете сеть. На входном крае (первый публичный прокси) нужно либо игнорировать входящий X-Forwarded-For и подставлять только $remote_addr, либо использовать set_real_ip_from в связке с модулем ngx_http_realip_module, ограничив доверенные подсети адресами вашего CDN или балансировщика:
set_real_ip_from 172.17.0.0/16; # доверяем только внутренней docker-сети
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Для приложений внутри контейнеров, которые сами разбирают IP из соединения (не из заголовка), значение $remote_addr на этом шаге всегда будет внутренним IP предыдущего прокси-контейнера — то есть тем самым адресом из 172.17.0.0/16, о котором шла речь выше. Если это видно в логах приложения вместо реального IP посетителя, значит, приложение либо не настроено читать X-Forwarded-For, либо запущено за прокси, который его не прокидывает. Общую механику этого пути разбирает статья про путь запроса через reverse proxy, а базовые принципы трансляции адресов — как работает NAT и почему устройства не видят друг друга напрямую.
Для логирования и аналитики отсюда следует практическое правило: реальный IP клиента нужно фиксировать на самом внешнем слое (там, где $remote_addr ещё не подменён внутренним прокси) и явно передавать его дальше во всех внутренних логах и метриках — как отдельное поле, а не полагаться на то, что «IP из соединения» на каком-то внутреннем сервисе будет совпадать с адресом реального посетителя. На связке контейнеров, где сетевые namespace-ы уже устроены как отдельные «комнаты» друг для друга — эта тема разобрана в статье про network namespaces контейнера — подмена адреса на каждой границе моста абсолютно ожидаема, и бороться с ней нужно на уровне приложения, а не сети.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли отключить NAT для исходящего трафика контейнеров?
Да, если запустить контейнер в режиме --network host — тогда он делит сетевой стек с хостом напрямую, без своего eth0 и без моста. Но это убирает и сетевую изоляцию: контейнер видит все интерфейсы и порты хоста как свои, что подходит не для всякого сценария и требует отдельно продуманной модели доступа.
Почему у контейнера при каждом перезапуске меняется внутренний IP?
Docker выдаёт адреса из подсети моста по мере запуска контейнеров, а не закрепляет их за именем. Если нужен постоянный внутренний адрес, задайте его явно через docker network connect --ip или используйте пользовательскую сеть с DNS по имени контейнера — Docker резолвит имена сервисов через встроенный DNS на мосту, и в большинстве случаев привязываться к конкретному IP вообще не нужно.
Как узнать, какой именно внешний IP хоста увидит внешний сервер при запросе из контейнера?
Проще всего — спросить снаружи из самого контейнера: docker exec -it myapp curl -s ifconfig.me (или аналогичный сервис, отдающий видимый извне адрес). Результат будет совпадать с исходящим адресом хоста, потому что именно на него MASQUERADE подменяет источник.
X-Forwarded-For можно подделать — как тогда доверять реальному IP?
Только ограничивая, с каких сетей вы вообще принимаете этот заголовок как достоверный (set_real_ip_from на публичном крае), и не читая его на промежуточных внутренних прокси без такой же проверки — при правильной цепочке каждое звено добавляет свой IP, а не переписывает чужой.
Одинаково ли работает NAT для bridge-сети по умолчанию и для пользовательской сети?
Механика та же — маскарадинг на выходе через MASQUERADE, только подсеть и мост у каждой пользовательской сети свои. Разница в основном в изоляции между сетями и в наличии встроенного DNS по именам контейнеров, которого нет в сети bridge по умолчанию.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →