Стеклянная луковица dns внутри k8s

Бесспорно, тема резолвинга dns запросов внутри k8s неоднократно поднималась на хабре и вставала ребром перед многими инженерами поддерживающими k8s кластера. Снимая, слой за слоем, попытаемся разобраться как резолвятся dns записи внутри k8s. Бонусом бегло взглянем на устройство механизма резолвинга dns для Go.

С чего все началось или просто «Unable to pull Docker image»

В предыдущей статье упомянут не совсем стандартный k8s кластер, используемый под CI/CD нужды. «Нестандартность» заключается в высокой утилизации cpu ввиду постоянных нагрузок. 

Время от времени в логах данного кластера появлялись ошибки вида »Unable to pull Docker image…». На первый взгляд ничего серьезного, однако, для одного настроенного сценария внутри данного кластера эта ошибка имела весьма критичный характер, так как требовала ручного вмешательства: используемый jenkins kubernetes плагин  выбрасывает вышеупомянутую ошибку, когда появляется проблема »Back-off pulling image» при скачивании образа необходимого для инициализации пода-пайплайна. Для минимизации ручного труда было принято решение попытаться решить данную проблему.

Технические детали данной проблемы

Что же приводит под к состоянию »Back-off pulling image»? В логах —  »dial tcp: lookup … no such host». Данная ошибка выбрасывается в dns client«е Go тогда, когда во время dns resolving«а что-то пошло не так. В нашем случае эту ошибку выбрасывает containerd. Хост registry, конечно же, существует и при повторной попытке все отлично отрабатывает.
Погрузимся глубже и попытаемся понять как вообще работает dns resolving не только на машинах, но и в контейнерах.

Слой первый — Go DNS резолвинг

Отправная точка нашего исследования — возникновение проблемы при попытках Go зарезолвить registry host. 

Go использует или собственный «Go» resolver, или же «Cgo» (больше деталей можно найти в оф. документации). Главное отличие данных реализаций состоит в том, что Go resolver построен на горутинах и использует свою логику для резолвинга, построенную на базе RFC, в то время как в Cgo осуществляется синхронный системный вызов API glibc getaddrinfo (3) в отдельном потоке. Под капотом Go заложено довольно много кода для определения какой resolver использовать в том или ином окружении. В основном этот выбор основан на возможности интерпретировать опции в файлах /etc/resolv.conf и /etc/nsswitch.conf. Если есть хоть какие-то неподдерживаемые Go resolver«ом опции, то будет использоваться Cgo resolver, если он принудительно не выключен, либо совсем не поддерживается. Об этом уже есть статья на хабре.

Если выбран «Go» resolver, то в самом стандартном варианте для разрешения static хостов используется /etc/hosts файл, затем /etc/resolv.confдля выполнения запросов к dns серверу. И всё. Хочу обратить внимание, что Go не имеет никакой внутренней логики по кэшированию dns запросов

В нашем случае containerd использует Сgo resolver, так как используемый дистрибутив Flatcar в /etc/nsswtich.conf содержит строку »hosts:  files usrfiles resolve dns».  Данная строка определяет порядок функций разрешения имени хоста

Логика обработки каждой функции определена в соответствующих kernel библиотеках вида libnss_{name}.so: libnss_files.so, libnss_resolve.so и т.д. По умолчанию, если функция возвращает успешный ответ, то остальные функции, стоящие после нее, не выполняются

В нашем случае Go resolver не был выбран, так как в конфигурации присутствуют «непонятные» для него опции «usrfiles» и «resolve». Опция «usrfiles» не представляет интереса для нас в отличии от опции «resolve», которая ведет к systemd-resolved — systemd сервису, отвечающему за разрешение сетевых имен.

Слой второй — Systemd-resolved

Итак мы находимся в точке, когда запрос на разрешение имени хоста попал к сервису systemd-resolved.

