У вас есть кластер, в котором хочется разместить как можно больше рабочих сервисов и продуктов для внутреннего использования, ибо удобно: систему управления версиями, репозиторий Docker-образов, S3-хранилище, базы данных, тестовые среды и т.д. Но есть "но": многие из них, как например, репозиторий Docker-образов, могут работать только с использованием TLS. У вас есть два пути: получить сертификат от одного из сертификационных центров, платных или бесплатных, вроде Let's Encrypt, или сгенерировать самому. Устанавливать на каждого клиента сертификат нашего "левого" самопровозглашённого центра не хочется, поэтому самоподписанный сертификат отпадает. Денег платить каждый год не хочется, поэтому выбираем Let's Encrypt. Но и тут есть проблема (помимо необходимости автоматизации перевыпуска сертификата не реже раза в три месяца): внутренние сервисы нельзя выставлять в публичную сеть, а значит и HTTP-01 вариант подтверждения владения доменом нам недоступен. Остаётся только DNS-01 метод, который имеет свои подводные камни.

В данной статье я расскажу о своём опыте автоматизации выпуска/перевыпуска wildcard-сертификата для домена второго уровня (wildcard, чтобы два раза не вставать, но можно выпускать сертификаты и для каждого отдельного поддомена) для использования внутренними сервисами, расположенными в Kubernetes-кластере, с помощью cert-manager и бесплатного сервиса поддержки DNS-зон Яндекс.Коннект.

Проблемы DNS-01 метода

Главной проблемой данного метода является необходимость автоматизации создания DNS-записей, которые используются для проверки владения доменом вида _acme-challenge.example.ru. В случае HTTP-01 метода, в котором используются заголовки HTTP-ответов, это реализуется достаточно просто средствами cert-manager. В случае DNS-01 ваш сервис управления DNS-зонами должен поддерживать API для управления записями. Во-первых, далеко не все сервисы имеют этот API. Во-вторых, DNS-зоны являются чувствительным ресурсом с точки зрения безопасности, и давать полный доступ к корневой зоне вашего домена какому-то скрипту не всегда есть возможность. Например, это могут запрещать безопасники компании. И, наконец, в-третьих, есть серьёзная проблема со скоростью распространения изменений DNS-записей.

Яндекс.Коннект, которым пользуюсь я, имеет такой API, и он неплохо работает, насколько я успел оценить. cert-manager позволяет написать своё расширение для API вашего провайдера. Для Яндекс.Коннект такого расширения не было, хотя список webhook-расширений довольно приличный. Есть, например, Яндекс.Облако. Мне пришлось написать свой: cert-manager-webhook-yandex-connect. За основу взял другой подобный cert-manager-webhook-gandi, выучил Golang, благо язык не сложный, и адаптировал под другой API. Я не буду писать здесь о принципах работы webhook и его разработке, ничего сложного там нет, кода получается с гулькин нос. Однако, это оказалось тупиковым решением, и вот почему.

Как это должно работать на бумаге. Скажем, есть у вас домен example.ru. Вы хотите выпустить wildcard-сертификат для доменов следующего уровня (*.example.ru) или для какого-то конкретного поддомена (test.example.ru). Вы устанавливаете рядом с cert-manager webhook для вашего DNS-провайдера, создаёте Issuer, запрашиваете Certificate. cert-manager создаёт нужную TXT DNS-запись вида _acme-challenge.example.ru (для *.example.ru), удостоверяется в её наличии, запрашивает у Let's Encrypt выпуск сертификата. Let's Encrypt удостоверяется в наличии указанной TXT-записи, правильности её содержимого и выдаёт сертификат. Задача выполнена!

