
DNS — неотъемлемая и очень важная часть инфраструктуры, о которой иногда забывают. Порой её воспринимают как нечто само собой разумеющееся, что просто всегда есть и работает. Вспоминают о ней обычно при странных багах, которые сложно диагностировать, или авариях, которые рушат всю инфраструктуру на часы.
Некоторое время назад я добрался до задачи рефакторинга DNS инфраструктуры — чтобы сделать её проще, удобнее и надежнее. В этой статье я хочу поделиться своим опытом и расскажу, как у нас получилось сделать внутренний распределенный DNS и управлять им как кодом.
Вводные
Что мы имеем:
внутренние DNS, обслуживающие наши зоны и запросы от наших сервисов — это несколько ЦОД, зон доступности, точек присутствия или чего‑нибудь еще, где нужен DNS
приватные DNS, на которые нужно делать forward — нам нужно о них знать, но мы не можем управлять их зонами
внешние DNS — хостеры, облака и прочее, где мы только управляем зонами, но не их инфраструктурой
хаотичное управление этим зоопарком — часть автоматизирована, часть — нет, с ростом инфраструктуры DNS перестраивался неравномерно
Что хотим получить:
отказоустойчивый DNS, не требующий вмешательства инженера для восстановления
балансировку и распределение нагрузки
управление всеми зонами и правилами forward в одном окне
защиту от ошибок и краха всего DNS из‑за ошибочных изменений
В первую очередь мы сконцентрировались на внутренней инфраструктуре, но часть с управлением внешними зонами тоже не оставили без внимания.
Архитектура нового DNS
Мы рассматривали разные варианты управления, синхронизации состояний, передачу зон и изменений. Мало сделать просто доставку зон — хочется быть уверенным, что изменения доехали до серверов, иметь возможность проверки дельты. И желательно еще так, чтобы это всё работало с внешними DNS.
Zone transfer
Самый простой и популярный способ синхронизации между DNS серверами:
изменения вносятся на master
master отправляет NOTIFY на slave
slave синхронизируют состояние через AXFR или IXFR
Есть разные вариации, оптимизации, superslave сценарии и прочее.
Механизм рабочий и давно зарекомендовал себя, но нам не нравится:
зависимость от одного master на запись
непредсказуемая задержка при изменениях
далеко не все провайдеры согласятся на такую интеграцию
Этот вариант отбросили сразу.
PowerDNS Authoritative — Database Backend
Если не хочется переживать за NOTIFY, PowerDNS позволяет использовать для хранения зон базы данных (MySQL, Postgres, MSSQL — это не весь список). Теперь за хранение и репликацию отвечает база, про это не нужно думать, но...
Проблему multi‑master в DNS мы переложили на базы данных:
строить active‑active master на несколько ЦОД ради DNS точно не хочется
асинхронные реплики и их поддержка тоже не внушают энтузиазм
Оценив объемы и сложности, такой вариант тоже решили не рассматривать.
PowerDNS Authoritative — Database Backend (еще раз)
Вернее, мы решили не рассматривать репликацию баз. Зато вот сама база как хранилище зон позволяет нам использовать API для управления зонами — это удобно и позволит нам гибче управлять самим DNS.
Но репликация зон нам всё еще необходима, что делать? А нам не нужно ничего делать!
Мы просто не будем перекладывать эту задачу на DNS. Ведь мы уже управляем зонами через API, вот и состояние будем приводить к нужному тоже через API.
Получается так:
делаем нужное нам количество серверов PowerDNS
конфигурируем их как независимые узлы — они ничего не знают друг о друге
управляем их зонами с помощью внешнего инструмента
Так мы получаем консистентную конфигурацию, независимость серверов друг от друга, и программное управление в придачу. Отлично, это — именно то, что нам нужно.
Но тут мы решили только проблему с authoritative DNS, еще есть recursor.
PowerDNS Recursor
Раз уж мы уже рассматриваем PowerDNS Authoritative, логично посмотреть и на их Recursor. Его и выбрали.
У него тоже есть возможность управления через API. Вообще его можно использовать как authoritative, но, честно говоря, мы даже не рассматривали такой вариант. Пусть решает свои задачи, а зоны обслуживает предназначенный для этого Authoritative.
А что с dnsdist?
Мы про него мы не забыли.
Изначально думали поставить его как балансировщик перед всеми компонентами, но в нашем случае это показалось избыточным. Производительности Recursor нам хватает, сложных правил маршрутизации у нас нет, переписывать запросы на ходу нам не нужно, а саму балансировку мы реализовали другим способом.
Подробнее об этом — далее.
DNS server
Итак, наш DNS сервер состоит из pdns‑recursor и pdns‑authoritative.
На входе recursor обслуживает запросы, выступает как маршрутизатор и кеш, за ним authoritative — отвечает за наши зоны.

