Перейти к основному содержимому

Создаём виртуальную сеть, как это делает Docker

· 10 мин. чтения
Иван Давыдов

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

NGINX

к сведению

Ссылка на статью на хабре – https://habr.com/ru/articles/794262/

Небольшая теоретическая часть

Для того, чтобы понимать, что будет происходить дальше в статье, надо немного окунуться в теорию. Если вы уже работали с настройкой сетей в 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'у, компьютер сможет "говорить" принтеру, что и в каком количестве надо распечатать :).

Switch

Firewall

Firewall используется для фильтрации трафика, как входящего, так и исходящего. Он может иметь вид физического устройства или виртуальной логической реализации. Мы будем использовать второе для дополнительной изоляции сетевых namespace'ов.

NAT

NAT или Network Address Translation – это технология преобразования внутренних ip-адресов во внешний, и наоброт. Например, когда вы подключены к одному Wi-Fi с телефона и компьютера, на обоих устройствах будет единый внешний ip-адрес. NAT будет менять у входящего трафика ip-адрес роутера на внтуренний ip-адрес устройста, которому адресованы пакеты. И наоборот, ip-адрес у исходящих пакетов будет изменён на ip-адрес роутера.

Нужно всё это для экономии публичных ip-адресов, так как их количество сильно ограничено (около 4.3 миллиардов, из которых 81% уже назначены или зарезервированы).

Что делает Docker?

how-docker-workds.png

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 – Необходимые системные параметры

Чтобы всё у нас корректно работало нам нужно поменять два системных параметра:

  1. net.ipv4.ip_forward – ip port forwarding нужен для того, чтобы можно было перенаправлть трафик между интерфейсами.
  2. net.ipv4.conf.<interface>.route_localnetroute_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

Всё!

End

По итогу мы получили:

  1. http-сервер, запущенный в изолированном сетевом пространстве.
  2. Доступ к этому серверу по порту 8000 из loopback (localhost) интерфейса хоста.
  3. Перенаправление пакетов от других машин по tcp порту 8000 в наш http-сервер.

Спасибо за чтение :)