Pull to refresh

DNS-as-Code на базе dnscontrol

Reading time12 min
Views5.4K

Всем привет!

Это моя первая заметка на Хабре, поэтому прошу отнестись к ней без особой строгости. Написать статью на Хабр я хотел очень давно, но вот подходящего и уникального материала не было. Все темы, которыми можно было поделиться, либо уже были кем-то разобраны, либо не очень интересны... Сегодня я наконец-то подыскал кое-что интересное, о чем хочу поведать.

Итак, погнали.

По роду своей деятельности я занимаюсь системным администрированием. Поскольку сам по себе я человек ленивый, я люблю максимально всё автоматизировать. Отсюда и любовь ко всяким средствам "Everything-as-Code".

В перерывах между написанием плейбуков для Ansible, кода для Terraform и прочими средствами автоматизации инфраструктуры, я начал искать для себя новые развлечения , что же еще можно автоматизировать, чтобы еще меньше в своей работе нажимать кнопок и запускать скриптов.

И тут я вспомнил про нашу внешнюю DNS-зону, которую мы хостим на nic.ru. На тот момент у нас было несколько доменов, которые в совокупности содержали около 3000 записей, причем около 80% из них - это записи для стендов разработчиков, которые отличаются друг от друга только порядковыми номерами или каким-нибудь суффиксом.

Пример
dev1           A      1.2.3.4
dev1-serviceA  CNAME  dev1
dev1-serviceB  CNMAE  dev1
...
dev2           A      1.2.3.4
dev2-serviceA  CNAME  dev2
dev2-serviceB  CNMAE  dev2
...

Таких dev<N> у нас было порядка 100-150 в разные моменты времени. Всё это осложнялось еще тем фактом, что периодически эти записи приходилось изменять/добавлять/удалять. Например, часть DEV-стендов нужно завернуть на другой IP, или каждому DEV-стенду нужен еще какой-нибудь отдельный CNAME и т.п.

Всем этим добром управляли либо вручную (поправить/добавить единичные записи), либо обвязкой скриптов с кучей параметров, багов и танцами с бубном большими мануалами.

И вот в какой-то из вечеров мне пришла в голову мысль "хм, а ведь эти записи очень легко запрограммировать, их можно генерировать каким-нибудь несложным алгоритмом...", "...для виртуалок в облаке у нас есть Terraform, для управления конфигурацией есть Ansible/Puppet/..., может и для DNS что-то есть?".

Через несколько секунд я уже вбиваю в строку поиска гугла фразу "DNS as Code" и начинаю искать инструменты, способные воплотить мои фантазии в реальность. Спустя 5-10 минут я натыкаюсь на инструменты Dnscontrol и Octodns. Хм, прикольные штуки, начинаю читать их возможности и принцип работы...

Давайте подробнее их рассмотрим.

octodns

OctoDNS – это инструмент, основанный на подходе «инфраструктура как код», который позволяет развертывать и управлять DNS-зонами. Для этого он использует стандартные принципы разработки программного обеспечения, в том числе контроль версий, тестирование и автоматическое развертывание. OctoDNS был создан GitHub и написан на Python.

Источник: DigitalOcean

Ок, ну с этим всё понятно. Что там с кодом? Руки то чешутся :) На каком языке писать? Что запускать? Читаем дальше:

Использование OctoDNS помогает избавиться от многих сложностей ручного управления DNS, поскольку файлы зон хранятся в структурированном формате (YAML).

Ах, ну да мы же теперь не сисадмины, а YAML-девелоперы девопсы. А в этом деле никуда без знаний великого и могучего YAML.

Ок, смотрим дальше, что там по возможностям:

  • Есть разработанные провайдеры для многих популярных облачных систем и регистраторов (NIC.RU, конечно, нету)

  • Можно синхронизировать DNS-записи между несколькими провайдерами

  • Есть возможность встраивать в CI/CD

Примерно таким образом можно описывать наши DNS-записи в формате YAML:

~/octodns/config/config.yaml
---
providers:
  config:
    class: octodns.provider.yaml.YamlProvider
    directory: ./config
    default_ttl: 300
    enforce_order: True
  digitalocean:
    class: octodns.provider.digitalocean.DigitalOceanProvider
    token: your-digitalocean-oauth-token

