Как известно, Docker умеет создавать виртуальные сети для безопасного и удобного сетевого взаимодействия внутри контейнеров. В этой статье мы рассмотрим, как именно он это делает на примере базовых манипуляций с сетью в рамках одного хоста с операционной системой Linux.

Небольшая теоретическая часть
Для того, чтобы понимать, что будет происходить дальше в статье, надо немного окунуться в теорию. Если вы уже работали с настройкой сетей в Linux, то можете сразу переходить к следующей части.
На всякий случай в этом блоке кратко объясню, что значат строки типа
192.168.0.1/16
. Часть/16
означает, что первые 16 бит ip-адреса предназначены для идентификации сети, а все остальные – для идентификации узла. В приведённом ранее примере192.168
– это часть, идетифицирующая сеть, а0.1
– идентифицирующая узел.
/32
означает, что указан конкретный ip-адрес узла.
Сетевые Namespace'ы (netns)
Network Namespace в Linux – это изолированная среда сетевых ресурсов. У неё свои ip-адреса, таблицы маршрутизации, firewall (о нём ниже) и так далее.
Они полезны, когда, например, у вас есть две виртуальные машины, которые вы хотите связать друг с другом, но изолировать от других сетей. Ну, или если вам нужно сделать тоже самое, но с контейнерам :).
Виртуальные сетевые интерфейсы
Виртуальные сетевые интерфейсы – это, по сути, логическое представление физических. Используются для маршрутизации, туннелирования, создания виртуальных сетей и так далее.
Мы будет использовать loopback
(или lo или localhost), veth
и bridge
, поэтому про них поподробнее.
loopback
(localhost) – Предоставляет возможность обмена трафиком в пределах самого устройства.Используется для внутренней коммуникации приложений и тестирования.
Доступен по адресам 127.0.0.1 - 127.255.255.255.
veth
(Virtual Ethernet) – Виртуальный ethernet кабель: связывает 2 точки виртуальной сети и делает возможным передачу трафика между ними.bridge
– По сути, это виртуальный switch.
Switch – это устройство, с помощью которого можно соединить различные сетевые устройства для обмена трафиком.
Например, подключив компьютер и принтер к одному и тому же Switch'у, компьютер сможет "говорить" принтеру, что и в каком количестве надо распечатать :).
Firewall
Firewall используется для фильтрации трафика, как входящего, так и исходящего. Он может иметь вид физического устройства или виртуальной логической реализации. Мы будем использовать второе для дополнительной изоляции сетевых namespace'ов.
NAT
NAT или Network Address Translation – это технология преобразования внутренних ip-адресов во внешний, и наоброт. Например, когда вы подключены к одному Wi-Fi с телефона и компьютера, на обоих устройствах будет единый внешний ip-адрес. NAT будет менять у входящего трафика ip-адрес роутера на внтуренний ip-адрес устройста, которому адресованы пакеты. И наоборот, ip-адрес у исходящих пакетов будет изменён на ip-адрес роутера.
Нужно всё это для экономии публичных ip-адресов, так как их количество сильно ограничено (около 4.3 миллиардов, из которых 81% уже назначены или зарезервированы).
Что делает Docker?

