
Ошибки в работе компонент DNS могут приводить к инцидентам: нагрузка за секунды возрастает на порядки, а с учётом политики обработки ошибок timeout и retry на клиентах — быстро приобретает лавинообразный характер. Такое случалось в истории эксплуатации нашего внутреннего рекурсивного контура DNS. Отказы в обслуживании создавались небольшим числом внешних запросов, которые обрабатывались параллельно с внутренними. Но в результате их расследования нам удалось преодолеть фундаментальные ограничения функционирования DNS‑сервисов.
Меня зовут Олег Горохов, в Yandex Infrastructure я работаю в команде управления трафиком. В этой статье поделюсь, как мы оптимизировали процессы обработки в задаче классификации и изоляции DNS‑запросов — и в итоге исключили влияние внешних запросов на внутренние благодаря перераспределению ресурсов. 2 мкс — ровно столько сейчас требуется выбранному методу для ответа на запросы в прод‑контуре. Это на три порядка быстрее, если сравнивать с традиционными ответами обычных DNS‑серверов.
Реализованные оптимизации стали возможными при использовании BPF‑программ в разных сегментах, в частности, BPF socket selection reuseport. Для полноценной работы требуются контроллеры, обеспечивающие runtime‑конфигурирование, мониторинг, менеджмент, доставку и синхронизацию внешних данных — об этом тоже немного поговорим.
Внутренние и внешние запросы
Итак, внешние DNS‑запросы, которые обрабатываются долго и непредсказуемо, могут влиять на остальные запросы. Формально для этой задачи мы оперируем классами запросов UDP DNS:
-
Внутренние — ответ для них существует на рекурсивном контуре (в одном из кешей) или в худшем случае терминируется на наших же авторитетах. Например, это запрос для ресолва
yandex.ruилиfb-4oxozf2sz22atvfg.sas.yp-c.yandex.net. Для таких запросов мы хотим гарантировать максимальную доступность сервиса.Внутренние данные в инфраструктуре находятся в репликах DNS‑серверов и для ответов на них хождение по сети не осуществляется. Тем не менее, при обработке тяжёлых внешних запросов (CPU, sockets) потребление доступных ресурсов приводит к отказу в обслуживании для всех остальных запросов. Это происходит уже на стадии приёма пакета на начальном этапе, при получении данных из сокета.
-
Внешние — ответы для таких запросов требуют рекурсивного обхода авторитетных серверов и апстримов вне наших сетей, доступность которых не гарантируется. Например, запросы на ресолв
google.comилиvk.com.Источники ответов на такие запросы могут быть недоступны, могут отвечать с недетерминированной задержкой и в общем случае потребляют значительные ресурсы DNS‑сервера при их обработке.
Особенностью процессинга внешних запросов с негарантированным временем ответов является исчерпание ресурсов процесса на ожидание, менеджмент состояний, потоками процесса, выполняющими обработку в сценариях недоступности DNS‑апстримов, медленных ответов. А поскольку пул объектов DNS‑сервера один и тот же и распределяется тредами как для внутренних, так и для внешних запросов, то отказ в обслуживании распространяется на все запросы.
Посмотрим на графики мониторинга во время подобного инцидента с отказом в обслуживании при процессинге внешних запросов:

Видно что утилизация CPU всех ядер DNS‑контейнера практически моментально стала 100%. В отчёте по инциденту указано, что даже при небольшой доле внешних запросов, порядка 1–5%, можно наблюдать отказ в обслуживании.
Для решения задачи мы подтвердили воспроизводимость проблемы отказа. В нашем эксперименте поучаствовало два танка, с номинальными внутренними запросами, и с внешними, рейт которых постепенно рос таким образом, чтобы получить точку разлома.

На этом графике приведён базовый эксперимент для четырёх потоков DNS‑сервера, относительно которого мы будем искать влияние внешних запросов. Видно, что точка разлома для этой конфигурации находится в районе 125K rps. Далее постепенно добавим внешние запросы, полученные из продового трафика. Для большинства этих запросов нет ответов: либо авторитеты не отвечают за выделенный timeout, либо сами недоступны.
В следующем эксперименте мы добавили к базовой нагрузке из второго танка всего 6K rps — но потенциально таких, с которыми DNS‑сервер плохо справляется.