zones:
  your-domain.:
    sources:
      - config
    targets:
      - digitalocean
~/octodns/config/your-domain.yaml
---
'':
  - type: A
    value: 1.2.3.4

www:
  type: A
  value: 5.6.7.8

Беглым чтением я оценил возможности этого инструмента. Вот чего мне в нем не хватило:

  • Нет возможности создавать циклы и другие алгоритмические конструкции, которые могут упростить создание однотипных записей

  • Нет возможности экспортировать зону в формате Bind

Оба этих минуса обесценивают возможность использования этого инструмента в моем кейсе. Идем дальше...

Dnscontrol

DNSControl — это инструмент, построенный по принципу «инфраструктура как код», который поддерживает развертывание и управление зонами DNS с использованием стандартных принципов разработки программного обеспечения, включая контроль версий, тестирование и автоматизированное развертывание. Инструмент DNSControl разработан Stack Exchange и написан на Go.

Источник: DigitalOcean

Где-то мы уже это читали, не правда ли? Пока отличия только в последнем предложении (написан на Go и разработан компанией Stack Exchange). Хм, ну да ладно, смотрим, как писать код:

DNSControl uses javascript as its primary input language to provide power and flexibility to configure your domains. The ultimate purpose of the javascript is to construct a DNSConfig object that will be passed to the go backend and operated on.

Источник: StackExchange

Чего? В "гошечку" встроен JavaScript? Серьезно? Ладно, смотрим дальше, как этим пользоваться.

Вот так выглядит основной файл конфигурации Dnscontrol:

dnscontrol.js
// define dummy-registar and Bind-provider
REG_NONE = NewRegistrar('none', 'NONE');
DNS_BIND = NewDnsProvider('bind', 'BIND', {
    // default SOA-records for all domains
    'default_soa': {
        'master': 'ns3-l2.nic.ru.',
        'mbox': 'dns.nic.ru.',
        'refresh': 9999,
        'retry': 9999,
        'expire': 9999000,
        'minttl': 999,
    },
    // default NS-records for all domains
    'default_ns': [
        'ns8-cloud.nic.ru.',
        'ns3-l2.nic.ru.',
        'ns4-l2.nic.ru.',
        'ns8-l2.nic.ru.',
        'ns4-cloud.nic.ru.'
    ]
});

А вот так выглядит файл с описанием записей вашей зоны:

my-zone.ru.js
function myzone_ru(REG, PROVIDER){
    return D('myzone.ru', REG, DnsProvider(PROVIDER),
        DefaultTTL('5m')
        ,A('@', '1.2.3.4')
        ,MX('@', 10, 'mx.myzone.ru.', TTL('6h'))
        ,A('www', '1.2.3.4')
        ,CNAME('portal', 'www')
    )
}

На первый взгляд кажется ужасно, даже YAML выглядит симпатичнее. Казалось бы, зачем встраивать движок JavaScript, чтоб потом писать такой вот простенький код. Идем снова в документацию, видим такую надпись:

Advanced Topics:

Code Tricks: Safely use macros and loops.

Проваливаемся по ссылке и видим пример, в котором используются переменные и циклы. Но есть такое предупреждение:

The dnsconfig.js language is JavaScript. On the plus side, this means you can use loops and variables and anything else you want...

Sure, you can do a lot of neat tricks with if/thens and macros and loops. Yes, YOU understand the code. However, think about your coworkers who will be the next person to edit the file. Are you setting them up for failure?

То есть мы в коде можем использовать любые (почти) конструкции языка JavaScript. Но разработчики предупреждают нас, что не нужно фанатизма. Не забывайте, что мы не программисты, а все лишь сисадмины (ну или модные девопсы). Задумайтесь о своих коллегах, которые потом будут пытаться в этом разобраться.

Но мы не боимся и пробуем создать что-то более сложное:

