Отказоустойчивый Anycast DNS с управлением через IaC

от автора

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: 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

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.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/