Однако, гладко было на бумаге, но забыли про овраги. Дело в том, как работают DNS-сервера. Есть два типа DNS-серверов: авторитетные (authoritative) и рекурсивные (recursive). Первые используются в качестве первичного источника данных для DNS-зон. В моём случае это Яндекс.Коннект. Последние для ответов на запросы клиентов, например, вашего браузера. Let's Encrypt, в отличии от большинства клиентов, общается не с рекурсивными серверами, а только напрямую с авторитетными. Вроде бы всё хорошо, но вступает в силу архитектура современного DNS-сервера. Клиентов много, запросов ещё больше, значит нужно масштабировать сервера. Вертикальное масштабирование (один сервер с очень хорошим железом) имеет свой потолок. Используется горизонтальное масштабирование, то есть серверов в пуле много. Много серверов, значит много источников данных, что, в свою очередь, означает отсутствие консистентности данных. То есть два последовательных запроса через секунду для одной и той же записи могут выдать разные ответы: на одном запись есть, на другом нет. Усугубляет проблему наличие кэша у каждого из серверов в пуле. Всё это приводит к тому, что распространение (propagation) DNS-записи занимает какое-то время (где-то сутки-двое) и первое время отличается нестабильностью ответов. Это справедливо и для обоих видов серверов. В моём случае я наблюдал этот эффект на dns1.yandex.net и dns2.yandex.net. При этом указанный TTL DNS-серверы не соблюдают. TTL == 300 ничего для них не значит, ровно как и TTL == 60.

Если добавить TXT-запись и сделать паузу на пару суток, всё будет хорошо, но cert-manager так не умеет. Как только он заметил первый раз запись на авторитетном сервере, он сразу делает запрос на сертификат. Но хуже того, по крайней мере в случае Яндекс.Коннект, TXT-запись может не появляется в течение некоторого периода времени, который выделяет на это cert-manager. Проще говоря, есть таймаут (30 минут, если я понял правильно) на появление записи. Если DNS-сервер не уложился, попытка считается проваленной и cert-manager сдаётся и засыпает где-то на час-два, чтобы попытаться вновь. В итоге эта тягомотина может продолжаться днями, а может и выстрелить с первого раза, как это у меня получилось для тестового домена ровно один раз (звёзды сошлись).

Что предлагает документация cert-manager делать с этой проблемой? Почти ничего. Рекомендуют добавить ключи, указывающие использовать только указанные рекурсивные сервера. Но проблема остаётся прежней — кэширование и задержки распространения. Есть ещё issue, косвенно затрагивающее эту проблему, но оно по-прежнему не закрыто.

Как надёжно решить эту проблему

Единственный рабочий вариант, пригодный даже для DNS-серверов без API — это развернуть свой собственный DNS-сервер, в скорости реакции которого вы будете уверены. Звучит сложно и трудозатратно, но это не совсем так.

Я, разумеется, далеко не первый инженер столкнувшийся с этой проблемой. Уже давно есть легковесный DNS-сервер специально предназначенный для этой задачи — ACME DNS. На Хабре даже была переводная статья автора данного приложения, но она носит скорее рекламный характер. Я же расскажу как развернуть этот сервер в кластере, с какими проблемами и задачами можно столкнуться, и как их решить.

В двух словах об ACME DNS. Это легковесный DNS-сервер позволяющий публиковать по внешнему запросу только TXT-записи. cert-manager умеет с ним работать. Сам проект заброшен автором года два назад, но текущий вариант вполне работоспособен. Есть его форки, в которых обновляют зависимости, например этот. Однако, в данной статье я опираюсь на исходный репозиторий.

Принцип работы ACME DNS довольно прост. Он предоставляет API с двумя методами: register и update. Первый позволяет зарегистрировать новый поддомен для DNS-проверки. Второй обновить TXT DNS-записи для ранее зарегистрированного домена.

Ещё одно замечание относительно развёртывания. В принципе, есть публичный сервис ACME DNS, которым можно пользоваться, но сколько он проживёт и насколько это безопасно, не известно. Поэтому я решил всё же развернуть свой.

Разворачиваем ACME DNS в кластере

