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:

  1. cert-manager генерирует private key
  2. Формирует CSR, отправляет на ACME-сервер через ClusterIssuer
  3. Выполняет challenge (DNS-01: вызывает webhook, который создаёт TXT-запись)
  4. Получает подписанный сертификат, записывает в spec.secretName как tls.crt + tls.key
  5. Следит за истечением, запускает обновление за 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 реплицирует его.

Порядок миграции:

  1. Добавить неймспейс cert-manager в spoke/namespaces.yaml
  2. Включить HelmRelease cert-manager и reflector в hub overlay нужного окружения
  3. Добавить sealed-secret yc-dns-credentials в spoke/secrets/kustomization.yaml
  4. Задеплоить ClusterIssuer и Certificate в spoke/
  5. Дождаться выдачи сертификата:
bashkubectl get certificate -n ingress-apisix --context=dev-k8s
# NAME                       READY   SECRET             AGE
# dev-wildcard   True    dev-wildcard    2m
  1. Убедиться, что reflector создал зеркала:
bashkubectl get secret dev-wildcard -A --context=dev-k8s
  1. Удалить все secretGenerator-блоки для dev-wildcard из spoke/secrets/kustomization.yaml
  2. Удалить dev-wildcard.crt и dev-wildcard.key из репозитория
  3. Закоммитить и запустить 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 дней до истечения — никаких ремайндеров, никаких инцидентов с протухшими сертификатами