Немного текста про поддержку 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, нам нужно:
- Выбрать метод раздачи IPv6-адресов для контейнеров
- Создать кастомную сеть или настроить дефолтную сеть bridge
- Убедиться, что 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 будет продолжать работать, как ни в чём не бывало.
Заключение
- Используйте IPv6 NAT. Это быстро, удобно и наименее рискованно
- Заставьте приложение слушать на всех интерфейсах. Это позволит открыть порт наружу с минимальными рисками обнаружить, что что-то не работает
- Если вы используете кастомные сети (docker network create), добавьте в скрипты запуска ваших приложений код, который бы удалял embedded DNS server из /etc/resolv.conf. Это позволит не зафейлиться при работе с AAAA-only доменами
- Не отказывайтесь от userland proxy. Если нужен большой диапазон портов, лучше попробовать использовать
--net=host
В целом, поддержка IPv6 в докере выглядит не до конца готовой. Однако, уже есть наработки, позволяющие вполне сносно жить. Поэтому берите и пользуйтесь, новая эра не за горами.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (3)
Livid
07.08.2017 23:00Раз уж речь зашла про docker networking, когда уже наконец можно будет сделать dns в кастомной сети без iptables? Ибо у меня на хосте iptables нет, бо писать на нем (по сравнению с nftables) правила для хоста с несколькими линками (в том числе несколько vpn) плюс докером плюс lxc — это смерти подобно, а вместе они (iptables и nftables) не работают. Писать в /etc/resolv.conf из entrypoint прошу не предлагать, это и так очевидный хак. Городить на хосте гейт в ipv4 с ipv6 тоже как-то не очень хочется.
elw00d
08.08.2017 23:02Из комментария не смог понять, какую задачу вы решаете :) вам нужен свой DNS-сервер именно для докера? а зачем?
VolCh
Нет варианта: «отключаю IPv6, когда сталкиваюсь с проблемами из-за его наличия». Собственно последние пару лет проблемы с IPv6 у меня связаны только с docker.