my-zone.ru.js
function generate_DEV_records (REG, PROVIDER){
    DEV_CNAME_RECORDS = [
        'serviceA'
        ,'serviceB'
    ]
	  dev_stand_count = 5
    dev_public_ip = '1.2.3.4'
    RECORDS = []
    for (var i = 1; i <= dev_stand_count; i++){
        RECORDS.push(
            A('dev' + i, dev_public_ip)
        )
        for (var j = 0; j < DEV_CNAME_RECORDS.length; j++){
            RECORDS.push(
                CNAME('dev' + i + '-' + DEV_CNAME_RECORDS[j], 'dev' + i)
            )
        }
    }

	  return D('myzone.ru', REG, DnsProvider(PROVIDER),
        RECORDS)
}

Я использую провайдера Bind, который просто генерирует файлы зон в формате Bind. С этими зонами потом я могу делать всё что угодно.

Для применения конфига выполняем команду dnscontrol push:

Вывод
❯ dnscontrol push
******************** Domain: myzone.ru
----- Getting nameservers from: bind
----- DNS Provider: bind...File does not yet exist: "zones/myzone.ru"
1 correction
#1: GENERATE_ZONEFILE: 'myzone.ru' (new file with 21 records)

WRITING ZONEFILE: zones/myzone.ru
SUCCESS!
----- Registrar: none...0 corrections
Done. 1 corrections.

В результате в каталоге zones появляются файлы зоны в формате Bind:

myzone.ru
$TTL 300
; generated with dnscontrol 2021-03-24T23:15:09+03:00
@                IN SOA   ns3-l2.nic.ru. dns.nic.ru. 2021032400 1440 3600 2592000 600
                 IN NS    ns3-l2.nic.ru.
                 IN NS    ns4-cloud.nic.ru.
                 IN NS    ns4-l2.nic.ru.
                 IN NS    ns8-cloud.nic.ru.
                 IN NS    ns8-l2.nic.ru.
dev1             IN A     1.2.3.4
dev1-servicea    IN CNAME dev1.myzone.ru.
dev1-serviceb    IN CNAME dev1.myzone.ru.
dev2             IN A     1.2.3.4
dev2-servicea    IN CNAME dev2.myzone.ru.
dev2-serviceb    IN CNAME dev2.myzone.ru.
dev3             IN A     1.2.3.4
dev3-servicea    IN CNAME dev3.myzone.ru.
dev3-serviceb    IN CNAME dev3.myzone.ru.
dev4             IN A     1.2.3.4
dev4-servicea    IN CNAME dev4.myzone.ru.
dev4-serviceb    IN CNAME dev4.myzone.ru.
dev5             IN A     1.2.3.4
dev5-servicea    IN CNAME dev5.myzone.ru.
dev5-serviceb    IN CNAME dev5.myzone.ru.

После внесения изменений в исходные файлы наших зон, можно выполнить команду dnscontrol preview, которая покажет планируемые изменения. Для применения изменений снова выполняем команду dnscontrol push

Неплохо, да?

Забиваем на все предостережения и начинаем писать более сложный код. Через пару часов экспериментов я уже получаю разветвленную структуру проекта с множеством JS-файлов и даже собственными функциями, которые я использую в коде:

Скриншот

Остановимся на этом инструменте и попробуем выстроить полный процесс DNS as Code.

Построение CI

Итак, мы вроде определились с инструментом, теперь давайте строить CI. Принципы Infrastructure as Code требуют от нас применять практики, используемые при разработке ПО. А именно:

  • Использование системы контроля версий

  • Код ревью

  • CI/CD

  • Тестирование

Да, требований много, попробуем всё это собрать в единый пайплайн.

В моем проекте мы используем Gitlab. Благодаря встроенному Container Registry и CI мы можем построить ведь необходимый нам пайплайн в одном месте, прямо в нашем репозитории проекта, это очень удобно.

Итак, для начала надо определиться с шагами, которые будут в нашем пайплайне. Я придумал следующие:

  • validate - валидация кода

  • prepare - подготовка всего необходимого, скачивание текущего состояния зон с сайта NIC.RU

  • plan - построение плана изменений

  • build - сборка новых файлов зон

  • test - тестирование зон на DNS-сервере

  • deploy - отправка проверенных зон в NIC.RU и их применение

Теперь нужно определиться с docker-образами, которые мы будем использовать на каждом из шагов пайплайна.

Я использую 2 образа:

Эти образы я пересобираю, т.к. мне нужно в них встроить наши корпоративные сертификаты и дополнительные утилиты (оставим это за рамками данной статьи).