Автор ACME DNS не предусмотрел развёртывание в Kubernetes, только в виде standalone-приложения и в Docker. Мне не удалось достаточно быстро найти helm-chart, поэтому я решил наколхозить своё решение. Нет, не чарт, мне было лень изучать синтаксис шаблонов Helm, да и не нравится он мне. Я просто сделал набор Kubernetes-ресурсов, который каждый может адаптировать под свои условия.

Пространство имён

ACME DNS лучше всего разместить в собственном пространстве имён, поэтому первым делом создаём его:

apiVersion: v1
kind: Namespace
metadata:
  name: acme-dns

Statefulset

ACME DNS хранит свои данные в базе данных. Поддерживается SQLite и PostgreSQL. Для дома, для семьи достаточно первого, для организации лучше воспользоваться вторым. Я использую SQLite, а значит нужен PVC. Я использую Longhorn с динамическим выделением, но можно использовать любой другой, подразумевающий постоянное хранение (не hostPath какой-нибудь).

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: acme-dns
  namespace: acme-dns
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: acme-dns
      app.kubernetes.io/instance: acme-dns
  serviceName: acme-dns
  template:
    metadata:
      labels:
        app.kubernetes.io/name: acme-dns
        app.kubernetes.io/instance: acme-dns
    spec:
      containers:
      - name: acme-dns
        image: joohoi/acme-dns:v0.8
        ports:
        - name: dns-udp
          containerPort: 53
          protocol: UDP
        - name: dns-tcp
          containerPort: 53
          protocol: TCP
        - name: http
          containerPort: 80
          protocol: TCP
        - name: https
          containerPort: 443
          protocol: TCP
        livenessProbe:
          httpGet:
            path: /health
            port: http
        readinessProbe:
          httpGet:
            path: /health
            port: http
        volumeMounts:
        - name: config
          mountPath: /etc/acme-dns
        - name: data
          mountPath: /var/lib/acme-dns
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 100m
            memory: 128Mi
      nodeSelector:
        kubernetes.io/hostname: "worker01"
      volumes:
      - name: config
        configMap:
          name: acme-dns-config
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: longhorn
      resources:
        requests:
          storage: 10Mi

На что стоит обратить внимание. Во-первых, почему StatefulSet? Поскольку у нас есть долгоживущие данные, необходимо обеспечить преемственность привязки томов к подам. Во-вторых, high availability нам не нужен, поэтому только один экземпляр. В-третьих, привязка к определённому рабочему узлу. Это сыграет свою роль, когда мы будем настраивать внешний роутер для доступа к ACME DNS извне нашего контура.

Далее, порты. С 53/UDP и 53/TCP всё понятно — это DNS. С 80 и 443 немного сложнее. Понятное дело, они нам нужны для доступа к HTTP API, но нужен по факту только один. Дело в том, что ACME DNS умеет выпускать Let's Encrypt сертификаты для себя самостоятельно, но я решил не использовать эту возможность, так как доступ к этому API нужен только изнутри кластера. Если так хочется закрыть эту дырку, лучше настроить RBAC так, чтобы доступ к API был разрешён только из пространства имён cert-manager. Поэтому, технически, нужен только 80 порт, 443 можно удалить.

ACME DNS нужны два источника данных: его конфигурация и база данных с регистрациями и DNS-записями. Первое будет храниться в ConfigMap, второе, как я сказал ранее, в томе Longhorn. 10 МБ хватает за глаза. Судя по консоли Longhorn, у меня занято 4.5 МБ. Это с одной регистрацией и несколькими записями. Думаю, даже с несколькими регистрациями этот объём не сильно вырастет.

Указанных ресурсов (процессор и память) вполне хватает. Скорее всего, хватит и меньше, но это нужно собирать статистику.

Сервисы

ACME DNS предоставляет два различных сервиса: сервис управления и, собственно, DNS-сервис. Поэтому нужно создать два сервиса.

apiVersion: v1
kind: Service
metadata:
  name: acme-dns-control
  namespace: acme-dns
  labels:
    app.kubernetes.io/name: acme-dns
    app.kubernetes.io/instance: acme-dns
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
  - name: https
    port: 443
    protocol: TCP
  selector:
    app.kubernetes.io/name: acme-dns
    app.kubernetes.io/instance: acme-dns