Получается такой путь запроса:
если зона явно не определена в forward — запрос уйдет в интернет на root hints
если определена и мы ей управляем — на него ответит локальный инстанс authoritative
если определена, но мы ей не управляем — recursor отправит запрос на указанный в конфигурации адрес
С самим DNS и стеком мы определились, теперь нужно собрать это вместе и научиться этим управлять.
Управление через IaC
Authoritative
Были разные идеи, как именно организовать работу с его API, даже сделать свой контроллер с reconciliation loop и вот это всё. Поразмыслив, явных плюсов от такой идеи мы для себя не нашли, поэтому решили остановиться на варианте попроще:
Ранее я говорил, что мы хотим управлять еще и внешними зонами одним инструментом — и octodns как раз позволяет нам это сделать. У утилиты есть множество готовых провайдеров, включая сам powerdns. Плюс — он написан на python, код у него довольно простой, поэтому расширить функционал или добавить провайдер при необходимости не трудно.
В примерах ниже я сильно упростил конфигурацию. Документация по octodns и gitlab‑ci хорошо написана, поэтому разбирать логику, правила и прочее я здесь не буду.
Конфигурация octodns описывается в YAML, это позволяет нам переиспользовать значения и целые блоки с помощью anchors — очень удобно, когда у вас много зон и серверов.
У нас получается примерно такой репозиторий:
authoritative/ ├── dns │ └── intranet │ ├── zone-a.internal │ ├── zone-b.internal │ └── zone-c.internal ├── dns-intranet.yaml └── .gitlab-ci.yml
В dns-intranet.yaml определяются DNS серверы и доступы к API:
powerdns_template: &powerdns_template class: octodns_powerdns.PowerDnsProvider api_key: env/POWERDNS_AUTHORITATIVE_API_KEY scheme: https port: 443 ssl_verify: true providers: intranet_config: class: octodns.provider.yaml.YamlProvider directory: ./dns/intranet enforce_order: false ns-1-az-1: <<: *powerdns_template host: 192.0.2.11 ns-2-az-1: <<: *powerdns_template host: 192.0.2.12 ns-1-az-2: <<: *powerdns_template host: 192.0.2.21 ns-2-az-2: <<: *powerdns_template host: 192.0.2.22 ns-1-az-3: <<: *powerdns_template host: 192.0.2.31 ns-2-az-3: <<: *powerdns_template host: 192.0.2.32 providers_templates: intranet_ns: &intranet_ns - ns-1-az-1 - ns-2-az-1 - ns-1-az-2 - ns-2-az-2 - ns-1-az-3 - ns-2-az-3 zones: '*': sources: - intranet_config targets: *intranet_ns
В dns/intranet/ держим конфигурации зон в разных файлах, например:
# zone-a.internal '': type: NS values: - ns1.zone-a.internal. - ns2.zone-a.internal. ns1: ttl: 3600 type: A value: 198.51.100.10 ns2: ttl: 3600 type: A value: 198.51.100.20 service-1: ttl: 300 type: A value: 192.0.2.101 service-2: ttl: 300 type: A value: 192.0.2.102 service-3: ttl: 120 type: A value: &svc-3 192.0.2.103 service-4: ttl: 120 type: A value: &svc-4 192.0.2.104 svc-3-4: ttl: 120 type: A values: - *svc-3 - *svc-4
В pipeline запускается octodns, в нашем случае коммит в dev ветку вычисляет дельту, а изменение применяется после merge в prod:
# commit - dev diff_intranet: stage: diff script: - octodns-sync --config-file dns-intranet.yaml # merge - prod apply_intranet: stage: apply script: - octodns-sync --config-file dns-intranet.yaml --doit --force
В pipeline - план выполнения и результаты измененй:
$ octodns-sync --config-file dns-intranet.yaml --doit --force INFO Manager sync: targets=['ns-1-az-1', 'ns-1-az-2'] INFO YamlProvider[intranet_config] populate: found 7 records, exists=True INFO PowerDnsProvider[ns-1-az-1] plan: desired=zone-a.internal. INFO PowerDnsProvider[ns-1-az-1] populate: found 8 records, exists=True INFO PowerDnsProvider[ns-1-az-1] plan: Creates=0, Updates=2, Deletes=1, Existing=8, Meta=False INFO PowerDnsProvider[ns-1-az-2] plan: desired=zone-a.internal. INFO PowerDnsProvider[ns-1-az-2] populate: found 8 records, exists=True INFO PowerDnsProvider[ns-1-az-2] plan: Creates=0, Updates=2, Deletes=1, Existing=8, Meta=False INFO Plan ******************************************************************************** * zone-a.internal. ******************************************************************************** * ns-1-az-1 (PowerDnsProvider) * Delete <ARecord A 300, service-2.zone-a.internal., ['192.0.2.102']> * Update * <ARecord A 120, service-3.zone-a.internal., ['192.0.2.103']> -> * <ARecord A 120, service-3.zone-a.internal., ['192.0.2.110']> (intranet_config) * Update * <ARecord A 120, svc-3-4.zone-a.internal., ['192.0.2.103', '192.0.2.104']> -> * <ARecord A 120, svc-3-4.zone-a.internal., ['192.0.2.104', '192.0.2.110']> (intranet_config) * Summary: Creates=0, Updates=2, Deletes=1, Existing=8, Meta=False * ns-1-az-2 (PowerDnsProvider) * Delete <ARecord A 300, service-2.zone-a.internal., ['192.0.2.102']> * Update * <ARecord A 120, service-3.zone-a.internal., ['192.0.2.103']> -> * <ARecord A 120, service-3.zone-a.internal., ['192.0.2.110']> (intranet_config) * Update * <ARecord A 120, svc-3-4.zone-a.internal., ['192.0.2.103', '192.0.2.104']> -> * <ARecord A 120, svc-3-4.zone-a.internal., ['192.0.2.104', '192.0.2.110']> (intranet_config) * Summary: Creates=0, Updates=2, Deletes=1, Existing=8, Meta=False ******************************************************************************** INFO PowerDnsProvider[ns-1-az-1] apply: making 3 changes to zone-a.internal. INFO PowerDnsProvider[ns-1-az-2] apply: making 3 changes to zone-a.internal. INFO Manager sync: 6 total changes
Таким образом у нас управляются все ресурсы во всех зонах: и внутренних, и внешних. Все изменения проходят через merge request, тестируются и не позволят сломать весь DNS разом. Конфигурация применяется поочередно, если что‑то пойдет не так — выполнение остановится.
Но есть еще одна задача, которую нужно решить, прежде чем идти дальше.
Recursor
Octodns решает проблему управления и доставки зон для Authoritative, но это не работает с Recursor, а нам всё еще нужно управлять маршрутизацией запросов на другие DNS серверы, да и на наши тоже.
Сначала была идея добавить его как отдельный провайдер, но это не очень бьется с логикой проекта, мы всё же не будем управлять ресурсными записями. В общем, проще было сделать отдельную утилиту для управления forward конфигурацией (и recursor вообще) — так я написал pdns‑recursor‑cli. К сожалению, прямо сейчас я не готов выложить её в паблик, но постараюсь найти время и сделаю это позже.
Работает pdns‑recursor‑cli примерно так же, как octodns: получает желаемый state из файла конфигурации, сверяет его с состоянием DNS, применяет изменения, если есть расхождения.
В конфигурации описываются серверы (targets), данные для авторизации в API и путь к правилам forward (zone_file):
zone_file: dns/recursor/zones.yaml targets: - name: rec-1 api_url: https://ns-1.az-1.internal/rec/ api_key: !env POWERDNS_RECURSOR_API_KEY api_ssl_verify: true - name: rec-2 api_url: https://ns-1.az-2.internal/rec/ api_key: !env POWERDNS_RECURSOR_API_KEY api_ssl_verify: true - name: rec-3 api_url: https://ns-1.az-3.internal/rec/ api_key: !env POWERDNS_RECURSOR_API_KEY api_ssl_verify: true
В zone_file описаны правила, как резолвить зону (рекурсивно или нет) и какие серверы для этого использовать:
aliases: internal_dns: &internal_dns - 192.0.2.11:53 - 192.0.2.12:53 remote_dns: &remote_dns - 198.51.100.10:53 - 203.0.113.10:53 testing_dns: &testing_dns - 192.0.2.201:53 zones: - name: intranet.internal. recursion_desired: false servers: *internal_dns - name: site-a.remote.tld. recursion_desired: false servers: *remote_dns - name: labs.example. recursion_desired: true servers: *testing_dns
Аналогично pipeline для Authoritative, запускается проверка дельты при коммите в dev:
$ pdns-recursor-cli state diff Retrieving state - [+] Retrieved state from rec-1 - [+] Retrieved state from rec-2 - [+] Retrieved state from rec-3 Diff with rec-1: - add: [] delete: [] update: intranet.internal.: - '[servers] 192.0.2.21:53, 192.0.2.22:53 -> 192.0.2.11:53, 192.0.2.12:53' Diff with rec-2: - add: [] delete: [] update: intranet.internal.: - '[servers] 192.0.2.21:53, 192.0.2.22:53 -> 192.0.2.11:53, 192.0.2.12:53' Diff with rec-3: - add: [] delete: [] update: intranet.internal.: - '[servers] 192.0.2.21:53, 192.0.2.22:53 -> 192.0.2.11:53, 192.0.2.12:53'
И применение конфига в prod:
$ pdns-recursor-cli state sync Retrieving state - [+] Retrieved state from rec-1 - [+] Retrieved state from rec-2 - [+] Retrieved state from rec-3 Syncing state on rec-1 - [+] Synced on rec-1 Syncing state on rec-2 - [+] Synced on rec-2 Syncing state on rec-3 - [+] Synced on rec-3
Этот же инструмент используется для работы с кешем:
$ pdns-recursor-cli cache flush intranet.internal. Flushing caches for zone "intranet.internal.", recursive: False - [+] Flushed on rec-1 - [+] Flushed on rec-2 - [+] Flushed on rec-3 $ pdns-recursor-cli cache flush . Flushing caches for zone ".", recursive: True - [+] Flushed on rec-1 - [+] Flushed on rec-2 - [+] Flushed on rec-3
Например, так мы можем сбросить DNS кеш на всей инфраструктуре по заданным доменам, отдельным записям или вообще весь.
Итоговая схема работы выглядит так:
сделали коммит — остальное делает CI/CD
проверка синтаксиса и грубых ошибок
получение дельты с recursor, authoritative и внешних DNS
вывод плана изменений
применение нового конфига после merge