Это повлияло не только на ответы на внешние недоступные для ответа запросы, но и вообще на весь DNS‑сервер целиком, на его способность отвечать.
Можем видеть: DNS‑сервер справляется с такими сценариями плохо, здесь требуется классификация и изоляция обработки запросов. При отказе и невозможности ответов для внешних запросов, внутренний поток ответов страдать не должен.
Подходы к решению
Первоначальный план решения включал в себя исследование поведения самого DNS‑сервера (мы используем unbound) и выделение проблемных участков процессинга.
Достаточно быстро стало понятно, что без значительных изменений в коде не обойтись, поэтому мой коллега @tailer провёл серьёзное исследование и рефакторинг особо страдающих кусков кода. Добавляя новые структуры данных и разделяя треды и объекты по классам, мы надеялись изолировать процессинг разных запросов внутри кода DNS‑сервера. Но это не удалось. Фактически вывод был неутешительным — модель обработки DNS‑пакетов в unbound не позволяет провести изоляцию различных классов запросов.
Второй подход был основан на результатах первого: мы выдвинули гипотезу, что классификацию и изоляцию следует делать до процессинга пакетов DNS‑сервером. Очевидно, если после классификации запросов разные классы обрабатываются разными серверами на непересекающихся подмножествах ресурсов CPU, то задача имеет решение. Однако это означает введение дополнительных контуров и управление ими.
Для начала вот список тех мест, где можно сделать такую классификацию и принять решение о судьбе пакета:
-
приложение на стороне клиента;
-
сокет клиентского приложения, отправляющий DNS‑пакет;
-
промежуточный (кеширующий, рекурсивный) DNS‑сервер;
-
балансер, обслуживающий VS рекурсивного контура;
-
сокет DNS‑сервера;
-
DNS‑сервер, терминирующий рекурсивный запрос.
По ряду причин мы сделали выбор в пользу механики, которая позволяет выбирать LISTEN‑сокет приложения DNS‑сервера для каждого пакета, инициирующего соединение. За этот выбор будет отвечать BPF‑программа, код которой исполняется в нужном месте так, что все DNS‑пакеты, для которых нужен процесс принятия сокетом, прошли через него. Это секция sk_reuseport.
Идея в том, чтобы классифицировать DNS‑пакет по его контенту и делать для него сопоставление сокетов, разбитых по группам соответствующего класса:
-
все внешние QNAME в пакетах относить к первой группе,
-
все внутренние — ко второй,
-
и так далее.
Разумеется, группы сокетов не должны пересекаться, а каждый тред должен иметь однозначное соответствие как с группой принадлежащих ему сокетов, так и с ядрами CPU, на которых он выполняется.
В итоге для этой задачи накопилось достаточно большое количество предположений, в рамках которых она может быть решена. Кроме того, был до конца не ясен вопрос: есть ли интерференция между потоками DNS‑сервера, даже при такой изоляции DNS‑запросов на входе в процесс.
Поэтому сначала давайте посмотрим, как устроена обработка пакетов, как можно реализовать их классификацию и что может нам помочь в этом, кроме BPF‑программы.
Обработка DNS-пакета
Рассмотрим, как схематично устроена обработка DNS‑пакетов на этапе от буфера сетевой карты до сокета приложения DNS‑сервера. Нас интересует именно этот участок, так как мы хотим изменить порядок назначения сокетов приложения пакетам, поступающих в систему.

Справа от схемы я указал некоторые идеи, которые реализуют наше решение. Основным этапом является загрузка BPF‑программы и её привязка ко всем открытым LISTEN‑сокетам процесса DNS‑сервера. Так как сегмент инсталляции включает sk_reuseport, каждый пакет, инициирующий соединение, проходит через нашу программу. К счастью для нас, для UDP это как раз все пакеты, для TCP — только SYN. Предположим, что доля TCP бесконечна мала, и будем обрабатывать весь трафик DNS TCP как отдельный класс со своим пулом потоков в DNS‑сервере. UDP‑пакеты мы предполагаем парсить и классифицировать на внешние и внутренние запросы по полю QNAME.
Классификация и изоляция
Итак, основная наша задача — изолировать потенциально плохой паттерн нагрузки от всех внутренних запросов. Чтобы уточнить, что такое плохой паттерн, мы ограничились внешними запросами, которые просто классифицировать по суффиксам QNAME DNS‑запросов.