---
apiVersion: v1
kind: Service
metadata:
name: acme-dns-dns
namespace: acme-dns
spec:
type: NodePort
selector:
app.kubernetes.io/name: acme-dns
app.kubernetes.io/instance: acme-dns
ports:
- name: dns-udp
port: 53
targetPort: 53
nodePort: 30053
protocol: UDP
- name: dns-tcp
port: 53
targetPort: 53
nodePort: 30053
protocol: TCP

Первый, сервис управления, — обычный кластерный сервис. Второй, DNS-сервис, — NodePort-сервис, который мы будем выставлять наружу для Let's Encrypt. Порты nodePort фиксированные, иначе не получится настроить внешний роутер.

Настройки ACME DNS

Пожалуй, с этими настройками я промаялся дольше всего. Хранить мы их будем, понятное дело, в виде ConfigMap, который подключается к StatefulSet.

apiVersion: v1
kind: ConfigMap
metadata:
  name: acme-dns-config
  namespace: acme-dns
data:
  config.cfg: |
    [general]
    # DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
    # In this case acme-dns will error out and you will need to define the listening interface
    # for example: listen = "127.0.0.1:53"
    listen = "0.0.0.0:53"
    # protocol, "both", "both4", "both6", "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
    protocol = "both4"
    # domain name to serve the requests off of
    domain = "acme-dns.example.ru"
    # zone name server
    nsname = "acme-dns.example.ru"
    # admin email address, where @ is substituted with .
    nsadmin = "acme@example.ru"
    # predefined records served in addition to the TXT
    records = [
        # domain pointing to the public IP of your acme-dns server
        "acme-dns.example.ru. A 25.25.25.25",
        # specify that acme-dns.example.ru will resolve any *.acme-dns.example.ru records
        "acme-dns.example.ru. NS acme-dns.example.ru.",
    ]
    # debug messages from CORS etc
    debug = false

    [database]
    # Database engine to use, sqlite3 or postgres
    engine = "sqlite3"
    # Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres
    # Please note that the default Docker image uses path /var/lib/acme-dns/acme-dns.db for sqlite3
    connection = "/var/lib/acme-dns/acme-dns.db"
    # connection = "postgres://user:password@localhost/acmedns_db"

    [api]
    # listen ip eg. 127.0.0.1
    ip = "0.0.0.0"
    # disable registration endpoint
    disable_registration = false
    # listen port, eg. 443 for default HTTPS
    port = "80"
    # possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
    tls = "none"
    # only used if tls = "cert"
    tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
    tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
    # only used if tls = "letsencrypt"
    acme_cache_dir = "api-certs"
    # optional e-mail address to which Let's Encrypt will send expiration notices for the API's cert
    notification_email = ""
    # CORS AllowOrigins, wildcards can be used
    corsorigins = [
        "*"
    ]
    # use HTTP header to get the client ip
    use_header = false
    # header name to pull the ip address / list of ip addresses from
    header_name = "X-Forwarded-For"

    [logconfig]
    # logging level: "error", "warning", "info" or "debug"
    loglevel = "debug"
    # possible values: stdout, TODO file & integrations
    logtype = "stdout"
    # file path for logfile TODO
    # logfile = "./acme-dns.log"
    # format, either "json" or "text"
    logformat = "text"

На что стоит обратить внимание?

ACME DNS умеет работать по UDP и TCP с использованием IPv4 и IPv6. Я не использую IPv6 и на всякий случай решил оставить и UDP и TCP. Поэтому protocol = "both4".

Слушать нужно на всех интерфейсах пода. По началу я поставил рекомендованное 127.0.0.1:53, но сервис был недоступен. Поэтому поставил listen = "0.0.0.0:53".

domain — это внешнее имя вашего DNS-сервера, на которое он должен отзываться. Что такое nsname я до конца не понял, но в оригинальной документации оно равно значению domain.