Осталось подумать над отказоустойчивостью и балансировкой.
BGP Anycast
Тут не будет истории про выборы технологий и вариантов, потому что мы изначально планировали именно такой вариант. BGP для балансировки и HA уже давно использовался в других сервисах и нам хорошо знаком. И вообще я бывший сетевик, я люблю BGP (но дело не в этом!).
Конечно, важно учитывать особенности и ограничения, которые неизбежно будут влиять на работу сервисов, например, ограниченное количество ECMP групп на оборудовании или дроп асимметричного трафика на firewall из‑за отсутствия сессии в таблице — но мы про них помним, но сейчас я не буду разбирать эти сценарии.
Суть Anycast сводится к анонсированию одинакового сервисного адреса (или нескольких адресов) которые будут обслуживать наш трафик, со всех серверов внутри сети. Для этого серверы строят BGP пиринг с маршрутизаторами (или коммутаторами, или route‑reflector, или чем‑нибудь еще) в своей зоне доступности. Хороший пример применения BGP Anycast — Google DNS, он именно так и построен (конечно, сложнее, чем описано в этой статье).

В нормальном состоянии, запросы будут обслуживаться ближайшим к клиенту сервером, в случае отказа — трафик будет доставлен на другой сервер за счет перестроения маршрутизации. А в качестве бонуса мы получаем ECMP балансировку и возможность горизонтально масштабировать количество серверов, если это потребуется.
На стороне клиента в конфигурации DNS всегда одни и те же адреса, независимо от локации.
Как это реализовано
Для такой схемы требуется настройка со стороны сетевого оборудования. Допустим, у нас уже настроены BGP сессии на маршрутизаторах, они принимают наши адреса для DNS и разрешают AS path prepend (в нашем случае это важно).