После еще пары дней экспериментов с Gitlab CI я получаю примерно такой пайплайн:

.gitlab-ci.yml
image: $CI_REGISTRY_IMAGE/dnscontrol

variables:
  CA_CERT_FILE: /etc/gitlab-runner/certs/ca.crt
  ZONES_OUT_DIR: $CI_PROJECT_DIR/zones
  NIC_API_URL: https://api.nic.ru
  NIC_SERVICE: MYSERVICE

cache:
  key: dns-nic-ru
  paths:
    - .nic_token

stages:
  - validate
  - prepare
  - plan
  - build
  - test
  - deploy

check:
  stage: validate
  script:
    - dnscontrol -v check

prepare:
  stage: prepare
  script:
    - mkdir -p $ZONES_OUT_DIR
    - dnscontrol push
    - ls -la $ZONES_OUT_DIR
    # проверяем токен NIC.RU (при необходимости перевыпускаем)
    - . ci/scripts/nic_auth.sh
    # загружаем текущие файлы зон из NIC.RU
    - ci/scripts/nic_download.sh
    - ls -la $ZONES_OUT_DIR
  artifacts:
    public: false
    paths: [ zones/ ]
    expire_in: 5 mins

plan:
  stage: plan
  script:
    # сохраняем полученный план изменений в артефакты
    - dnscontrol preview | tee plan.txt
  artifacts:
    # отображаем артефакт в Merge Request, чтоб ревьюверы могли быстро посмотреть
    expose_as: plan
    paths: [ plan.txt ]
    public: false
    expire_in: 3 mos

build:
  stage: build
  script:
    - dnscontrol -v push
  artifacts:
    name: zones
    expose_as: zones
    paths: [ zones/ ]

