Pull to refresh

Docker + IPv6 = ❦

Reading time7 min
Views18K

Немного текста про поддержку IPv6 в докере и ещё кой-какие нюансы docker networking.


IPv4


Для разминки рассмотрим обычную IPv4-only систему. На хост-машине есть интерфейс eth0. К этому интерфейсу привязан внешний IP-адрес. Ещё есть loopback интерфейс. Когда на такую машину мы устанавливаем docker, он создаёт себе дефолтную сеть с названием bridge. Для этой сети на хост-машине создается еще один интерфейс docker0. У него тоже появляется ip адрес, например, 172.17.0.1. Когда мы запускаем контейнер, докер выделяет контейнеру адрес из выбранной сети (bridge по умолчанию). Например, 172.17.0.5. Внутри контейнера появляется интерфейс eth0 и на нём адрес 172.17.0.5. Итак, с адресами базово разобрались. Теперь попробуем понять, как процесс внутри контейнера может обращаться к внешним ресурсам и как сделать так, чтобы можно было снаружи сходить в контейнер. Если процесс в контейнере хочет сходить по сети во внешний интернет, ему нужны: адрес, настройки маршрутов и сконфигурированный DNS-резолвер. Адрес у нас уже есть, маршруты прокладываются докером автоматически (до внутреннего eth0 напрямую, а далее через iptables), а DNS докер подкладывает из файла /etc/resolv.conf хост-машины (при этом оттуда убираются строчки, ведущие на localhost, по очевидным причинам). С DNS есть один нюанс: если использовать не дефолтную сеть, а создать свою командой docker network create, то при запуске контейнеров в этой сети в конфиг /etc/resolv.conf будет добавлен еще один DNS-сервер 127.0.0.11. Этот особенный DNS-сервер называется embedded DNS server, и его нельзя отключить никак, кроме ручного удаления в entrypoint контейнера. Эта особенность мало влияет на работу в IPv4- only окружениях, но в IPv6 может стать проблемой. Вернёмся к этому вопросу, когда будем обсуждать IPv6. Итак, у нас всё есть для того, чтобы траффик успешно достигал внешних сетей. Если же мы хотим, чтобы процесс в контейнере слушал какой-то порт, и был доступен извне, то мы должны настроить процесс так, чтобы он слушал выданный ему интерфейс, а сам контейнер запускать с флагом -p. При этом докер запускает на хост-машине отдельный процесс docker-proxy (так называемый userland proxy), который, в свою очередь, байндится ко всем интерфейсам хост-машины и проксирует трафик в указанный контейнер.


Кстати, адреса, выдаваемые контейнерам, доступны для других контейнеров напрямую (если не рассматривать экзотические топологии). Например, если один контейнер имеет адрес 172.17.0.5 и слушает порт 80, то мы можем обращаться к нему по этому адресу как с хост-машины, так и из любого другого контейнера. Запускать контейнер с флагом -p 80:80 для этого не нужно. Этот флажок предназначен уже для того, чтобы порт стал доступен и на внешних интерфейсах хост-машины.


IPv6


Теперь представим, что мы должны научить наше приложение обращаться к IPv6-хостам. Допустим, что наша хост-машина теперь помимо IPv4- адреса на eth0 получила и IPv6-адрес (глобально маршрутизируемый, link-local адреса не в счёт). Также у нас вместо IPv4-DNS сервера теперь имеется IPv6-сервер (было бы странно иметь DNS-сервер, доступный по IPv4, который умеет отвечать на AAAA-запросы). Если мы захотим, чтобы наши контейнеры тоже могли ходить по IPv6, нам нужно:


  1. Выбрать метод раздачи IPv6-адресов для контейнеров
  2. Создать кастомную сеть или настроить дефолтную сеть bridge
  3. Убедиться, что dns сервер, прописанный в /etc/resolv.conf контейнера, умеет отвечать на AAAA-запросы

DNS стоит крайним пунктом, потому что сначала нужно научиться ходить по IP-адресам, а потом уже добавлять поддержку DNS


Базово есть следующие варианты для раздачи IP-адресов (кроме --net=host, который мы не будем рассматривать, там и так всё должно работать):


1) Реальная подсеть от провайдера размера /80 и больше


Если ваш провайдер выделяет вам настоящую /64-подсеть (или хотя бы /80), это самый простой вариант. Скорее всего, всё заработает сразу после того, как вы добавите опции --ipv6 --fixed-cidr-v6={{ ваша сеть }} в конфиг. Может ещё понадобиться установить несколько sysctl опций:


net.ipv6.conf.all.forwarding=1
net.ipv6.conf.default.forwarding=1
net.ipv6.conf.eth0.accept_ra=2

2) NDP proxying


Если в вашем распоряжении нет достаточно большой подсети, а есть, к примеру, только 16 адресов (/124), то можно воспользоваться NDP проксированием. При этом адреса контейнерам придётся указывать самостоятельно (либо явно с использованием кастомной сети, либо неявно в дефолтной, используя тот факт, что адреса докер выдаёт предсказуемо, на базе MAC-адреса, а MAC-адрес можно указать руками). Ещё придётся добавить каждый такой адрес в список neighbors и включить несколько флагов ядра. Этот вариант неудобен из-за большого количества ручной работы. Подробнее про этот способ можно почитать в документации.


3) IPv6 NAT


Если же провайдер выделяет только один адрес (или не хочется возиться с NDP), то можно взять и настроить NAT. При этом в качестве подсети можно взять любую ULA-подсеть (https://en.wikipedia.org/wiki/Unique_local_address) (аналог 10.0.0.0/8 в IPv4) и настроить ip6tables. Добрые люди сделали контейнер (https://github.com/robbertkl/docker-ipv6nat), который достаточно просто запустить, и всё заработает: он будет автоматически редактировать правила ip6tables при создании новых контейнеров. В принципе, этот вариант кажется предпочтительным, потому что в этом случае вы никак не зависите от поведения провайдера (или IaaS), тем самым нивелируя риски по переходу к другому поставщику услуг. Для функционирования IPv6 в этом режиме достаточно лишь прописать подсеть в конфиг (или добавить свою новую сеть) и запустить маленький контейнер. Всё остальное сделает он сам.


Далее нужно настроить сеть выбранным способом и проверить работоспособность собранной схемы.


DNS


Итак, осталось рассмотреть DNS. Если мы решили использовать дефолтную сеть, с DNS проблем возникнуть не должно. Но если мы взяли кастомную сеть, то здесь есть одна особенность. Дело в том, что докер при создании контейнера в кастомной сети всегда добавляет свой встроенный DNS сервер. Помните, тот, который 127.0.0.11? А он не в силах разрешить ipv6-only домены. Скорее всего, это баг. А еще прикол в том, что его нельзя отключить! Единственный способ избавиться от него — руками выкорчевать строчку из resolv.conf перед запуском приложения:


RESOLV_CONF=$(sed 's/nameserver 127.0.0.11//g' /etc/resolv.conf)
echo "$RESOLV_CONF" > /etc/resolv.conf

(здесь намеренно не использовалась опция inplace — попробуйте сделать то же самое с sed -i и вы поймёте, почему)


После этого наш контейнер снова может успешно резолвить доменные имена, т.к. конфигурация скопирована с хост-машины.


Далее нужно разобраться, как правильно слушать сокеты в приложении, если хотите открыть порт для подключений снаружи и что делать, если приложение написано криво и не поддерживает ipv6.


Открытие портов наружу


Если всё (и хост машина, и докер) IPv4-only, проблем вроде бы возникнуть не должно. Просто приложение должно уметь байндиться на 0.0.0.0. Если приложение умеет слушать только на локалхосте, то тут спасёт только кастомная tcp proxy, запущенная в том же контейнере. Она будет слушать на всех интерфейсах, а транслировать данные в локалхост. socat вполне должен подойти для этой задачи.


Если мы имеем дело с IPv6, то в идеале нужно, чтобы программа создавала слушающий сокет, привязывая его к :: и без флага SO_IPV6_ONLY. Такой сокет будет одновременно слушать и ::, и 0.0.0.0. Далее все зависит от множества факторов:


  • есть ли dual stack на хост-машине, или же она IPv6-only
  • запускается ли userland proxy, или же docker демон запущен с флагом --userland-proxy=false
  • какой из механизмов поддержки IPv6 выбран: реальная /64-подсеть, NDP-проксирование или IPv6 NAT, или какая-то другая кастомная топология

В принципе, особой разницы между dual-стеком и IPv6-only стеком нет. Внутри всё равно будет dual stack так как докер использует в своей основе IPv4, а IPv6 рассматривает только как дополнение. Единственное, что нужно будет сделать дополнительно, – проверить, что порт доступен снаружи по обоим протоколам.


С userland proxy чуть сложнее. Сначала надо определиться: использовать ли дефолтный настройки (userland proxy включен) или отключить его напрочь. Действительно, зачем он нужен, если публикацию портов и forwarding трафика можно настроить целиком в iptables? И действительно, есть такая возможность. Можно добавить опцию --userland-proxy=false в конфигурацию docker engine. Но здесь есть несколько нюансов. Во-первых, через iptables можно затереть текущие открытые порты. Например, какое-то приложение слушало порт 80, потом пришёл докер и попросил добавить правило форвардить пакеты для этого порта в контейнер. Фаервол ответил "ОК" и после этого траффик перестал приходить первому приложению. docker-proxy в такой ситуации просто не запустился бы, сообщив о том, что порт занят. Ещё iptables не позволяет форвардить трафик c loopback-интерфейса, то есть по адресам 127.0.0.1 и ::1 на хост-машине ваш контейнер доступен не будет. Для первого случая наверное можно накодить проверки, а локалхост в принципе не очень-то и нужен. Но самая большая проблема в том, что --userland-proxy=false пока не готов для продакшена. Вот тут описано, как включение этой опции приводит к серьёзным проблемам. И судя по всему, в ближайшее время это вряд ли будет исправлено. Поэтому всё-таки лучше пока не выключать userland proxy, даже несмотря на то, что его использование приводит к дополнительному расходу памяти.


С использованием userland proxy в dual-стеке нужно помнить ещё и о том, что хоть слушает он порт на всех интерфейсах, пробрасывать траффик он может только на один IP-адрес (он ведь не балансер). Какой из них будет выбран, если у контейнера их два: IPv4 и IPv6? В исходниках видно, что IP-адрес выбирается один:
https://github.com/docker/libnetwork/blob/master/portmapper/mapper.go#L96
Но какой из них действительно будет выбран? Экспериментально выяснено, что всегда выбирается IPv4-адрес. Для точного ответа нужно будет исследовать код


https://github.com/docker/libnetwork/blob/master/drivers/bridge/bridge.go


В любом случае (возвращаясь к базовой рекомендации) в идеале приложение нужно научить слушать все интерфейсы. В любом другом случае есть риски. Например, если используется IPv6 NAT + userland proxy, приложение слушает только IPv4, а хост-машина с dual-стеком, то по при попытке подключиться к нему извне по IPv4 всё будет ок, а вот по IPv6 работать не будет. Почему? Потому что входящие IPv4-пакеты проксируются через docker-proxy в IPv4-адрес контейнера, а IPv6-пакеты транслируются в IPv6 адрес контейнера посредством ip6tables правилами, созданными IPv6 NAT. То есть IPv4-соединения легально идут через userland proxy, а IPv6-трафик напрямую в контейнер! Это легко проверить, запустив в контейнере одновременно python -m SimpleHTTPServer 8000 и какой-нибудь nc6 -6 -l -p 8000 --continuous --exec cat. Два процесса слушают один порт, но привязаны к разным IP: '0.0.0.0' и '::'. telnet к ним снаружи будет работать независимо: IPv6 будет вести в IPv6, IPv4 в IPv4. Если после этого найти процесс docker-proxy, обслуживающий этот контейнер и убить его, то по IPv4 соединения больше приниматься не будут, а IPv6 будет продолжать работать, как ни в чём не бывало.


Заключение


  1. Используйте IPv6 NAT. Это быстро, удобно и наименее рискованно
  2. Заставьте приложение слушать на всех интерфейсах. Это позволит открыть порт наружу с минимальными рисками обнаружить, что что-то не работает
  3. Если вы используете кастомные сети (docker network create), добавьте в скрипты запуска ваших приложений код, который бы удалял embedded DNS server из /etc/resolv.conf. Это позволит не зафейлиться при работе с AAAA-only доменами
  4. Не отказывайтесь от userland proxy. Если нужен большой диапазон портов, лучше попробовать использовать --net=host

В целом, поддержка IPv6 в докере выглядит не до конца готовой. Однако, уже есть наработки, позволяющие вполне сносно жить. Поэтому берите и пользуйтесь, новая эра не за горами.

Only registered users can participate in poll. Log in, please.
Как часто вы сталкиваетесь с необходимостью поддержки IPv6?
20.97% Каждое моё приложение поддерживает IPv613
20.97% Иногда бывают проблемы, связанные с IPv6, чиню их, но в основном использую IPv413
58.06% Всё делаю и проверяю только на IPv436
62 users voted. 18 users abstained.
Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments7

Articles