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 для управления зонами

  • Gitlab CI для доставки конфигурации

Ранее я говорил, что мы хотим управлять еще и внешними зонами одним инструментом — и 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

Workflow

Осталось подумать над отказоустойчивостью и балансировкой.

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 теперь намного труднее. Но всё еще можно, конечно.

Спасибо за внимание!

Комментарии (2)


  1. Soullego
    18.04.2026 10:41

    Спасибо, интересная статья! Было бы ещё интересно почитать про подключения внешних aDNS, понятно что там тоже должен быть API, но может столкнулись с какими-либо подводными камнями.


    1. Xelld Автор
      18.04.2026 10:41

      Спасибо!

      Обычно провайдеры octodns без проблем работают, тут все зависит от стабильности API.

      Некоторые поставщики могут довольно строго ограничивать RPS, это приходится учитывать и обрабатывать кодом, но для популярных платформ это обычно уже реализовано.