records — важный элемент. Я пробовал оставлять этот список пустым. Мне казалось, что достаточно аналогичных записей, которые указаны на авторитетном DNS-сервере моего домена второго уровня (в статье это — example.ru), но DNS-сервер не работал. DNS-записи создавались, но не были видны извне.

engine — где ACME DNS будет хранить свои данные. В моём случае — SQLite. connection для SQLite должен указывать на файл в папке тома, указанного в StatefulSet.

В секции api настраиваются параметры для управляющего API. В моём случае я не использую HTTPS, поэтому достаточно настроить ipport и tls = "none". Всё остальное нужно для HTTPS. Если вам вдруг понадобится HTTPS, будет нужно настроить ещё один том в StatefulSet, в котором будут храниться TLS-сертификаты в секретах.

loglevel полезно установить в debug. Логов не так много, но по первости полезно видеть всё, что происходит с ACME DNS, чтобы искать причины проблем.

Одна из причин, почему я не стал делать helm-chart, состоит в том, что хочется облагородить генерацию настроек, а это требует времени, а востребованность этого чарта не ясна. С моей точки зрения, достаточно один раз настроить, а дальше всё будет работать само.

Развёртывание и запуск

Последовательно применяем описанные выше ресурсы с помощью kubectl apply -f ... и проверяем, что под запустился:

NAME             READY   STATUS    RESTARTS   AGE
pod/acme-dns-0   1/1     Running   0          4d22h
NAME                       TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                     AGE
service/acme-dns-control   ClusterIP   10.29.63.13     <none>        80/TCP,443/TCP              7d19h
service/acme-dns-dns       NodePort    10.29.163.135   <none>        53:30053/UDP,53:30053/TCP   7d19h
NAME                        READY   AGE
statefulset.apps/acme-dns   1/1     7d18h

Настройка внешнего роутера

Я использую роутер для обеспечения доступа вовне и внутрь моей сети. Не стану детально расписывать конфигурацию роутера, так как у каждого роутер свой, а значит и необходимые настройки свои. Опишу лишь общие моменты.

  1. Две записи dst-nat, маршрутизирующая пакеты на порт 53/UDP и 53/TCP на worker-node-ip:node-port. Если, например, IP адрес рабочего узла worker01 кластера, указанного в StatefulSet .spec.template.spec.nodeSelector.kubernetes.io/hostname, равен 10.29.29.11, а в сервисе acme-dns-dns .spec.ports.[<port>].nodePort равен 30053, это значение должно быть 10.29.29.11:30053.

  2. Две записи firewall в цепочке forwarding, разрешающие пакеты на порты 30053/UDP и 30053/TCP с WAN-интерфейса на 10.29.29.11.

Регистрация домена для проверки владения доменом

Напоминаю, что ACME DNS API имеет два метода, один из которых register. Нам необходимо вызвать его, чтобы зарегистрировать домен.

Запускаем в кластере pod, содержащий CURL, например:

kubectl run curl --image=radial/busyboxplus:curl -i --tty

Далее есть два варианта. Можно ограничить список IP, с которых могут приходить запросы на обновление DNS-записей (метод API update), а можно не ограничивать. Я покажу вариант с ограничением, он более безопасен, для второго нужно просто не передавать что-либо в body.

curl -X POST http://acme-dns-control.acme-dns.svc.cluster.local/register
    -H "Content-Type: application/json" \
    --data '{"allowfrom": ["192.168.0.0/16"]}'

Здесь acme-dns-control.acme-dns.svc.cluster.local — адрес сервиса управления ACME DNS, а 192.168.0.0/16 — pod CIDR, который можно получить, выполнив команду:

kubectl cluster-info dump | grep -m 1 cluster-cidr

Вы должны получить ответ, содержащий настройки авторизации, вроде этого:

{
  "username": "eabcdb41-d89f-4580-826f-3e62e9755ef2",
  "password": "pbAXVjlIOE01xbut7YnAbkhMQIkcwoHO0ek2j4Q0",
  "fulldomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru",
  "subdomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf",
  "allowfrom": ["192.168.0.0/16"]
}