Что же предлагает systemd-resolved? Из документации можно почерпнуть, что он выполняет такие функции, как поддержание настроек в файле resolv.conf на основе данных DHCP и статической конфигурации DNS для сетевых интерфейсов, включает в себя кэширующий слой, а также поддерживает DNSSEC и LLMNR (Link Local Multicast Name Resolution), а также предоставляет 3 интерфейса для отправки запросов на разрешение имен:  

  1. Нативное, асинхронное, полнофункциональное API, реализованное поверх DBUS

  2. getAddrInfo — тот самый, который привел нас сюда

  3. Заглушка на IP-адресах 127.0.0.53 и 127.0.0.54 (DNS StubListener). Приложения, работающие напрямую с /etc/resolv.conf и посылающие DNS-запросы в обход какого-либо API, могут быть направлены на эту заглушку, чтобы эти запросы были обработаны systemd-resolved

Мы не будем рассматривать DNSSEC и LLMNR, т.к. эти функции нужны лишь немногим приложениям и в нашем случае не задействованы. Рассмотрим подробнее /etc/resolv.conf и как он связан с systemd-resolved

Файл /etc/resolv.conf можно представить как базовый API, который используется для конфигурации DNS резолвинга в различных ОС. Systemd-resolved, чтобы быть совместимым с этим API, имеет несколько режимов работы с вышеуказанным файлом. Systemd-resolved поставляет 2 сгенерированных файла /run/systemd/resolve/resolv.confи /run/systemd/resolve/stub-resolv.conf. Эти файлы не должны использоваться напрямую, а только через символическую ссылку /etc/resolv.conf, т.к. они обновляются systemd-resolved и могут содержать настройки, которые предоставляет DHCP сервер (nameserver, search и др.). В зависимости от того, как определен /etc/resolv.conf, systemd-resolved автоматически выбирает режим работы, а запросы приложений на разрешение имен, использующие этот файл для резолвинга, будут по-разному обработаны:

  • Если используется ссылка на файл /run/systemd/resolve/resolv.conf, то запросы будут попадать напрямую к DNS серверам, минуя systemd-resolved.

  • Если используется ссылка на файл /run/systemd/resolve/stub-resolv.conf, то запросы будут обрабатываться systemd-resolved. Этот режим использования рекомендуется systemd-resolved, т.к. позволяет приложениям использовать systemd-resolved через стандартный интерфейс + использовать правильные поисковые домены (search) при обращении к DNS серверу

  • Если /etc/resolv.conf является файлом, либо символической ссылкой, не относящейся к файлам systemd-resolved, то в этом случае systemd-resolved будет просто читать его для конфигурации DNS. В этом случае если nameserver не указывает на 127.0.0.53, то systemd-resolved не будет задействован при разрешении имен через /etc/resolv.conf

Как мы уже упоминали «Cgo» resolver использует только функцию getAddrInfo, которая перенаправляет запрос к systemd-resolved и никак не использует /etc/resolv.conf. «Go» resolver, напротив, использует /etc/resolv.conf. Исходя из описания systemd-resolved можно было бы просто создать /etc/resolv.conf ссылку на /run/systemd/resolve/stub-resolv.conf и получить все плюшки systemd-resolved, однако до версии go 1.20 этот файл содержал неподдерживаемые опции и go все равно переключался на «Cgo» resolver. Поэтому, как альтернатива, можно самому создавать /etc/resolv.conf и указывать nameserver  127.0.0.53. В таком случае запросы также будут направлены к systemd-resolved при использовании Go резолвера.

Кажется, механизм резолвинга dns и роль systemd-resolved в нем прояснились. Однако, становится ясно, что в контейнерах работа данного механизма отличается и возникают различные вопросы, вида:   А что использует контейнер k8s для настройки dns? Связана ли как-то настройка резолвинга на хосте с настройкой резолвинга внутри контейнера? Использует ли контейнер systemd-resolved? А если нет, то как работает кэшинг запросов?

Слой третий — контейнеры и CoreDNS

Как известно в k8s приложения могут обращаться к другим приложения в том же кластере по имени сервиса. Для разрешения таких имен используются addon k8s — dns сервер. Начиная с версии 1.12 таким addon«ом по-умолчанию стал CoreDNS (до этого был kube-dns)