Docker для каждого контейнера создаёт сетевой namespace (netns), и если вы не указали при создании контейнера сеть, к которой его надо подключить, то контейнер подключится к дефолтной сети (которой под капотом является bridge
) парой veth
интерфейсов. Это нужно для того, чтобы по умолчанию контейнеры могли друг с другом общаться.
Но только этого было бы недостаточно. Для отправки ip пакетов контейнеры должны знать, какой узел будет начальной точкой в построении маршрута до других контейнеров, подключённых к той же docker-сети, то есть bridge
'у. Этой начальной точкой (gateway
'ем) становится сам bridge
, к которому подключен контейнер. То есть выглядит это как-то так: "Для трафика, отправленного на ip-адреса, которые находятся в подсети 172.17.0.0/16, задай gateway 172.17.0.1".
Мы только что рассмотрели docker network driver bridge, но есть ещё несколько драйверов, например:
--network none
– контейнер вообще не будет ни к чему подключен, и не будет иметь даже доступа в интернет через сеть хоста. Всё что у него будет, этоloopback
интерфейс.
--network host
– контейнер будет подключен к сети хоста.
Для того, чтобы у контейнеров был доступ в интернет, Docker добавляет в NAT правила подмены ip-адреса для пакетов, которые отправляются из bridge
интерфейсов docker-сетей. Это нужно, чтобы ip-адреса контейнеров менялись на ip-адрес выходного интерфейса (или ip-адрес хоста), через который строятся маршруты в интернет. Также каждому контейнеру добавляется дефолтный gateway (шлюз) в таблицу маршрутизации, чтобы пакеты для всех адресов отправлялись через bridge
интерфейс, так как у него есть доступ к сети хоста.
Помимо этого Docker добавляет в firewall
правила, запрещающие перенаправление трафика из одной docker-сети в другую для их изоляции друг от друга.
Когда вы публикуете порт из контейнера -p <внешний порт>:<внутренний порт>
внутренний порт становится доступен на локальной сети хоста. Сейчас по дефолту docker для этого держит процесс "docker-proxy", который слушает нужные порты и перенаправляет на адрес нужного контейнера, и его внутренний порт. Это ненужное legacy, которое будет скоро полностью удалено из Docker в пользу hairpin NAT, но пока его можно только вручную отключить, добавив демону флаг --userland-proxy=false
.
Простыми словами, hairpin NAT - это процесс, когда пакет отправляется на адрес внешнего узла, но затем направляется обратно к узлу внутри той же локальной сети.
Docker также умеет: создавать оверлейную сеть, соединяя несколько docker демонов, создавать IPvlan сеть, объединяя контейнеры без использования
bridge
интерфейса и так далее. Это всё очень интересно, но в текущуюю статью это не войдет, слишком много информации.
Практическая часть
Создадим виртуальную сеть с доступом в интернет и выведем из неё в сеть хоста TCP порт 8000, который будет доступен через 127.0.0.1
и другим машинам в нашей сети (аналог docker -p <внешний порт>:<внутренний порт>
).
Большая часть объяснений будет в комментариях в кодовых блоках.
Далее по статье для модификаций Firewall и NAT я буду использовать
iptables
. Понимаю, что он уже считается устаревшим, но Docker продолжает его использовать, поэтому и я решил использовать его, а неnftables
.
Шаг #1 – Создаём network namespace
Начнём с создания netns и запуска в нём простого питоновского http-сервера.
# Создаём netns c именем "red"
ip netns add red
# Задаём loopback интерфейсу Ipv4 адрес, так как по дефолту он не задан
ip -n red addr add 127.0.0.1/8 dev lo
# Включаем loopback интерфейс
ip -n red link set lo up
# Запускаем http-сервер в рамках нового netns
ip netns exec red python3 -m http.server
Мы можем запрашивать у сервера что-либо только изнутри netns (ip netns exec red curl 127.0.0.1:8000
). Пока он полностью изолирован.
Шаг #2 – Создаём bridge интерфейс
Теперь нам нужно создать тот самый bridge
интерфейс и подключить к нему ранее созданный netns red.
Помимо этого мы также зададим ip-адреса для наших netns и bridge
вместе с указанием подсети, которая должна у них совпадать.
# Создаём bridge интерфейс с именем br0
ip link add br0 type bridge
# Задаём bridge интерфейсу ip-адрес 10.100.0.1.
# При этом также указывая, что первые 24 бита
# предназначены для идентификации сети, то есть часть "10.100.0".
ip addr add 10.100.0.1/24 dev br0
# Включаем интерфейс br0
ip link set br0 up
# Создаём пару veth интерфейсов с именами red0 и red0.br0
ip link add red0 type veth peer name red0.br0
# Подключаем один из veth интерфейсов к bridge'у и включаем его
ip link set red0.br0 master br0
ip link set red0.br0 up
# Второй veth устанавливаем для нашего red netns
ip link set red0 netns red
# Устанавливаем ему ip-адрес и включаем
ip -n red addr add 10.100.0.2/24 dev red0
ip -n red link set red0 up
Теперь с сети хоста можно пинговать netns и отправлять запросы http-серверу.
ping 10.100.0.2
curl 10.100.0.2:8000
Для эксперимета можете по аналогии создать второй netns, подключить его к bridge
и попробовать попинговать один netns из другого и наоборот.
ip netns exec blue ping 10.100.0.2
ip netns exec red ping 10.100.0.3
Вы увидете, что ping
проходит, так как оба netns подключены к одному и тому же bridge
.
Шаг #3 – Необходимые системные параметры
Чтобы всё у нас корректно работало нам нужно поменять два системных параметра:
net.ipv4.ip_forward
– ip port forwarding нужен для того, чтобы можно было перенаправлть трафик между интерфейсами.net.ipv4.conf.<interface>.route_localnet
–route_localnet=1
разрешает перенаправления трафика на локальные сети. В нашем случае нам нужно разрешить это для нашегоbridge
интерфейса.
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv4.conf.br0.route_localnet=1
Шаг #4 – Даём доступ в интернет из network namespace
Нужно добавить в таблицу NAT в iptables правило, которое применяет ко всем пакетам с исходными адресами нашей подсети bridge
действие подмены ip-адреса на ip-адрес интерфейса, через который пакеты прошли (в нашем случае на ip-адрес интерфейса eth0). Вы должны подставить вместо eth0 имя вашего основного сетевеого интерфейса с доступом в интернет.
Также через тот же iptables необходимо добавить правила, разрешающие forward трафика из нашего основного сетевого интерфейса в bridge
интерфейс и наоборот, в таблицу FILTER.
И наконец, добавить дефолтный gateway для netns, чтобы по умолчанию пакеты, адресованные во внешний интернет, шли через адрес bridge
интерфейса.
# Правило для таблицы NAT
iptables -t nat -A POSTROUTING -s 10.100.0.0/24 -o eth0 -j MASQUERADE
# Правило для таблицы FILTER (firewall)
iptables -A FORWARD -o eth0 -i br0 -j ACCEPT
iptables -A FORWARD -i eth0 -o br0 -j ACCEPT
# Для нашего netns'а задаём дефолтным gateway'ем наш bridge
ip netns exec red ip route add default via 10.100.0.1
Пробуем пропинговать внешний ip-адрес:
ip netns exec red ping 8.8.8.8
Шаг #5 – Выводим TCP порт в сеть хоста
На этом этапе мы добавим возможность стучать в наш http-сервер через http://localhost:8000
и вместе с этим сделаем возможным обращаться к нему машинкам извне, то есть другим реальным компьютерам, которые подключены к вашей приватной сети, или, если у вас напрямую подключение к публичной сети, тогда вообще для всех компьютеров в интернете.
### Правила для таблицы NAT
# Для пакетов извне (с других компьютеров) (относится к цепочке PREROUTING) добавляем правило,
# которое для всех пакетов, адресованных нашей локальной сети по tcp порту 8000,
# меняет destionation адрес на адрес нашего namespace'а.
iptables -t nat -A PREROUTING -p tcp -m addrtype --dst-type LOCAL -m tcp --dport 8000 -j DNAT --to-destination 10.100.0.2:8000
# Для пакетов, сгенерированных хостом (относится к цепочке OUTPUT), добавляем правило,
# которое для всех пакетов, адресованных нашей локальной сети по tcp порту 8000,
# меняет destionation адрес на адрес нашего namespace'а.
iptables -t nat -A OUTPUT -p tcp -m addrtype --dst-type LOCAL -m tcp --dport 8000 -j DNAT --to-destination 10.100.0.2:8000
# Добавляем действие MASQUERADE, которое применяется к пакетам,
# проходящим через интерфейс br0
# и которые были отправлены с локальной сети (то есть прошедшие через цепочку OUTPUT).
# MASQUERADE заменит их ip-адрес на ip-адрес шлюза,
# через который они покинули сеть, в нашем случае на ip-адрес br0.
iptables -t nat -A POSTROUTING -o br0 -m addrtype --src-type LOCAL -j MASQUERADE
# Добавляем действие MASQUERADE ко всем TCP пакетам
# с портом 8000, адресованным локальной сети.
# Нужно для пакетов, пришедших от других компьютеров (вышедших из цепочки PREROUTING).
iptables -t nat -A POSTROUTING -m addrtype --dst-type LOCAL -p tcp -m tcp --dport 8000 -j MASQUERADE
### Правила для таблицы FILTER (firewall)
# Разрашаем роутинг, пакетам проходящим через bridge интерфейс, которые относятся к установленным соединениям.
iptables -A FORWARD -o br0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# Разрешаем роутинг пакетам, не входящим с интерфейса bridge и адресованным этому интерфейсу по TCP порту 8000.
iptables -A FORWARD -d 10.100.0.2/32 ! -i br0 -o br0 -p tcp -m tcp --dport 8000 -j ACCEPT
# Разрешаем трафик, который приходит с bridge интерфейса
iptables -A FORWARD -i br0 -j ACCEPT
Теперь можно попробовать постучаться в наш http-сервер из сети хоста:
curl 127.0.0.1:8000
И также поотправлять в него запросы с других машин через ip-адрес хоста:
# замените ip на свой x)
curl 192.168.1.15:8000
Всё!
По итогу мы получили:
http-сервер, запущенный в изолированном сетевом пространстве.
Доступ к этому серверу по порту 8000 из loopback (localhost) интерфейса хоста.
Перенаправление пакетов от других машин по tcp порту 8000 в наш http-сервер.
Спасибо за чтение :)