Wildcard TLS с ACME и cert-manager: автоматизация Let's Encrypt через Yandex Cloud DNS
Published: 2026-06-17
Wildcard-сертификат покрывает все поддомены сразу — *.dev.antonnovikov.com закрывает TLS для prometheus.dev.antonnovikov.com, kibana.dev.antonnovikov.com и любого другого сервиса без отдельного сертификата на каждый маршрут. Получение и обновление wildcard требует DNS-01 challenge — никакого HTTP-эндпоинта, только TXT-запись в DNS через API. Разберём, как работает ACME и DNS-01, как cert-manager автоматизирует весь lifecycle в Kubernetes и как встроить это в FluxCD hub-and-spoke GitOps через Yandex Cloud DNS.
Проблема: статические сертификаты в git
Наивный подход работает ровно до первого протухшего сертификата: вручную выпустить wildcard, закоммитить .crt и .key, скопировать в десяток неймспейсов через secretGenerator, примонтировать в ingress. Повторить через 90 дней.
В реальном кластере с 10+ сервисами и несколькими окружениями это означает:
- Файлы сертификатов лежат в git (открытым текстом или запечатанными — всё равно обновляются вручную)
- Ремайндер в календаре «обновить до истечения»
secretGeneratorкопирует один сертификат в 10–15 неймспейсов на каждое окружение- Одно пропущенное обновление — TLS-ошибки по всему окружению
В fluxcd/projects/dev/kustomization/spoke/secrets/kustomization.yaml именно такая картина: один wildcard-сертификат продублирован в 15 secretGenerator-блоках. Каждая ротация сертификата — это обновление двух файлов, коммит и reconcile по всем окружениям.
Решение — автоматизировать весь lifecycle: ACME challenge, выдача, обновление и распределение по неймспейсам без хранения cert material в git.
Как работает ACME
ACME (Automatic Certificate Management Environment, RFC 8555) — протокол, который Let's Encrypt использует для выдачи сертификатов без участия человека. Клиент (cert-manager) создаёт аккаунт на ACME-сервере, запрашивает сертификат, проходит challenge для подтверждения владения доменом и получает подписанный сертификат.
Три типа challenge:
HTTP-01 — ACME-сервер обращается по http://<domain>/.well-known/acme-challenge/<token>. Требует открытый 80-й порт. Не работает для wildcard.
DNS-01 — клиент создаёт TXT-запись _acme-challenge.<domain> с токеном, ACME-сервер проверяет DNS. Работает для wildcard. Требует доступ к API DNS-провайдера.
TLS-ALPN-01 — TLS-хэндшейк на 443 со специальным ALPN-расширением. Нишевый случай, здесь не применяется.
Для *.dev.antonnovikov.com единственный вариант — DNS-01. Let's Encrypt требует его для wildcard в обязательном порядке. Это ограничение протокола, обходных путей нет.
Полная схема взаимодействия:
cert-manager Let's Encrypt Yandex Cloud DNS
│ │ │
│── new-order ──────────▶ │
│◀── challenge: DNS-01 ─│ │
│ │ │
│── create TXT ─────────────────────────────── ▶
│ _acme-challenge.dev.antonnovikov.com = <token> │
│ │ │
│── POST /challenge ─────▶ │
│ │── DNS query TXT ────▶
│ │◀── TXT value ────────│
│◀── cert issued ────────│ │
│ │ │
│── delete TXT ─────────────────────────────── ▶
Сертификаты Let's Encrypt действуют 90 дней. cert-manager по умолчанию обновляет за 30 дней до истечения — настраивается через spec.renewBefore. Обновление запускается автоматически, без участия человека.
cert-manager: ACME-клиент для Kubernetes
cert-manager — стандартный контроллер Kubernetes для управления сертификатами. Вводит три CRD:
ClusterIssuer — конфигурация центра сертификации на уровне кластера. Для ACME: URL сервера, email, ссылка на private key, конфигурация solver'а.
Certificate — запрос сертификата с указанием DNS-имён, срока действия, алгоритма ключа. Ссылается на ClusterIssuer и указывает Secret, куда запишется TLS.
CertificateRequest — одна попытка выдачи, создаётся cert-manager'ом при необходимости обновления.
Что происходит при создании Certificate:
- cert-manager генерирует private key
- Формирует CSR, отправляет на ACME-сервер через ClusterIssuer
- Выполняет challenge (DNS-01: вызывает webhook, который создаёт TXT-запись)
- Получает подписанный сертификат, записывает в
spec.secretNameкакtls.crt+tls.key - Следит за истечением, запускает обновление за 30 дней до него
Результирующий Secret — стандартный kubernetes.io/tls, идентичный тому, что создавал secretGenerator из статических файлов. Никаких изменений в APISIX, ApisixTls или ingress не требуется.
DNS-01 через Yandex Cloud
Поддержка DNS-01 в cert-manager расширяется через webhook'и. Webhook — небольшой сервер, реализующий solver-интерфейс cert-manager: принимает запросы на создание и удаление TXT-записей, вызывает API DNS-провайдера.
Для Yandex Cloud DNS webhook (yandex-cloud/cert-manager-webhook-yandex) требует:
- Сервисный аккаунт YC с ролью
dns.editorна DNS-зону - Авторизованный ключ (JSON) в виде Kubernetes Secret
- Folder ID, в котором живёт DNS-зона
Шаг 1: создать сервисный аккаунт и ключ
bashyc iam service-account create --name cert-manager-dns
FOLDER_ID=$(yc config get folder-id)
yc resource-manager folder add-access-binding \
--id "$FOLDER_ID" \
--role dns.editor \
--service-account-name cert-manager-dns
yc iam key create \
--service-account-name cert-manager-dns \
--output /tmp/yc-key.json
Шаг 2: запечатать ключ и закоммитить
bashkubectl create secret generic yc-dns-credentials \
--from-file=authorized-key.json=/tmp/yc-key.json \
--namespace cert-manager \
--dry-run=client -o yaml > /tmp/yc-dns-secret.yaml
kubeseal --format yaml \
--controller-namespace flux-system \
--kubeconfig .kubeconfigs/dev.yaml \
< /tmp/yc-dns-secret.yaml \
> fluxcd/projects/dev/kustomization/spoke/secrets/yc-dns-credentials.yaml
rm /tmp/yc-key.json /tmp/yc-dns-secret.yaml
Sealed secret добавить в spoke/secrets/kustomization.yaml рядом с остальными секретами.
ClusterIssuer с YC DNS webhook:
yamlapiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@antonnovikov.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
webhook:
groupName: acme.cloud.yandex.com
solverName: yandex-cloud-dns
config:
folder: <FOLDER_ID>
serviceAccountSecretRef:
name: yc-dns-credentials
key: authorized-key.json
Certificate на wildcard:
yamlapiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dev-wildcard
namespace: ingress-apisix
spec:
secretName: dev-wildcard
dnsNames:
- "*.dev.antonnovikov.com"
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 2160h # 90 дней
renewBefore: 720h # обновить за 30 дней до истечения
После выдачи Secret ingress-apisix/dev-wildcard существует под тем же именем, на которое ссылается ApisixTls в fluxcd/projects/dev/kustomization/routes/apisix-tls-tech.yaml. Ничего менять в routes не нужно.
Реализация во FluxCD
cert-manager деплоится как HelmRelease на каждый спок. Поскольку споки управляются с hub через kubeConfig, HelmRelease идёт в fluxcd/custom/apps/cert-manager/.
yaml# fluxcd/custom/apps/cert-manager/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: cert-manager
namespace: cert-manager
spec:
interval: 1h
chart:
spec:
chart: cert-manager
version: "v1.16.*"
sourceRef:
kind: HelmRepository
name: jetstack
namespace: flux-system
values:
crds:
enabled: true
dns01RecursiveNameservers: "8.8.8.8:53,1.1.1.1:53"
dns01RecursiveNameserversOnly: true
dns01RecursiveNameserversOnly: true — cert-manager проверяет TXT-записи только через внешние резолверы, а не через internal cluster DNS. Без этого флага cert-manager может спрашивать CoreDNS, который не знает о публичных зонах, и валидация периодически падает с ошибкой timeout.
Webhook YC DNS — отдельный HelmRelease с dependsOn на cert-manager:
yaml# fluxcd/custom/apps/cert-manager/webhook-yc.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: cert-manager-webhook-yandex
namespace: cert-manager
spec:
interval: 1h
dependsOn:
- name: cert-manager
chart:
spec:
chart: cert-manager-webhook-yandex
sourceRef:
kind: HelmRepository
name: cert-manager-webhook-yandex
namespace: flux-system
Оба HelmRelease нацелены на неймспейс cert-manager, который должен существовать на споке — добавить в fluxcd/projects/{env}/kustomization/spoke/namespaces.yaml перед деплоем.
В fluxcd/base/helmrepositories.yaml добавить три новых репозитория:
yaml---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: jetstack
namespace: flux-system
spec:
interval: 1h
url: https://charts.jetstack.io
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: cert-manager-webhook-yandex
namespace: flux-system
spec:
interval: 1h
url: https://yandex-cloud.github.io/cert-manager-webhook-yandex
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: emberstack
namespace: flux-system
spec:
interval: 1h
url: https://emberstack.github.io/helm-charts
ClusterIssuer и Certificate — ресурсы уровня спока, идут в fluxcd/projects/{env}/kustomization/spoke/ и применяются на споке через {env}-spoke Kustomization.
Распределение сертификата по неймспейсам
cert-manager создаёт Secret в одном неймспейсе (ingress-apisix). Текущий подход с secretGenerator копировал его в 15 неймспейсов. Для замены нужна репликация Secret'ов.
kubernetes-reflector от EmberStack решает именно это: следит за аннотированными Secret'ами и зеркалирует их в указанные неймспейсы.
yaml# fluxcd/custom/apps/reflector/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: reflector
namespace: kube-system
spec:
interval: 1h
chart:
spec:
chart: reflector
version: ">=7.0.0"
sourceRef:
kind: HelmRepository
name: emberstack
namespace: flux-system
Аннотируем Certificate для авторепликации:
yamlapiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dev-wildcard
namespace: ingress-apisix
spec:
secretName: dev-wildcard
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: >-
kube-system,observability,headlamp,elasticsearch,kafka,minio,queue,db,redis,app,aurora,reportportal
dnsNames:
- "*.dev.antonnovikov.com"
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 2160h
renewBefore: 720h
Reflector создаёт зеркальные Secret'ы в каждом из указанных неймспейсов. При обновлении cert-manager'ом — все зеркала обновляются автоматически в течение секунд. Никакого secretGenerator, никаких cert-файлов в git, никакого ручного труда при ротации.
Миграция со статических файлов
Блоки secretGenerator в spoke/secrets/kustomization.yaml удаляются после того, как cert-manager выдаёт сертификат и reflector реплицирует его.
Порядок миграции:
- Добавить неймспейс
cert-managerвspoke/namespaces.yaml - Включить HelmRelease cert-manager и reflector в hub overlay нужного окружения
- Добавить sealed-secret
yc-dns-credentialsвspoke/secrets/kustomization.yaml - Задеплоить
ClusterIssuerиCertificateвspoke/ - Дождаться выдачи сертификата:
bashkubectl get certificate -n ingress-apisix --context=dev-k8s
# NAME READY SECRET AGE
# dev-wildcard True dev-wildcard 2m
- Убедиться, что reflector создал зеркала:
bashkubectl get secret dev-wildcard -A --context=dev-k8s
- Удалить все
secretGenerator-блоки дляdev-wildcardизspoke/secrets/kustomization.yaml - Удалить
dev-wildcard.crtиdev-wildcard.keyиз репозитория - Закоммитить и запустить reconcile
ApisixTls в routes/apisix-tls-tech.yaml трогать не нужно — он по-прежнему ссылается на dev-wildcard в ingress-apisix.
Что может пойти не так
Задержка DNS propagation — DNS-01 требует видимости TXT-записи с публичных резолверов Let's Encrypt до верификации. dns01RecursiveNameservers гарантирует, что cert-manager проверяет через внешние DNS и не считает запись видимой раньше времени. При таймауте challenge перезапускается автоматически. Диагностика:
bashkubectl describe certificaterequest -n ingress-apisix --context=dev-k8s
kubectl logs -n cert-manager -l app=cert-manager --context=dev-k8s | grep -i "error\|challenge"
Ошибка аутентификации webhook — если ключ сервисного аккаунта невалиден или роль dns.editor не назначена, webhook возвращает ошибку и challenge зависает:
bashkubectl logs -n cert-manager -l app.kubernetes.io/name=cert-manager-webhook-yandex \
--context=dev-k8s
Reflector не синхронизирует — если целевой неймспейс не существовал в момент первого запуска, зеркало не создаётся. После появления неймспейса reflector подхватит при следующей проверке (по умолчанию раз в минуту). Форсировать можно, обновив любую аннотацию на исходном Secret'е.
APISIX не подхватывает обновлённый сертификат — APISIX перечитывает TLS через ApisixTls controller. Обычно подхватывает без рестарта, но если нет:
bashkubectl rollout restart deployment apisix -n ingress-apisix --context=dev-k8s
Rate limits Let's Encrypt — лимит production API: 5 одинаковых сертификатов на домен в неделю. При первичной настройке всегда использовать staging:
yamlspec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
Staging-сертификаты не доверенные для браузеров, но issuance flow — идентичный. Переключаться на production только после полной проверки.
Итого
- ACME автоматизирует полный lifecycle TLS — после первичной настройки участие человека не нужно
- Wildcard-сертификаты требуют DNS-01; HTTP-01 для
*.domain.comне работает - cert-manager реализует ACME в Kubernetes через
ClusterIssuerиCertificate - YC DNS webhook создаёт и удаляет TXT-записи через YC DNS API
kubernetes-reflectorраспределяет один Secret по многим неймспейсам через аннотации — заменяетsecretGeneratorсо статическими файлами- Cert material не хранится в git; живёт как Kubernetes Secret под управлением cert-manager
- Автообновление за 30 дней до истечения — никаких ремайндеров, никаких инцидентов с протухшими сертификатами