
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 теперь намного труднее. Но всё еще можно, конечно.
Спасибо за внимание!
Автор: Xelld