Схематично и тезисно процесс резолвинга dns внутри k8s кластера выглядит следующим образом:  

  1. У k8s pod«a есть поле отвечающее за поведение dns резолвинга — dnspolicy. Подробнее можно почитать в оф. документации, мы же остановимся на 2-х важных для дальнейшего понимания значениях

    • Default — как описано в документации, под будет наследовать »name resolution configuration» хоста, на котором он запускается. Следует заметить, что здесь речь идет только о файле /etc/resolv.conf (код).  Т.е. если хочется использовать преимущества systemd-resolved, то в /etc/resolv.conf хоста нужно явно указывать nameserver 127.0.0.53

    • ClusterFirst — значение по умолчанию. Запросы на резолвинг будут отправлены в CoreDNS. В этом случае kubelet строит /etc/resolv.conf на основе resolv.conf, переданного в параметре kubelet --resolv-conf, либо дефолтного /etc/resolv.conf хоста, если параметр пуст.  Вместо nameserver будет использовано значение параметра kubelet --cluster-dns (ip адрес coredns сервиса). Значениями «options» будут дефолтные и захардкоженые. В качестве параметра «search» используются существующие домены из resolv.conf  + kubelet добавляет домены, необходимые для поиска k8s сервисов — .cluster.local, .svc.cluster.local, .{namespace}.svc.cluster.local.

    Также есть возможность переопределить /etc/resolv.conf передав настройки через поле пода »dnsConfig»

  1. CoreDNS запускается как deployment с dnsPolicy == default, чтобы использовать механизм резолвинга хоста для резолва внешних имен. Для этого deployment«a создается сервис с ClusterIP, который передается в настройку kubelet –cluster-dns для каждой k8s ноды. На сайте k8s можно найти пример описания конфигурации CoreDNS.

    • В конфиге CoreDNS описан плагин kubernetes, который перехватывает запросы к сервисам kubernetes и резолвит их 

      kubernetes cluster.local in-addr.arpa ip6.arpa{
      
            pods insecure
      
            fallthrough in-addr.arpa ip6.arpa
      
            ttl 30
      }
    • Также определяется логика для остальных запросов — в простейшем случае просто перенаправлять в стандартный резолвер — /etc/resolv.conf

        forward . /etc/resolv.conf
    • Помимо этого у CoreDNS есть другие полезные плагины: cache (кэш dns запросов), log (логирует dns запросы) и прочие

Исходя из вышеизложенной информации общая схема резолвинга dns в k8s кластере может быть представлена следующим образом:

fbd8eaf1b4b32300609d2bed683fd720.png

Порой возникает проблема проверки резолва внутреннего dns из какого-либо пода, т.к. приложение возвращает условный »no such host» при резолве какого-либо сервиса при том, что сервис рабочий и k8s endpoint«ы имеют ip адреса. Здесь нужно понимать как сервис резолвит имя сервиса. Использует ли просто /etc/resolv.conf? Или может вызывает «getaddrinfo» за ковром? И если это второй вариант, то для объективного тестирования простой команды dig или nslookup не достаточно. Можно воспользоваться getent, чтобы воспроизвести по максимуму приближенный вариант разрешения имени, а лучше всего попробовать протестировать приложением, написанным на том же языке

Итог

Пройдя путь от ошибки Kuberentes Jenkins плагина до исходного кода Go и принципа systemd resolvera мы смогли разобраться в полном flow резолвинга dns как в контейнерной среде, так и в Unix системах, выяснили как работает dns резолвинг в Go и на собственном примере установили случаи включения того или иного резолвера.

Что же касается изначальной проблемы, побудившей меня провести данное исследование, то она была решена посредством принудительного включения Go резолвера. Почему? Была найдена issue, связанная с systemd-resolved, которая возникала при работе с некоторыми DNS серверами. На самом деле это даже не проблема systemd-resolved, а некорректное поведение самого DNS сервера, которое удалось в итоге воспроизвести и подтвердить эту проблему. В таком случае почему бы просто не выключить systemd-resolved? Ответ — хотелось выбрать самый безболезненный способ, позволяющий не терять преимущества кэширования DNS для всех остальных приложений внутри k8s кластера, которым нужна возможность резолвинга.

Признаюсь, мне впервые пришлось так глубоко погрузиться в механизм реализации резолвинга dns внутри k8s —  буду рад обсуждениям в комментариях.

© Habrahabr.ru