На стороне DNS серверов (ns-1 и ns-2) создаются anycast адреса 198.51.100.10 и 198.51.100.20, применяемые на loopback интерфейс (в примере используется netplan):
# /etc/netplan/0_loopback.yaml network: version: 2 renderer: networkd ethernets: lo: addresses: - 127.0.0.1/8 - ::1/128 # anycast-svc-dns-1 - 198.51.100.10/32 # anycast-svc-dns-2 - 198.51.100.20/32
Для стыковки по BGP используется bird, в нем настраиваются соседства, анонсы и фильтры. Дополнительно, мы анонсируем адреса с разным приоритетом для ns-1 и ns-2, используя path prepend. Таким образом, ns-1 всегда будет приоритетным для адреса 198.51.100.10, а ns-2 — для адреса 198.51.100.20.
Конфигурация bird для ns-1:
# ns-1 log syslog all; router id 192.0.2.11; protocol device { } protocol direct { interface "lo"; } protocol kernel { import all; export all; } protocol bgp { local as 65501; neighbor 203.0.113.1 as 65502; neighbor 203.0.113.2 as 65502; keepalive time 3; hold time 9; import none; export filter { if net = 198.51.100.10/32 then accept; if net = 198.51.100.20/32 then { bgp_path.prepend(65501); } accept; reject; }; }
И для ns-2:
# ns-2 log syslog all; router id 192.0.2.12; protocol device { } protocol direct { interface "lo"; } protocol kernel { import all; export all; } protocol bgp { local as 65501; neighbor 203.0.113.1 as 65502; neighbor 203.0.113.2 as 65502; keepalive time 3; hold time 9; import none; export filter { if net = 198.51.100.20/32 then accept; if net = 198.51.100.10/32 then { bgp_path.prepend(65501); } accept; reject; }; }
Здесь мы импортируем connected route из интерфейсов lo, запрещаем прием префиксов от соседей и анонсируем им только то, что определено в filter. Для понижения приоритета определенного префикса, мы добавляем ему в AS_PATH еще одно вхождение нашей AS (prepend).
Про выбор маршрута в BGP
В BGP есть много способов управлять трафиком и приоритетами, AS path prepend — только один из них. Например, по этому алгоритму выбирается маршрут в Cisco.
Этой минимальной конфигурации достаточно, чтобы наш DNS заработал. Осталось его масштабировать, покрыть мониторингом и написать DR план. Но это уже тема для другой статьи.
О чем я не рассказал
И о чем лучше подумать заранее.
В статье я намеренно не раскрывал все мелочи и особенности, которые могут быть, всё же целью было поделиться своей историей. Инфраструктура у всех разная и везде есть свои особенности. Но хочу отдельно отметить важные моменты, которые могут сэкономить нервы и минуты простоя, не вдаваясь детально в реализацию.
Шифрование и защита API
В примерах я не вдавался в настройку TLS, выпуск сертификатов и ограничения доступа к API. Разумеется, в проде это необходимо. Перед API можно поставить nginx, traefik или любой другой веб‑сервер, который решает эти задачи.
Автоматизация развертывания и управления серверами (и оборудованием)
На масштабе без автоматизации никуда, тем не менее, здесь это кртиически важно. Ошибки в конфигурации DNS могут очень больно стрелять. Мы используем ansible и gitlab для раскатки и изменения конфигураций (например, BGP), делаем это небольшими частями, предусматриваем автоматический откат и остановку выполнения, если что‑то пошло не по плану.
Liveness probe
Если DNS не будет работать, а адрес продолжит анонсироваться — будет неприятно. Например, это одна из причин, почему в конфигурации выше адреса отдаются с разным приоритетом. Возможно, кому‑то хватит systemd зависимостей bird от pdns, а кому‑то потребуется изменение анонсов BGP при наступлении определенных событий, например, с помощью birdwatcher. Мы внедряли проверки резолвинга своих зон и понижение приоритета, если что‑то идет не так.
Лучше понизить приоритет, чем полностью убрать анонсы
Автоматический drain при деградации сервиса — это здорово, пока это не решат сделать все участники DNS одновременно, одна компания в 2021 году в этом убедилась. Можно понижать приоритет автоматикой, оставить запасной less‑specific как статический маршрут или использовать третий адрес вне anycast — вариантов предохранителей много, главное — чтобы он у вас был.
Итоги
Это была интересная задача и я доволен тем, как всё получилось. После переезда на новую архитектуру нам стало намного проще жить: коллеги больше не боятся трогать DNS, вся история и конфиги — в репозиториях и управляются однообразно, а сломать сам DNS теперь намного труднее. Но всё еще можно, конечно.
Спасибо за внимание!
Soullego
Спасибо, интересная статья! Было бы ещё интересно почитать про подключения внешних aDNS, понятно что там тоже должен быть API, но может столкнулись с какими-либо подводными камнями.
Xelld Автор
Спасибо!
Обычно провайдеры octodns без проблем работают, тут все зависит от стабильности API.
Некоторые поставщики могут довольно строго ограничивать RPS, это приходится учитывать и обрабатывать кодом, но для популярных платформ это обычно уже реализовано.