У вас есть кластер, в котором хочется разместить как можно больше рабочих сервисов и продуктов для внутреннего использования, ибо удобно: систему управления версиями, репозиторий 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, поэтому достаточно настроить ip
, port
и 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
Настройка внешнего роутера
Я использую роутер для обеспечения доступа вовне и внутрь моей сети. Не стану детально расписывать конфигурацию роутера, так как у каждого роутер свой, а значит и необходимые настройки свои. Опишу лишь общие моменты.
Две записи 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
.Две записи 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
.
A
-запись для нашего DNS-сервера:acme-dns.example.ru A 25.25.25.25
.acme-dns.example.ru
— адрес, указанный в конфигурации ACME DNS в параметреdomain
.25.25.25.25
— внешний статический адрес вашего роутера. TTL можно указывать любой, я предпочитаю ставить300
, но, как я описал выше, это ни на что не влияет из-за особенностей архитектуры DNS-серверов.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. Далее какие записи нужно проверять:
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
...
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
.
Здесь стоит обратить внимание на следующие моменты:
Я использую кластерный Issuer, так как предполагается, что выпускаем мы wildcard-сертификат, а значит он не принадлежит пространству имён ни одного приложения.
Обязательно укажите свой корректный e-mail адрес в поле
.spec.acme.email
..spec.acme.solvers[dns01].cnameStrategy
обязательно должно быть установленно вFollow
..spec.acme.solvers[dns01].acmeDNS.host
указывает на сервис управления ACME DNS..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.io
, orders.acme.cert-manager.io
, challenges.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 варианта: reflector, kubed, kubernetes-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.
Благодарю за прочтение. Надеюсь, кому-нибудь пригодится материал данной статьи.