
Коллеги, всем привет!
Долгое время в нашей внутренней сети для обработки DNS-трафика мы использовали только BIND, и нам с ним было хорошо. Но в какой-то момент его возможностей перестало хватать. В статье расскажу, что именно с BIND не так и почему теперь весь DNS-трафик у нас проходит через DNSdist. И что это вообще такое...
Жизнь с BIND

Прежде чем говорить о DNSdist, нужно знать, почему мы вообще начали в его сторону думать. А для этого необходимо понимать, как была устроена наша DNS-инфраструктура до его прихода. Итак, начнём.
Мы живём в трех разных облаках. В каждом из них у нас поднято по два DNS-сервера BIND, которые реплицируются от «мастера», который также поднят в одном из этих облаков. Все сервера хранят одни и те же зоны, одну и ту же информацию.
Клиентами этих серверов у нас выступают все наши виртуальные машины, АРМ и K8S кластера. Для отказоустойчивости на всех ВМ мы указываем DNS-сервера из всех облаков. Например: ВМ из облака А будет в /etc/resolv.conf иметь DNS-сервера из облаков A, B и C. Никакого DNS-клиента (типа systemd-resolved) на этих ВМ не установлено (к сожалению), поэтому DNS-запросы летят вразнобой. Это станет нашей проблемой номер ноль.
В конфигурации самих BIND'ов у нас, помимо наших зон, в которые мы записываем адреса виртуальных машин, сервисов, балансиров и так далее, есть зоны, которые, по сути, обслуживаются не нами, а, к примеру, провайдерами облачных сервисов, которые мы используем.
zone zona IN { type forward; forward only; forwarders { 172.17.20.22; 172.17.20.23;}; };
Тут у нас появляется одна, но серьёзная проблема – умершие апстримы не выпадают из балансировки. Это значит, что если один из серверов, который мы указали в списке forwarders, упадет, BIND продолжит пытаться слать ему запросы. Это нам не нравится, клиенты начинают получать лишние timeout'ы, а лучше бы не получали.
Помимо этого, у некоторых апстримов есть специфичная особенность – они по-хорошему должны обслуживать только DNS-запросы тех клиентов, которые находятся с ними в одном и том же облаке. К таким апстримам у нас, например, относятся Consul (решение от компании HashiCorp) сервера, которые возвращают клиенту адреса живых инстансов запрашиваемого сервиса. Всё, что нам нужно, это, по сути, редиректить DNS-запрос на нужный DNS-сервер (который указан в forwarders) в зависимости от ip-адреса клиента и при этом иметь возможность указать fallback апстримы. К сожалению, распределение запросов по форвардам, основываясь на source ip в BIND, невозможно, и это наша последняя проблема.
Выход на сцену DNSdist
Теперь давайте поговорим наконец-то о том, что же такое DNSdist. Если по-простому, то это Load Balancer для DNS-запросов. Он не содержит информации о зонах (кроме той, что закешировал), он просто берёт запрос и перенаправляет его самому подходящему, основываясь на политике балансировки, DNS-серверу (если предыдущий ответ на такой же запрос не закешировался или кеш «протух»).
Конфигурацию DNSdist принимает либо в формате Lua-скрипта, либо в формате обычной и всем знакомой YAML-конфигурации. В нашем понимании Lua намного гибче, чем YAML (так как это, блин, скриптовый язык), поэтому, если инструмент из коробки даёт возможность сконфигурировать себя чем-то таким, то надо хотя бы попробовать! Мы попробовали, и получилось очень даже неплохо :).
По конфигурации важно отметить один не очень приятный момент: DNSdist не может «на ходу» перечитать конфигурацию из файла, который вы ему указали. То есть нет никакого systemctl reload dnsdist, и вы не можете отправить ему SIGHUP, чтобы он что-то там перечитал в своей конфигурации (однако это не значит, что вы не можете заставить dnsdist переоткрыть файл, в который он пишет логи. Об этом будет ниже).
Но это не означает, что вы вообще не можете настраивать DNSdist без перезагрузки. DNSdist имеет встроенный веб-сервер, который помимо того, что может выводить метрики сервера в Prometheus формате и отображать их на простенькой веб-страничке (скриншот ниже), также позволяет частично манипулировать конфигурацией. Но под манипуляцией, к сожалению или к счастью, имеется в виду только сброс кеша и добавление адресов, которые могут обращаться к серверу.