test:
  stage: test
  image: $CI_REGISTRY_IMAGE/bind9
  variables:
    BIND_MAIN_CONFIG: /etc/bind/named.conf
    BIND_ZONES_DIR: /var/lib/bind/
    BIND_TESTS_DIR: $CI_PROJECT_DIR/tests
  script:
    - cat ci/bind9/named.conf > $BIND_MAIN_CONFIG
    - cp $ZONES_OUT_DIR/* $BIND_ZONES_DIR/
    # генерируем кофиг bind на основе имеющихся зон
    - ci/scripts/zones.conf.sh >> $BIND_MAIN_CONFIG
    - cat $BIND_MAIN_CONFIG
    # проверяем валидность конфига
    - /usr/sbin/named-checkconf /etc/bind/named.conf
    # запускаем bind с полученным конфигом
    - /usr/sbin/named -g -c /etc/bind/named.conf -u bind &
    # ждем пока поднимется bind
    - while ! (ss -4tulnp | grep 53 > /dev/null); do echo "Waiting for a socket to go up"; sleep 1; done
    - ps aux
    # прогоняем автотесты (проверяем, что записи резолвятся как надо)
    - ci/scripts/bind_test.sh

deploy:
  stage: deploy
  script:
    # снова на всякий случай проверяем токен
    - . ci/scripts/nic_auth.sh
    # выгружаем зоны в NIC.RU
    - ci/scripts/nic_upload.sh
  dependencies:
    - build
  rules:
    - if: '$CI_COMMIT_BRANCH == "master"'
      when: manual

Отдельно, хотелось бы рассказать про шаги plan и test.

На шаге plan вывод команды перенаправляется в файл, который затем складывается в артефакты. Этот артефакт мы помечаем опцией expose_as. Опция указывает, что когда контрибьютор создаст Merge Request, ссылка на этот файл и джобу будет автоматически туда прикреплена. Это очень удобно для ревьюверов, которые кроме изменений в коде будут видеть и планируемые изменения в результирующей зоне. Выглядит это вот так:

Скриншот

Если кликнуть по кнопке plan, которая находится по надписью Job, то проваливаемся в вывод джобы и можем посмотреть план:

Скриншот

На шаге test производится проверка зон на реальном bind-сервере. Т.е. запускается контейнер с DNS-сервером Bind, создаются необходимые конфиги и скармиливаются наши зоны. Затем прогоняются тесты, которые проверяют, что необходимые записи резолвятся и возвращают правильный результат.

На некоторых шагах используются дополнительные shell-скрипты, в которые заложена нужная логика. Тут уж всё зависит от ваших необходимостей.

Shell-срипты для CI:

nic_auth.sh
#!/bin/bash

NIC_TOKEN_FILE='.nic_token'
NIC_CHECK_ACCESS_URL="$NIC_API_URL/dns-master/services/$NIC_SERVICE/zones/test.com/revisions"
NIC_TOKEN_URL="$NIC_API_URL/oauth/token"

# clear file with token if variable is defined
if [[ "$NIC_CLEAR_TOKEN_FILE" == "true" ]]; then
    rm $NIC_TOKEN_FILE
fi

# if file exists set token from it
if [ -f $NIC_TOKEN_FILE ]; then
    echo "Token file exists: $NIC_TOKEN_FILE. Use access token from file"
    export NIC_ACCESS_TOKEN=$(cat $NIC_TOKEN_FILE | jq -r '.access_token')
else
    echo "Token file does not exists"
fi

if [[ "$NIC_REFRESH_TOKEN" != "" ]]; then
    echo "Variable NIC_REFRESH_TOKEN is defined, use it as refresh token"
    echo "Refresh token: $NIC_REFRESH_TOKEN"
elif [ -f $NIC_TOKEN_FILE ]; then
    echo "Variable NIC_REFRESH_TOKEN is not defined, use refresh token from file"
    export NIC_REFRESH_TOKEN=$(cat $NIC_TOKEN_FILE | jq -r '.refresh_token')
    echo "Refresh token: $NIC_REFRESH_TOKEN"
fi


function refresh_token() {
    CURL_OUTPUT=$(curl -X POST -LSs $NIC_TOKEN_URL -H "Authorization: Basic $NIC_AUTH_BASE64" \
        -d "grant_type=refresh_token&refresh_token=$NIC_REFRESH_TOKEN" -w "\n%{http_code}"
    )
    CURL_STATUS_CODE=$(echo "$CURL_OUTPUT" | tail -n1)
    CURL_OUTPUT=$(echo "$CURL_OUTPUT" | sed \$d)

    if [[ "$CURL_STATUS_CODE" == "200" ]]; then
        echo "Access token has been refreshed and written to file $NIC_TOKEN_FILE"
        echo "$CURL_OUTPUT" > $NIC_TOKEN_FILE
        export NIC_ACCESS_TOKEN=$(echo "$CURL_OUTPUT" | jq -r '.access_token')
        export NIC_REFRESH_TOKEN=$(echo "$CURL_OUTPUT" | jq -r '.refresh_token')
        echo "New refresh token: $NIC_REFRESH_TOKEN"
    else
        echo "Error while refresh token:"
        echo "$CURL_OUTPUT"
        return 1
    fi
}


# check access to API
echo "Checking access to NIC.RU Rest API by GET-request to $NIC_CHECK_ACCESS_URL"
AUTH_CURL_OUTPUT=$(curl -LSs $NIC_CHECK_ACCESS_URL -H "Authorization: Bearer $NIC_ACCESS_TOKEN" -w "\n%{http_code}")
AUTH_CURL_STATUS_CODE=$(echo "$AUTH_CURL_OUTPUT" | tail -n1)
AUTH_CURL_OUTPUT=$(echo "$AUTH_CURL_OUTPUT" | sed \$d)

if [[ "$AUTH_CURL_STATUS_CODE" == "200" ]]; then
    echo "Access token is valid"
    return 0
# if access denied then try to refresh token
elif [[ "$AUTH_CURL_STATUS_CODE" == "401" ]]; then
    echo "Access denied. Output:"
    echo "$AUTH_CURL_OUTPUT"
    if [[ "$NIC_REFRESH_TOKEN" != "" ]]; then
        echo "Trying to refresh access token by refresh_token"
        refresh_token
    else
        echo "Refresh token is not defined. Can not refresh access token"
        return 3
    fi
else
    echo "Error on API request."
    echo "$AUTH_CURL_OUTPUT"
    return 2
fi
nic_download.sh
#!/bin/bash

NIC_API_ZONES_URL="$NIC_API_URL/dns-master/services/$NIC_SERVICE/zones/"

for FILENAME in $ZONES_OUT_DIR/*
do
    ZONE_NAME=$(basename -- "$FILENAME")
    ZONE_URL="$NIC_API_ZONES_URL/$ZONE_NAME"
    echo "Download file $ZONE_URL to $FILENAME"
    curl -X GET -fL $ZONE_URL -H "Authorization: Bearer $NIC_ACCESS_TOKEN" > $FILENAME
    if [[ $? != 0 ]]; then
        echo "Error occured while downloading zone $ZONE_NAME"
        exit 1
    fi
done
nic_upload.sh
#!/bin/bash

NIC_API_ZONES_URL="$NIC_API_URL/dns-master/services/$NIC_SERVICE/zones"

for FILENAME in $ZONES_OUT_DIR/*
do
    ZONE_NAME=$(basename -- "$FILENAME")
    ZONE_URL="$NIC_API_ZONES_URL/$ZONE_NAME"
    echo "Upload file $FILENAME to $ZONE_URL"
    curl -X POST -fL $ZONE_URL -H "Authorization: Bearer $NIC_ACCESS_TOKEN" --data-binary @$FILENAME
    if [[ $? != 0 ]]; then
        echo "Error occured while uploading zone $ZONE_NAME"
        exit 1
    fi
    echo "Commit zone $ZONE_NAME"
    curl -X POST -fL $ZONE_URL/commit -H "Authorization: Bearer $NIC_ACCESS_TOKEN"
    if [[ $? != 0 ]]; then
        echo "Error occured while committing $ZONE_NAME"
        exit 1
    fi
    echo "List revisions of zone ZONE_NAME"
    curl -fL $ZONE_URL/revisions -H "Authorization: Bearer $NIC_ACCESS_TOKEN" | head -n 7
done
zones.conf.sh
#!/bin/bash
for FILENAME in $ZONES_OUT_DIR/*
do
ZONE_NAME=$(basename -- "$FILENAME")
cat <<EOF
zone "$ZONE_NAME." IN {
    type master;
    file "$BIND_ZONES_DIR/$ZONE_NAME";
};
EOF
done
bind_test.sh
#!/bin/bash
shopt -s nocasematch

DNS_SERVER=${1:-"localhost"}
TEST_EXIT_CODE=0

for FILENAME in $BIND_TESTS_DIR/*
do
    # ignore files with .example extenstions
    if [[ $FILENAME != *.example ]]; then
        ZONE_NAME=$(basename -- "$FILENAME")
        while IFS="" read -r line || [ -n "$line" ]; do
            # skip commented lines
            commented_lines_regex="^[[:space:]]*#"
            if [[ $line =~ $commented_lines_regex ]]; then continue; fi

            # convert space-separated string to array
            IFS=' ' read -r -a strarr <<< "$line"

            record="${strarr[0]}"
            # if record is not absolute
            if [[ $record != *. ]]; then record="$record.$ZONE_NAME."; fi
            
            type="${strarr[1]}"

            address="${strarr[2]}"
            # if address is not absolute and is not IP-address
            if [[ $address != *. && ! $address =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then 
                address="$address.$ZONE_NAME."; 
            fi

            dig_comand="dig $type $record @$DNS_SERVER +short +nosearch"
            echo "$ZONE_NAME: Test: $dig_comand"
            echo "$ZONE_NAME: Expected: $address"

            dig_output=$($dig_comand)
            echo "$ZONE_NAME: dig output: $dig_output"

            if [[ "$dig_output" != "$address" ]]; then
                echo "$ZONE_NAME: Test failed: '$dig_output' != '$address'"
                TEST_EXIT_CODE=1
            else
                echo "$ZONE_NAME: Test success!"
            fi
            echo "---------------------------"
        done < $FILENAME
    fi
done

exit $TEST_EXIT_CODE

Результаты

Что нам удалось:

  1. Построить процесс DNS as Code на базе инструмента dnscontrol

  2. Обеспечить выполнение всех практик разработки с помощью Gitlab

  3. Сократить время добавления DNS-записей

  4. Создать единый источник правды для DNS в виде репозитория с кодом

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

Tags:
Hubs:
+13
Comments5

Articles