BPF‑программа в SEC(sk_reuseport) позволяет нам заменить традиционный алгоритм выбора сокета для пакета, инициирующего соединение, на нужный нам:
-
для внешних запросов мы равномерно выбираем сокет из socket map, в котором сгруппированы сокеты приложения для тредов, выделенных нам для внешнего DNS‑трафика. В крайнем случае, когда внешние запросы начали пожирать ресурсы, мы готовы ими пожертвовать, при этом мы также жертвуем тредами, выделенными специально для этого.
-
для внутренних запросов DNS UDP мы соответственно выбираем пул сокетов для тредов внутренней обработки. Мы не готовы ими жертвовать, даже если внешние запросы сойдут с ума и захотят принести нам страдание. Рассчитываем, что внешние запросы в случае проблем с их обработкой поставят в полку треды и, возможно, соответствующие CPU, но внутренние под эту деградацию не попадут
-
для всех ошибочных ситуаций, непонятных кодов ответов, отсутствия сокетов в нужных пулах, некорректных сокетов и так далее
sk_reuseportделает fallback на традиционное распределение сокетов приложения между поступающими пакетами. Это нас спасёт, когда что‑то идёт кардинально неправильно. Кроме того, пакет должен соответствовать параметрам, с которым сокет создан, (dstip, dstport, proto). Если наше сопоставление не удовлетворяет этому правилу, система снова сделает fallback.
Контроллер
Реализация идей и поиск решений не всегда проходили гладко. На этом пути мы последовательно пробовали разные варианты:
-
Код управления непосредственно в кодовой базе DNS‑сервера unbound. Налог на поддержание патчей к DNS‑серверу сделал этот вариант менее приоритетным.
-
Контроллер на С.
-
Контроллер управления N процессами DNS‑сервера.
-
Наконец, контроллер на GO. Тут наши желания совпали с возможностями языка программирования, и в итоге окончательный вариант нашёл свою реализацию.
Основные функции контроллера:
-
Наблюдение за состоянием процесса DNS‑сервера. Нам нужно получать наборы открытых сокетов, их дескрипторы, распределение этих сокетов по тредам, affinity потоков.
-
Конфигурация и менеджмент BPF‑программ. В нашем случае кроме непосредственно инициализации программы, используется функция
pidfd_getfd(). Она нужна чтобы получать дубликаты fd открытых сокетов и аттачить к ним BPF‑программы. Контроллеру приходится постоянно следить за актуальностью runtime socket reuse array BPF‑программы, а также переживать рестарты процессов DNS‑серверов. Функция доступна начиная с ядра 5.6, в нашем случае используется 5.15. -
Отдельный поток занимается синхронизацией CPU affinity и использует
sched_setaffinity(). Контроллер выбирает по возможности не пересекающиеся affinity для потоков наблюдаемого процесса DNS‑сервера. Для RTC требуются соответствующие capability CAP_SYS_NICE. -
Мониторинг, сбор метрик. В некоторых случаях контроллер по значениям метрик, вышедших за границы интервала нормальных значений, может перевести работу BPF‑программы в dry run, тем самым снять возможный импакт от некорректного состояния конфигураций или полученных данных.
CPU affinity потоков DNS-процесса
Одно из необходимых условий изоляции — каждый тред должен быть привязан к единственному определённому логикой изоляции ядру CPU. Целевая ситуация, к которой контроллер пытается приводить наблюдаемое распределение:
threads:'32' as index:'04' '0000100000000000000000000000000000000000000000000000000000000000' threads:'32' as index:'05' '0000010000000000000000000000000000000000000000000000000000000000' threads:'32' as index:'06' '0000001000000000000000000000000000000000000000000000000000000000' threads:'32' as index:'07' '0000000100000000000000000000000000000000000000000000000000000000' threads:'32' as index:'08' '0000000010000000000000000000000000000000000000000000000000000000' threads:'32' as index:'09' '0000000001000000000000000000000000000000000000000000000000000000' threads:'32' as index:'10' '0000000000100000000000000000000000000000000000000000000000000000'threads:'32' as index:'11' '0000000000010000000000000000000000000000000000000000000000000000' threads:'32' as index:'12' '0000000000001000000000000000000000000000000000000000000000000000' threads:'32' as index:'13' '0000000000000100000000000000000000000000000000000000000000000000' ...
Не на все ядра могут быть поставлены affinity — на сам процесс могут быть наложены ограничения cgroup cpu_set. Важно чтобы потоки разных классов не пересекались по ядрам.
Результаты
Для подтверждения результата ещё раз выполним эксперимент, который мы проводили вначале. Однако в этот раз включим контроллер bpf socket selection.
-
Добавляем один тред внешней нагрузки с изоляцией 3:1. Все треды работают под affinity. Видно, что мы практически достигли посчитанный ранее baseline, при этом в четвёртом треде внешняя нагрузка давно съела все ресурсы, что видно на графике:

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