Ещё вы можете вносить изменения в конфигурацию через консольный доступ к DNSdist. Всё, что вам нужно сделать — это прописать вот эти две строчки в конфиге:
controlSocket('127.0.0.1:8899') setKey("ENCODED KEY")
И дальше вот таким образом подключиться к консоли: dnsdist -k "ENCODED KEY" -c 127.0.0.1:8899, если вы не рут, и без указания ключа, если выполняете команду от рута. Это не очень удобно, и нормально такое автоматизировать будет достаточно геморройно, но это можно использовать, как ручник, когда вам ну вот вообще нельзя ребутать DNSdist, а внести изменения в конфигурацию надо. Подробнее об этом тут.
Если вам нужно менять конфигурацию периодически, и факторы, по которым вам нужно это делать, вы знаете, а такжесчитаете, что можете легко описать их в коде, то DNSDist предоставляет вам следующие возможности: создавать динамические блоки временных IP-адресов, на которые вы сможете «завязаться» в своих политиках, описывать какой-либо код в функции maintenance(), которая запускается автоматически раз в секунду, использовать блоки динамических правил и так далее. Вы можете написать почти любую логику для динамического изменения политик балансировки. Например, сделать так, чтобы в случае, если часть серверов в одном пуле вдруг начала периодически отдавать ServFail, перевести трафик на другой пул. Главное, чтобы ваши Lua-функции работали достаточно быстро и не блокировали выполнение других. Мы такими вещами у себя не пользуемся, наша конфигурация достаточно проста.
Наша конфигурация DNSdist
Давайте теперь посмотрим, на то как выглядит наша конфигурация DNSdist. Полностью, конечно, не покажем, но самые интересные моменты подсветим :).
Начинается всё с файла dnsdist.conf, который и указывается при запуске DNSdist.
dnsdist.conf
-- Импорт других файлов конфигурации dofile("/etc/dnsdist/preps.lua") dofile("/etc/dnsdist/secrets.lua") dofile("/etc/dnsdist/pools.lua") dofile("/etc/dnsdist/logging.lua") dofile("/etc/dnsdist/pool_actions.lua") -- Политика балансировки по умолчанию setServerPolicy(roundrobin) -- Сокет для DNS принятия запросов setLocal('0.0.0.0:53') -- Настройка сокета для изменения конфигурации из терминала controlSocket('127.0.0.1:8899') setKey(terminalPass) -- Настройка веб сервера webserver("0.0.0.0:8080") setWebserverConfig({ password=webServerPass, apiKey=webServerPass })
Из интересного в этом файле:
Возможность импорта других файлов конфигураций, которую предоставляет Lua.
Переопределение дефолтной политики балансировки трафика.
По первому пункту, я думаю, всё понятно, а вот про второй давайте поподробнее. По дефолту для всех пулов апстримов используется политика leastOutstanding, которая отправляет запрос серверу либо с меньшим количеством запросов «в очереди», либо, если таких нет, то серверу с наименьшим порядковым значением, либо, если и таких нет, то с наименьшим средним latency. Нам такая стратегия не подходила, потому что при ней у нас в каждом пуле большая часть запросов летела в один сервер (потому что у него на 1-2мс latency был ниже, чем у других). Поэтому мы решили использовать для балансировки стандартный roundrobin. Подробнее про политики балансировки можно почитать тут.
Далее у нас идет preps.lua файл, в нём лежат константы и функция создания новых пулов.
preps.lua
geoProdZone = "geo.zona." -- Селектор запросов определённой зоны geoProdZoneSelector = QNameSuffixRule(geoProdZone) -- Селектор запросов только от определённых source ip cloud1SubnetsSelector = OrRule{ NetmaskGroupRule("172.18.0.0/16"), NetmaskGroupRule("172.19.0.0/16"), NetmaskGroupRule("172.20.0.0/16") } function newPool(options) for i, v in ipairs(options.servers) do local server = { address=v.address, name=v.name, -- Основной пул для сервера pool=options.primaryPool, checkType="SOA", --- Сервер обязан ответить что-то, что не NXDomain, ServFail или Refused чтобы пройти хелс чек mustResolve=true, --- Выводим сервер из балансировки после трех неудачных проверок maxCheckFailures=3, -- Две успешные проверки чтобы ввести сервер обратно в балансировку rise=2 } server.checkName = options.zone local newServer = newServer(server) -- Определяем вторичный пул для сервреа if options.secondaryPool then newServer:addPool(options.secondaryPool) end end end
В целом для констант всё описано в комментариях, поэтому на них не будем заострять внимание. С функцией чуть поинтереснее. DNSdist из коробки даёт функцию на создание нового сервера (по сути, на определение апстрима), но использовать только её нам не очень понравилось из-за дублирования кода, поэтому пришлось смастерить свою небольшую функцию newPool, которая и определяет сервер с правильными хелсчеками, и добавляет его в основной пул и вторичный (если такой передан).
Теперь посмотрите, как удобно ей создавать пулы апстримов в файле pools.lua.
pools.lua
newPool({ servers = { {address="172.23.1.2", name="cloud1_prod_consul1"}, {address="172.23.1.3", name="cloud1_prod_consul2"}, {address="172.23.1.4", name="cloud1_prod_consul3"} }, -- Передаём основной пул для серверов primaryPool = "consul_prod_cloud1", -- Передаём вторичный пул для серверов secondaryPool = "consul_prod_default", -- Передаём зону для корректных хелсчеков zone = consulProdZone })
Далее у нас идет небольшой, но интересный файлик logging.lua.
logging.lua
function forLogsSelector(dq) return not ( dq.qname:isPartOf(newDNSName("domain1.ru")) or dq.qname:isPartOf(newDNSName("domain2.ru")) ) and dq.qname:countLabels() > 1 end addAction( LuaRule(forLogsSelector), LogAction( -- Имя файла, куда писать лог "/var/log/dnsdist/queries.log", -- Бинарный формат логов false, -- Добавлять (append) строчки логов к файлу или каждый раз очищать его true, -- Буферизация для логов перед записью в файл false ) )
В нём мы настраиваем логгирование запросов к DNSdist-серверу. Само логгирование включается через добавление действия LogAction, которое записывает логи в файл. Подробнее про действия поговорим в следующем файле. В этом же для нас основной интерес представляет функция forLogsSelector, которая по сути обозначает запросы каких доменов нужно логгировать, а каких нет. В наши DNS-сервера летит куча запросов доменов, о которых нам не интересно знать, что их кто-то когда-то запрашивал, и, соответственно забивать этим еластик не очень хочется. И наоборот, есть домены, про резолв которых интересно было бы знать (в основном безопасникам). Поэтому в этом файлике мы отсекаем лог запроса большого количества ненужных зон и оставляем только те, которые нам нужны.
Если вы ротейтите логфайл (меняете текущему имя и создаете рядом новый), вам надо сказать DNSdist, чтобы он файлик переоткрыл. Для этого нужно воспользоваться терминалом и следующим образом «релоаднуть» LogAction:
echo "getAction(0):reload()" | dnsdist -c 127.0.0.1:8899
Только учтите, что такой вызов работает только в случае, если действие с LogAction идёт в вашем списке первым. О порядке выполнение действий чуть ниже.
Ну и последнее – это файлик с действиями: pool_actions.lua.
pool_actions.lua
-- consul cloud 1 action addAction(AndRule{ cloudOneSubnetsSelector, consulDevZoneSelector, PoolAvailableRule("consul_dev_cloud1") }, PoolAction("consul_dev_cloud1")) -- consul fallback action addAction(AndRule{ consulDevZoneSelector, PoolAvailableRule("consul_dev_default") }, PoolAction("consul_dev_default")) -- default fallback addAction(AllRule(), PoolAction("bind_default"))
Тут важно немного объяснить, как работают действия (actions).
Во-первых, синтаксис у них следующий: первым аргументом идет селектор, который выбирает, на какие запросы распространяется действие. Вторым идет само действие (есть ещё третий аргумент – хешмапа с опциями, но мы ей не пользуемся).
Во-вторых, последовательность, в которой вы эти действия в коде прописываете, имеет значение. То есть если вы самым первым определили действие с селектором AllRule, то никакие действия дальше работать не будут просто потому, что первое действие по итогу будет обрабатывать все запросы. Исключениями тут являются действия, которые выполняются и пропускают запрос дальше – например, LogAction, про который говорили выше.
В-третьих, вы можете менять последовательность действий на запущенном сервере DNSdist с помощью терминала, про который я говорил выше.
Полный список селекторов и действий можно найти тут и тут.
В нашем файле сверху у нас три действия.
Первое отбирает только запросы от клиентов из облака Cloud1 и «нацеленные» на DEV зону consul. В случае, если dev консулы в Cloud1 живы, мы перенаправим туда трафик. Если хотя бы одно из условий не истинно, действие не выполнится, и запрос пойдет к следующему.
Второе – это уже фолбек для консулов. Оно проверяет, что пользователь запрашивает DEV зону consul и в дефолтном пуле консулов (то есть где находятся все консулы из всех облаков) есть хотя бы один живой сервер.
Ну и последнее – это фолбек для всех действий: если ничего выше не сработало, то трафик пойдет на дефолтные BIND-сервера.
Итог
Подытожим: DNS-сервис, который мы предоставляем нашим клиентам, стал работать надёжнее после внедрения DNSdist за счёт его хелсчеков для апстрим DNS-серверов и гибкой настройки политик распределения DNS-трафика. Мы продолжим сопровождать DNSdist и в будущем, возможно, даже включим у него кеширование запросов на некоторых пулах для увеличения производительности.
Спасибо за чтение!