Секрета с настройками авторизации ACME DNS

Создаём файл, registration.json на основе предыдущего вывода:

{
  "example.ru": {
    "username": "eabcdb41-d89f-4580-826f-3e62e9755ef2",
    "password": "pbAXVjlIOE01xbut7YnAbkhMQIkcwoHO0ek2j4Q0",
    "fulldomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru",
    "subdomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf",
    "allowfrom": ["192.168.0.0/16"]
  }
}

Создаём Kubernetes-секрет в пространстве имён cert-manager на основе этого файла:

kubectl create -n cert-manager secret generic acme-dns --from-file registration.json

Если вы планируете использовать данный экземпляр ACME DNS для подтверждения нескольких доменов второго уровня, можно использовать ту же регистрацию. В этом случае JSON-файл должен выглядеть следующим образом:

{
  "example.ru": {
    "username": "eabcdb41-d89f-4580-826f-3e62e9755ef2",
    "password": "pbAXVjlIOE01xbut7YnAbkhMQIkcwoHO0ek2j4Q0",
    "fulldomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru",
    "subdomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf",
    "allowfrom": ["192.168.0.0/16"]
  },
  "another-example.ru": {
    "username": "eabcdb41-d89f-4580-826f-3e62e9755ef2",
    "password": "pbAXVjlIOE01xbut7YnAbkhMQIkcwoHO0ek2j4Q0",
    "fulldomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru",
    "subdomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf",
    "allowfrom": ["192.168.0.0/16"]
  }
}

DNS-записи доменной зоны второго уровня

Теперь нам нужно добавить DNS-записи в зону второго уровня. В данной статье — это example.ru.

  1. A-запись для нашего DNS-сервера: acme-dns.example.ru A 25.25.25.25acme-dns.example.ru — адрес, указанный в конфигурации ACME DNS в параметре domain25.25.25.25 — внешний статический адрес вашего роутера. TTL можно указывать любой, я предпочитаю ставить 300, но, как я описал выше, это ни на что не влияет из-за особенностей архитектуры DNS-серверов.

  2. CNAME-запись для проверки владения доменом: _acme-challenge.example.ru CNAME d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru. Здесь _acme-challenge.example.ru — адрес, по которому должна быть расположена TXT-запись с кодом проверки владения доменом, с точки зрения Let's Encrypt. d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru — адрес, на который Let's Encrypt на самом деле пошлёт запрос, он так умеет.

Для получения сертификатов для доменов example.ru и *.example.ru этого достаточно. Однако, если вам нужно будет получить сертификат для домена третьего уровня, например, test.example.ru или *.test.example.ru, вам нужно будет создать такую запись:

_acme-challenge.test.example.ru CNAME d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru

...и так для каждого конкретного домена третьего уровня. Но это не проблема, мы же всё это затеяли для сертификатов со звёздочкой! ???? В качестве утешения скажу, что CNAME-записи почему-то (может мне опять повезло?) появляются быстрее, чем A-записи.

RBAC для API ACME DNS

Настройку RBAC показывать не стану, там всё достаточно просто. Нужно разрешить доступ к API ACME DNS только от сервисной учётной записи пространства имён cert-manager.

Issuer ACME DNS для Let's Encrypt

Перед тем, как создавать Issuer cert-manager, необходимо дождаться уверенной доступности наших DNS-записей. Это можно сделать с помощью, например, данного online-инструмента. Поле Server должно указывать на ваш авторитетный DNS-сервер доменной зоны второго уровня. Например, для Яндекс.Коннект — это dns1.yandex.net. Поле Query устанавливаем в ANY. Ну или воспользоваться утилитой dig. Далее какие записи нужно проверять:

  1. Domain: acme-dns.example.ru. Вывод должен содержать A-запись и NS-запись:

...

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;acme-dns.example.ru.		IN	ANY
;; AUTHORITY SECTION:
acme-dns.example.ru.	300	IN	NS	acme-dns.example.ru.
;; ADDITIONAL SECTION:
acme-dns.example.ru.	300	IN	A	25.25.25.25

...
  1. Domain: _acme-challenge.example.ru. Вывод должен содержать CNAME-запись:

...

;; QUESTION SECTION:
;_acme-challenge.example.ru.	IN	ANY
;; ANSWER SECTION:
_acme-challenge.example.ru. 21600	IN	CNAME	d420c923-bbd7-4056-ab64-c3ca54c9b3cf.acme-dns.example.ru.

...

Когда эти записи будут появляться при каждом запросе, а не через раз, можно создать Issuer.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-production
spec:
  acme:
    # The ACME server URL
    server: https://acme-v02.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: acme@example.ru
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-production
    solvers:
    - dns01:
        cnameStrategy: Follow
        acmeDNS:
          host: http://acme-dns-control.acme-dns.svc.cluster.local:80
          accountSecretRef:
            name: acme-dns
            key: registration.json

Я показываю вариант для продуктового окружения Let's Encrypt. Для тестового окружения поменяйте https://acme-v02.api.letsencrypt.org/directory на https://acme-staging-v02.api.letsencrypt.org/directory и в названиях вместо production используйте staging.

Здесь стоит обратить внимание на следующие моменты:

  1. Я использую кластерный Issuer, так как предполагается, что выпускаем мы wildcard-сертификат, а значит он не принадлежит пространству имён ни одного приложения.

  2. Обязательно укажите свой корректный e-mail адрес в поле .spec.acme.email.

  3. .spec.acme.solvers[dns01].cnameStrategy обязательно должно быть установленно в Follow.

  4. .spec.acme.solvers[dns01].acmeDNS.host указывает на сервис управления ACME DNS.

  5. .spec.acme.solvers[dns01].acmeDNS.accountSecretRef содержит указание на секрет и его поле, в котором содержится JSON с данными регистрации, которую мы выполнили ранее.

Выпуск сертификата и копирование TLS-сертификата в пространства имён приложений

Настало время выпустить сам сертификат. Для этого создаём следующий ресурс.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-example-ru-production
  namespace: cert-manager
spec:
  dnsNames:
  - '*.example.ru'
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer
  secretName: wildcard-example-ru-production
  secretTemplate:
    annotations:
      reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
      reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "harbor"
      reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
      reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "harbor"

Дожидаемся окончания выпуска сертификата, мониторя состояние сертификата wildcard-example-ru-production:

kubectl get -n cert-manager certificate wildcard-example-ru-production

Когда сертификат будет выпущен, в колонке READY False поменяется на True, и TLS-сертификат будет сохранён в секрете wildcard-example-ru-production. Если за несколько минут это не произойдёт, смотрим (describe) состояние ресурсов типа certificaterequests.cert-manager.ioorders.acme.cert-manager.iochallenges.acme.cert-manager.io, а также логи pod acme-dns-0.

Сертификат предназначен для нескольких приложений. Кластерных сертификатов в cert-manager нет, поэтому размещаем его в пространстве имён самого cert-manager. Секрет с TLS-сертификатом будет создан в этом же пространстве имён, но нужен он нам в пространствах имён приложений. Для начала я собираюсь использовать его для Harbor, который будет развёрнут в пространстве имён harbor. Секреты в Kubernetes всегда принадлежат какому-либо пространству имён, а значит нам нужно как-то скопировать секрет wildcard-example-ru-production в пространство имён harbor, где он будет использован, в частности, в ingress-ресурсе. Как быть? Можно скопировать руками, но каждые 3 месяца придётся повторять это упражнение. Можно наколхозить свою автоматизацию, но проще воспользоваться уже готовым решением.

На странице Syncing Secrets Across Namespaces документации cert-manager предложено 3 варианта: reflectorkubedkubernetes-replicator. Мне больше понравился первый. С моей точки зрения, он более логичен и лучше поддерживается, но что выбрать, зависит от вас. Установка описана в readme проекта и не вызывает каких-либо проблем, поэтому описывать её не стану.