В рамках задачи полученная механика была инсталлирована на все продакшн‑контейнеры рекурсивного контура DNS. Начиная с этого момента, внешний и внутренний поток DNS‑трафика были изолированы и не влияли друг на друга. Изоляцию также можно наблюдать на графиках blackbox‑мониторинга, который теперь также разделился на несколько представлений для наблюдения за разными классами запросов:

Ещё одним косвенным подтверждением могут являться графики времени ответов, собранные для внешних и внутренних запросов.

Собранные метрики программы BPF показывают распределение запросов по классам. Мы видим что подавляющее число запросов — внутренний UDP. Есть немного TCP, остальные запросы — внешние. Несмотря на то, что их на порядки меньше, чем внутренних запросов, нельзя недооценивать эффект влияния их обработки DNS‑сервером на все остальные запросы.

Верхние графики убеждают нас в том, что внешние запросы сгруппированы в одной группе потоков DNS‑сервера: среднее количество запросов в очередях велико, время их обработки тоже. На нижнем графике очереди практически пустые, и есть лишь небольшое количество рекурсивных внутренних запросов.
Выводы
Контроллер распределения сокетов работает на DNS‑контурах уже довольно давно и приносит нам радость. Можно подвести некоторые итоги, сделать выводы:
-
Задача классификации, изоляции трафика, в том числе запросов DNS UDP, может быть эффективно решена BPF‑программами. При этом, для упрощения поддержки таких решений и их эксплуатации, можно использовать внешний по отношению к процессу DNS‑сервера контроллер. В последних ядрах появились новые возможности функций pidfd_*() для отчуждения управления BPF‑программами сокетов наблюдаемых процессов — с ними это гораздо проще.
-
Контроллеры управления BPF‑программами играют важную роль в доставке конфигураций, мониторинге, менеджменте. В этой задаче пришлось добавить много эвристики для группировки сокетов наблюдаемого процесса, привязке их к потокам, управлению affinity. Не всегда возможно провернуть такой фокус, но нам повезло, что мы знаем алгоритм создания сокетов DNS‑сервером.
-
BPF позволяет решать подобные задачи и даже более сложные, начиная непосредственно участвовать в процессинге запросов. Однако необходимость создания различных механизаций: контроллеров, мониторингов, различных режимов работы — усложняют всю систему целиком, добавляя в неё детали, а вместе с этим новые точки отказа.
Но это ещё не вся история. Даже, несмотря на то что DNS в целом неплохо линейно масштабируется, запас capacity, требующийся для обработки внутреннего спонтанного трафика в ряде сценариев, может превышать десятки Mrps. Держать запас из сотен традиционных DNS‑серверов, хоть как‑то заметно использующихся в единичных случаях, не самое выгодное распределение ресурсов. Альтернатива расширению серверных мощностей — увеличение на порядок количества обрабатываемых запросов на ядро CPU. Как именно мы решали задачи кардинального увеличения capacity DNS‑контуров, стоит рассказать в отдельной статье.
ссылка на оригинал статьи https://habr.com/ru/articles/1056358/