
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: trueproviders: 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.32providers_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-3zones: '*': 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.10ns2: ttl: 3600 type: A value: 198.51.100.20service-1: ttl: 300 type: A value: 192.0.2.101service-2: ttl: 300 type: A value: 192.0.2.102service-3: ttl: 120 type: A value: &svc-3 192.0.2.103service-4: ttl: 120 type: A value: &svc-4 192.0.2.104svc-3-4: ttl: 120 type: A values: - *svc-3 - *svc-4
В pipeline запускается octodns, в нашем случае коммит в dev ветку вычисляет дельту, а изменение применяется после merge в prod:
# commit - devdiff_intranet: stage: diff script: - octodns-sync --config-file dns-intranet.yaml# merge - prodapply_intranet: stage: apply script: - octodns-sync --config-file dns-intranet.yaml --doit --force
В pipeline — план выполнения и результаты измененй:
$ octodns-sync --config-file dns-intranet.yaml --doit --forceINFO Manager sync: targets=['ns-1-az-1', 'ns-1-az-2']INFO YamlProvider[intranet_config] populate: found 7 records, exists=TrueINFO PowerDnsProvider[ns-1-az-1] plan: desired=zone-a.internal.INFO PowerDnsProvider[ns-1-az-1] populate: found 8 records, exists=TrueINFO PowerDnsProvider[ns-1-az-1] plan: Creates=0, Updates=2, Deletes=1, Existing=8, Meta=FalseINFO PowerDnsProvider[ns-1-az-2] plan: desired=zone-a.internal.INFO PowerDnsProvider[ns-1-az-2] populate: found 8 records, exists=TrueINFO PowerDnsProvider[ns-1-az-2] plan: Creates=0, Updates=2, Deletes=1, Existing=8, Meta=FalseINFO 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.yamltargets: - 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:53zones: - 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 diffRetrieving state- [+] Retrieved state from rec-1- [+] Retrieved state from rec-2- [+] Retrieved state from rec-3Diff 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 syncRetrieving 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.yamlnetwork: 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-1log 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-2log 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 теперь намного труднее. Но всё еще можно, конечно.
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/1024930/