cert-manager позволяет указать аннотации и метки для создаваемых секретов с TLS-сертификатами, что весьма удобно. В моём случае секрет будет скопирован в пространство имён harbor, в чём лего убедиться:

$ kubectl get -n harbor secrets wildcard-example-ru-production
NAME                             TYPE                DATA   AGE
wildcard-example-ru-production   kubernetes.io/tls   2      6d10h

Если в последствии вам понадобится (а вам понадобится) скопировать секрет TLS-сертификата в другое пространство имён, нужно редактировать не секрет, а сертификат cert-manager, добавляя в аннотации имена нужных пространств имён через запятую. Например, так:

...
  secretTemplate:
    annotations:
      reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
      reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "harbor,web-app,longhorn"
      reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
      reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "harbor,web-app,longhorn"

Изменения, сделанные в самом секрете пропадут.

На этом, пожалуй, всё. Сертификаты будут обновляться по расписанию, особенности архитектуры DNS-серверов не будут ставить нам палки в колёсах и делать этот процесс непредсказуемым. Мы можем выпускать TLS-сертификаты в том числе и для внутренних сервисов, не смотрящих в публичную Сеть, что невозможно с помощью более простого способа HTTP-01.

Благодарю за прочтение. Надеюсь, кому-нибудь пригодится материал данной статьи.

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


  1. sukharichev
    05.07.2022 16:54
    +2

    Статья интересная, спасибо.
    и давать полный доступ к корневой зоне вашего домена какому-то скрипту не всегда есть возможность
    Если вдруг у вас уже есть свой DNS-сервер(ы) в инфраструктуре, то давать полный доступ скриптам не надо, вот по этому прекрасному рецепту
    https://www.umgum.com/letsencrypt-wildcard-bind9
    я делал субзону _acme-challenge.domain.com и давал на нее скрипту права только по TSIG и только на TXT. Работает! Пользуясь случаем, спасибо автору. Подходит для любого современного ДНС поддерживающего TSIG. Если у кого-то более сложная ситуация - primary DNS свой (например, hidden master) а secondary DNS у регистратора или хостера, то такую зону передать на них без api действительно не получится, но в случае с bind9 это решается просто - у него есть директива include. делаете делаете минимальный файл основной зоны, инклюдите еще один и все ручные изменения делаете во втором, а первый пусть обновляется скриптом с помощью rndc. rndc делает первый файл некрасивым, но никогда его не ломает. А на secondary рассылаете notify.


    1. MikeEshva Автор
      05.07.2022 22:59
      +1

      Благодарю за ценное дополнение!

      Будет случай и необходимость настраивать корпоративную сеть, обязательно воспользуюсь. А пока ознакомлюсь для расширения кругозора.


  1. Vamp
    05.07.2022 18:16
    +2

    но можно выпускать сертификаты и для каждого отдельного поддомена

    Лучше не надо, так как это раскрывает состав кластера через публичные certificate transparency логи.


    1. MikeEshva Автор
      05.07.2022 23:04
      +1

      Благодарю за ценное замечание!

      Я, скорее, имел в виду в данном контексте не внутренние сервисы, а публичные. Бывает всякое. Решение моё работает и для публичных сервисов. Не всегда админы, особенно в большой организации со строгими правилами, позволяют всем командам использовать wildcard-сертификаты: у каждого продукта должен быть отдельный сертификат.

      Но для внутренних сервисов полностью согласен.


    1. MikeEshva Автор
      05.07.2022 23:31

      Статья на Хабре про эту технологию для интересующихся: Обзор Certificate Transparency.


    1. sukharichev
      06.07.2022 08:15
      +2

      Интересно, я бы про такое не подумал. Но это решается довольно просто - делается 1 технический поддомен типа infra.domain.com и вайлдкард делается на него, а там уже плодятся всякие srv1.infra.domain.com и т. п.