Kubernetes для самых маленьких. Часть 19: Как cert-manager выпускает сертификаты?

Все части серии «Kubernetes для самых маленьких»

В прошлой части Traefik стал единой точкой входа на 192.168.10.200, а шлюз apps получил сертификат *.apps.example.com — самоподписанный, выпущенный руками через openssl на тридцать дней. Через месяц он истечёт, браузеры ему не доверяют, а ключ лежал на ноутбуке. Сегодня поручим сертификаты кластеру: cert-manager 1.21 сам получает их у Let's Encrypt по протоколу ACME или у собственного центра сертификации, кладёт в Secret и продлевает заранее. Как устроены сертификаты, цепочки доверия и ACME, подробно разобрано в части 23 сетевой серии; здесь — что из этого делает Kubernetes и какие объекты появляются в API.

Стенд — как в конце прошлой части: неймспейс demo с Deployment и сервисом hello по части 11 и части 15, MetalLB 0.16.1 в режиме L2 по части 17, CRD Gateway API 1.6.3, Traefik 3.7.16 со шлюзом apps и два Ingress для имени hello-ingress.apps.example.com: hello на точке входа websecure с TLS из Secret hello-ingress-tls и hello-redirect, который перенаправляет HTTP на HTTPS. Secret hello-ingress-tls в прошлой части мы создавали руками; сейчас его нет, и выпустит его cert-manager — настоящий, от Let's Encrypt. Для этого роутер пробрасывает порты 80 и 443 со своего публичного адреса 203.0.113.10 на 192.168.10.200, а в публичной зоне DNS есть запись hello-ingress.apps.example.com A 203.0.113.10.


Что такое cert-manager

cert-manager — проект CNCF (Cloud Native Computing Foundation, фонд, где развиваются Kubernetes и связанные проекты), набор контроллеров и CRD, которые превращают сертификат в объект API. Пишем «хочу сертификат на такие имена от такого издателя, хранить в таком Secret» — контроллер добивается, чтобы так было, и следит, чтобы так оставалось. Это тот же цикл согласования, что у Deployment, только желаемое состояние — действующий сертификат.

Установка состоит из трёх Deployment в неймспейсе cert-manager:

  • cert-manager (controller) — основной контроллер: следит за Certificate, Issuer и остальными ресурсами, ходит к ACME-серверам, создаёт Secret;
  • cert-manager-webhook — admission-вебхук: API-сервер отправляет ему каждый объект cert-manager перед записью в etcd, вебхук подставляет значения по умолчанию и проверяет объект, и ошибка в манифесте видна сразу при kubectl apply, а не потом в журнале контроллера;
  • cert-manager-cainjector — вписывает сертификаты CA в поле caBundle объектов, которым API-сервер должен доверять при обращении к вебхукам (ValidatingWebhookConfiguration, MutatingWebhookConfiguration, CRD с конверсией). Первым делом он обслуживает сам cert-manager: у его вебхуков стоит аннотация cert-manager.io/inject-ca-from-secret: cert-manager/cert-manager-webhook-ca.

Установка

Официальный манифест ставит всё сразу, вместе с CRD. Ставим текущий выпуск 1.21.3:

$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.3/cert-manager.yaml
namespace/cert-manager created
customresourcedefinition.apiextensions.k8s.io/challenges.acme.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/orders.acme.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/certificaterequests.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/certificates.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/clusterissuers.cert-manager.io created
customresourcedefinition.apiextensions.k8s.io/issuers.cert-manager.io created
serviceaccount/cert-manager-cainjector created
serviceaccount/cert-manager created
serviceaccount/cert-manager-webhook created
...
deployment.apps/cert-manager-cainjector created
deployment.apps/cert-manager created
deployment.apps/cert-manager-webhook created
mutatingwebhookconfiguration.admissionregistration.k8s.io/cert-manager-webhook created
validatingwebhookconfiguration.admissionregistration.k8s.io/cert-manager-webhook created

Роли для выбора лидера манифест кладёт в kube-system: там же контроллер и cainjector держат свои Lease cert-manager-controller и cert-manager-cainjector-leader-election. Это стоит помнить при удалении — Lease останутся, если удалить только неймспейс.

Интеграция с Gateway API у cert-manager по умолчанию выключена — в журнале только что запущенного контроллера есть строка "skipping disabled controller" ... controller="gateway-shim". Включается она флагом контроллера --enable-gateway-api. Добавим его к аргументам контейнера JSON-патчем:

$ kubectl -n cert-manager patch deployment cert-manager --type=json \
    -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--enable-gateway-api"}]'
deployment.apps/cert-manager patched
$ kubectl -n cert-manager logs deploy/cert-manager | grep -i gateway
I1029 13:15:44.246719       1 options.go:296] "enabling the sig-network Gateway API certificate-shim and HTTP-01 solver" logger="cert-manager"

Включились две вещи: gateway-shim, который выпускает сертификаты по аннотациям на Gateway, и решатель HTTP-01 через HTTPRoute — о них ниже. CRD Gateway API к этому моменту уже стоят (их мы поставили в прошлой части), и это важно: контроллер проверяет их наличие только при старте. Если поставить их позже, cert-manager нужно перезапустить — kubectl -n cert-manager rollout restart deployment cert-manager.

Тот же результат даёт одна команда Helm: helm install cert-manager oci://quay.io/jetstack/charts/cert-manager --version v1.21.3 -n cert-manager --create-namespace --set crds.enabled=true --set config.gatewayAPI.enabled=true; чарт кладёт gatewayAPI.enabled: true в файл конфигурации контроллера (ConfigMap cert-manager, ресурс ControllerConfiguration) вместо флага. Что такое Helm и values, подробно — в части 27. Кроме самого кластера понадобится утилита cmctl — консольный клиент cert-manager для проверки статуса и ручного продления. Она живёт в отдельном репозитории cert-manager/cmctl и ставится бинарником со страницы его выпусков (здесь — 2.6.2, рядом лежит checksums.txt для проверки).

Готовность API проверяется так:

$ cmctl check api --wait=2m
The cert-manager API is ready

Эта команда пробует создать пробный объект в режиме dry-run: если вебхук ещё не поднялся или cainjector не успел вписать ему CA, она подождёт. Типичная ошибка — сразу после kubectl apply создавать Issuer и получать отказ «failed calling webhook»: вебхук ещё не готов.


Цепочка объектов

У cert-manager шесть CRD, и при выпуске по ACME их объекты появляются по цепочке:

 Issuer / ClusterIssuer        кто выдаёт: ACME, свой CA, Vault, самоподписанный
        ▲
        │ issuerRef
 Certificate                   что нужно: имена, срок, ключ, в какой Secret
        │ создаёт
        ▼
 CertificateRequest            один запрос: CSR + ссылка на издателя; Approved
        │ создаёт (только ACME)
        ▼
 Order                         заказ у ACME-сервера на набор имён
        │ создаёт по одному на имя
        ▼
 Challenge                     доказать владение именем: HTTP-01 или DNS-01
        │ создаёт
        ▼
 под-решатель, Service, HTTPRoute    или    TXT-запись в DNS
        │
        ▼ (после выдачи)
 Secret kubernetes.io/tls      tls.crt, tls.key  ── читают Traefik, приложения

Issuer и ClusterIssuer описывают, кто и как подписывает сертификаты. Issuer живёт в неймспейсе и обслуживает Certificate только из него; ClusterIssuer — кластерный, его могут использовать Certificate из любого неймспейса. Типы издателей: acme (Let's Encrypt и любой другой ACME-сервер), ca (подписывать своим CA, ключ которого лежит в Secret), selfSigned (каждый сертификат подписывает сам себя), vault (HashiCorp Vault — хранилище секретов со встроенным центром сертификации), venafi (коммерческая платформа управления сертификатами) и внешние издатели, которые ставятся отдельными контроллерами.

Certificate — желаемое: имена (dnsNames, ipAddresses), издатель, secretName, срок, параметры ключа. Его контроллер сравнивает желаемое с тем, что лежит в Secret: сертификата нет, он скоро истекает, имена в нём не те, издатель другой — значит, нужен новый.

CertificateRequest — один конкретный запрос на подпись: CSR (Certificate Signing Request, запрос с открытым ключом и именами, подписанный закрытым ключом) и ссылка на издателя. Перед подписью запрос должен быть одобрен (условие Approved); встроенный одобритель cert-manager одобряет запросы ко всем Issuer и ClusterIssuer, но его можно заменить на политику, которая, например, не даст команде demo выпустить сертификат на чужое имя. На каждое продление создаётся новый CertificateRequest с номером ревизии в имени, а старые удаляются по revisionHistoryLimit. По умолчанию он равен 1: в истории остаётся только последний запрос, и на стенде после продления запрос -1 исчез вместе со своим Order.

Order и Challenge появляются только у ACME-издателей и повторяют сущности протокола один к одному.


ACME, HTTP-01 и DNS-01

ACME (Automatic Certificate Management Environment, RFC 8555) — протокол, по которому клиент автоматически получает сертификат у центра сертификации. Клиент регистрирует учётную запись (пару ключей), создаёт заказ на набор имён, для каждого имени получает испытание и доказывает, что управляет именем; после этого отправляет CSR и забирает сертификат. Подробно — в части 23 сетевой серии.

Испытаний два вида, и от выбора зависит, что должно быть устроено в сети.

HTTP-01. Центр сертификации делает запрос http://<имя>/.well-known/acme-challenge/<токен> на порт 80 и ждёт в ответе строку <токен>.<отпечаток ключа учётной записи>. cert-manager для этого поднимает маленький под-решатель (cm-acme-http-solver-…, образ cert-manager-acmesolver), Service типа NodePort к нему и маршрут — HTTPRoute с точным путём /.well-known/acme-challenge/<токен> или Ingress. Let's Encrypt проверяет имя не из одной точки: первым приходит основной сервер проверки, следом — ещё несколько из других сетей и регионов, и ответ должен совпасть у большинства. Так подменить ответ, перехватив маршрут к одному серверу, не получится. Ограничения:

  • имя должно разрешаться в публичном DNS в адрес, на порт 80 которого Let's Encrypt может достучаться из интернета. Наш 192.168.10.200 — частный адрес, поэтому на роутере нужен проброс портов с публичного адреса (у нас 203.0.113.10) на 192.168.10.200 и публичная запись hello-ingress.apps.example.com A 203.0.113.10 (или сразу *.apps.example.com);
  • wildcard-сертификат через HTTP-01 получить нельзя.

DNS-01. Центр сертификации запрашивает TXT-запись _acme-challenge.<имя> и ждёт в ней хеш от той же строки. cert-manager создаёт запись через API DNS-провайдера, дожидается, пока она станет видна, и удаляет после проверки. Встроенно поддерживаются Cloudflare, Route53, Google Cloud DNS, Azure DNS, DigitalOcean, Akamai, ACME-DNS и RFC2136 — стандартный протокол динамического обновления DNS с подписью ключом TSIG, который понимают BIND и Windows DNS; остальные провайдеры подключаются внешними вебхуками. DNS-01 нужен, когда:

  • нужен wildcard — *.apps.example.com;
  • сервис не доступен из интернета: внутренние панели, кластер без проброса портов. Достаточно, чтобы публичной была зона DNS, сам сервер может быть где угодно.

У DNS-01 своя цена: ключ API провайдера лежит в кластере, и с ним можно поменять любую запись в зоне. Поэтому токен выпускают с минимальными правами — изменение записей одной зоны, — и, если провайдер позволяет, выносят _acme-challenge в отдельную зону через делегирование или CNAME (cert-manager идёт по такому CNAME, если у решателя задано cnameStrategy: Follow).

Одно общее следствие: все сертификаты публичных центров сертификации попадают в журналы Certificate Transparency — публичные журналы всех выданных сертификатов, куда записи только добавляются и откуда не удаляются; браузеры требуют, чтобы сертификат был в них записан. Имя внутреннего сервиса в сертификате Let's Encrypt становится известно всему миру, и это не теория: на стенде через две минуты после выдачи в журнале Traefik появились запросы к hello-ingress.apps.example.com с чужих адресов — сканеры читают журналы CT и сразу проверяют новые имена. Для внутренних имён это довод в пользу собственного CA, о нём ниже.

Let's Encrypt: staging, prod и лимиты

У Let's Encrypt два окружения:

Адрес каталога Чем отличается
staging https://acme-staging-v02.api.letsencrypt.org/directory сертификаты от тестовой иерархии с именами вроде (STAGING) Pretend Pear X1, которой никто не доверяет; лимиты гораздо выше
prod https://acme-v02.api.letsencrypt.org/directory настоящие сертификаты, строгие лимиты

Лимиты production, которые чаще всего задевают при отладке:

Лимит Значение
новых сертификатов на зарегистрированный домен (example.com) 50 за 7 дней
сертификатов на одинаковый набор имён 5 за 7 дней
неудачных проверок на имя и учётную запись 5 в час
новых заказов на учётную запись 300 за 3 часа

Отсюда правило: сначала staging. Настраиваем издателя на staging, добиваемся Ready: True, и только потом меняем издателя на prod. Пять неудачных проверок в час исчерпываются за пару минут, если HTTP-01 не проходит из-за ошибки в маршруте, а cert-manager повторяет попытки.

Срок жизни сертификатов Let's Encrypt сокращается. Сейчас профиль по умолчанию classic выдаёт сертификаты на 90 дней; с 10 февраля 2027 года он будет выдавать на 64 дня, с 16 февраля 2028-го — на 45. Профиль tlsserver уже даёт 45 дней, shortlived — 160 часов, около шести с половиной суток. Staging получает такие изменения раньше production: на стенде staging выдал сертификат на 64 дня, а production — на 90. Профиль выбирается полем profile у ACME-издателя. Вывод для настройки: не задавайте продление фиксированным числом дней, об этом ниже.


Издатели для стенда

Ключ API DNS-провайдера кладём в Secret. Для ClusterIssuer секреты ищутся в неймспейсе самого cert-manager (он называется cluster resource namespace, у манифеста это флаг --cluster-resource-namespace=$(POD_NAMESPACE)), поэтому Secret создаётся там. Пусть зона example.com обслуживается в Cloudflare:

$ kubectl -n cert-manager create secret generic cloudflare-api-token \
    --from-literal=api-token='<токен с правом Zone:DNS:Edit только для example.com>'
secret/cloudflare-api-token created

Издатель staging:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-staging-account
    solvers:
      - selector:
          dnsNames:
            - "*.apps.example.com"
        dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
      - http01:
          gatewayHTTPRoute:
            parentRefs:
              - name: apps
                namespace: traefik
                kind: Gateway
                sectionName: http

privateKeySecretRef — Secret, куда cert-manager при первом обращении положит ключ ACME-учётной записи (по умолчанию RSA 2048). Поля email здесь нет, и это осознанно. Адрес — необязательный контакт учётной записи; рассылку писем об истечении сертификатов Let's Encrypt прекратил 4 июня 2025 года, так что следить за сроками всё равно должен cert-manager и ваш мониторинг. Если адрес указать, он должен быть настоящим: с email: admin@example.com учётная запись не регистрируется:

$ kubectl describe clusterissuer letsencrypt-staging | sed -n '/^Status:/,$p'
Status:
  Acme:
  Conditions:
    Last Transition Time:  2026-10-29T13:17:07Z
    Message:               Failed to register ACME account: 400 urn:ietf:params:acme:error:invalidContact: Error validating contact(s) :: contact email has forbidden domain "example.com"
    Observed Generation:   1
    Reason:                ErrRegisterACMEAccount
    Status:                False
    Type:                  Ready
Events:                    <none>

Без поля email издатель стал готов через несколько секунд — Reason: ACMEAccountRegistered, Message: The ACME account was registered with the ACME server, а в status.acme.uri появился адрес учётной записи на сервере.

Решатели (solvers) выбираются по селекторам: dnsNames совпадает только с точно такими именами, dnsZones — с зоной и всеми её поддоменами, matchLabels — с метками Certificate; решатель без селектора подходит ко всему, а при нескольких подходящих побеждает более конкретный. Здесь wildcard идёт через DNS-01, все остальные имена — через HTTP-01, маршрутом на слушатель http нашего шлюза.

Издатель prod отличается только именем, адресом server и Secret учётной записи (letsencrypt-prod-account). Учётные записи у staging и prod разные, это нормально.

Внутренний центр сертификации

Для имён, которые не должны светиться в публичных журналах (hello.demo.svc.cluster.local, внутренние панели, связь между сервисами по TLS), заведём свой CA. Это три объекта: самоподписанный издатель, корневой сертификат, выпущенный им, и издатель типа ca, который подписывает всё остальное этим корнем:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-root-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: example.com Internal Root CA
  secretName: internal-root-ca
  duration: 87600h          # 10 лет
  privateKey:
    algorithm: ECDSA
    size: 384
  issuerRef:
    kind: ClusterIssuer
    name: selfsigned
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-root-ca

Всё готово за пару секунд:

$ kubectl get clusterissuer
NAME                  READY   AGE
internal-ca           True    3s
letsencrypt-prod      True    2m15s
letsencrypt-staging   True    5m45s
selfsigned            True    3s
$ kubectl -n cert-manager get certificate
NAME               READY   SECRET             AGE
internal-root-ca   True    internal-root-ca   4s

Издатель selfsigned нужен ровно один раз — чтобы выпустить корень. Корневой Secret лежит в cert-manager (в нём ключи ca.crt, tls.crt, tls.key): оттуда его читает ClusterIssuer internal-ca. Ключ корня — самое ценное в этой конструкции: кто его прочитает, выпустит сертификат на любое внутреннее имя. Доступ к Secret в cert-manager должен быть только у администраторов (RBAC — в части 29), а сами Secret в etcd стоит шифровать, как в части 12. В организациях посерьёзнее корень держат вне кластера, а в кластер отдают только промежуточный CA.

Клиентам нужно доверять этому корню. Раздавать его по неймспейсам помогает trust-manager — отдельный контроллер того же проекта. Он читает ресурс Bundle (кластерный, trust.cert-manager.io/v1alpha1), собирает из источников набор доверенных сертификатов и раскладывает его в ConfigMap по выбранным неймспейсам; приложения монтируют этот ConfigMap как файл. Проект распространяет его Helm-чартом, ставится он одной командой: helm install trust-manager oci://quay.io/jetstack/charts/trust-manager -n cert-manager --wait. Чарт 0.26.0 сам пользуется cert-manager: сертификат для вебхука trust-manager выпускает Issuer trust-manager в неймспейсе cert-manager.

Документация trust-manager предупреждает: не указывайте источником сам Secret корня. При перевыпуске корня trust-manager тут же разошлёт новый, старый пропадёт из доверенных, и всё, что подписано старым, перестанет проверяться. Правильно — скопировать сертификат корня (без ключа!) в отдельный ConfigMap в неймспейсе cert-manager и менять его, когда вы сами решили:

$ kubectl -n cert-manager get secret internal-root-ca -o jsonpath='{.data.ca\.crt}' | base64 -d > internal-root-ca.crt
$ kubectl -n cert-manager create configmap internal-root-ca-public --from-file=ca.crt=internal-root-ca.crt
configmap/internal-root-ca-public created
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: example-internal-ca
spec:
  sources:
    - useDefaultCAs: true        # публичные корни из пакета ca-certificates
    - configMap:
        name: internal-root-ca-public
        key: ca.crt
  target:
    configMap:
      key: ca-bundle.pem
    namespaceSelector:
      matchLabels:
        trust.example.com/internal-ca: "true"

Пометим demo и посмотрим, что получилось:

$ kubectl label namespace demo trust.example.com/internal-ca=true
namespace/demo labeled
$ kubectl get bundle
NAME                  CONFIGMAP TARGET   SECRET TARGET   SYNCED   REASON   AGE
example-internal-ca   ca-bundle.pem                      True     Synced   4s
$ kubectl describe bundle example-internal-ca | sed -n '/^Status:/,$p'
Status:
  Conditions:
    Last Transition Time:  2026-10-29T13:27:25Z
    Message:               Successfully synced Bundle to namespaces that match this label selector: trust.example.com/internal-ca=true
    Observed Generation:   1
    Reason:                Synced
    Status:                True
    Type:                  Synced
  Default CAVersion:       cert-manager-debian-trixie-20250419+deb13u1.1-e9c7b63ee2623386
Events:
  Type    Reason  Age   From     Message
  ----    ------  ----  ----     -------
  Normal  Synced  4s    bundles  Successfully synced Bundle to namespaces that match this label selector: trust.example.com/internal-ca=true
  Normal  Synced  3s    bundles  Successfully synced Bundle to namespaces that match this label selector: trust.example.com/internal-ca=true
$ kubectl -n demo get configmap example-internal-ca -o jsonpath='{.data.ca-bundle\.pem}' | grep -c 'BEGIN CERTIFICATE'
149

В неймспейсе demo появился ConfigMap example-internal-ca с ключом ca-bundle.pem: 148 публичных корней из пакета Debian (его образ trust-pkg-debian-trixie чарт ставит рядом с trust-manager, версия видна в Default CAVersion) плюс наш корень. Владелец ConfigMap — Bundle, так что руками его править бесполезно. Под монтирует его в /etc/ssl/certs/ или передаёт путь приложению переменной окружения и одинаково доверяет и Let's Encrypt, и внутреннему CA. Селектор задан явно: пустой селектор сейчас означает «во все неймспейсы», но проект предупреждает, что в будущих версиях это поведение изменится.


Аннотация или явный Certificate

Есть два способа получить сертификат.

Аннотация на Gateway или Ingress. cert-manager следит за Gateway и Ingress (эта часть называется ingress-shim, для Gateway — gateway-shim) и сам создаёт Certificate по их описанию. Вернём к шлюзу apps из прошлой части аннотацию:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: apps
  namespace: traefik
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  # слушатели — как в прошлой части

Для каждого слушателя, у которого есть hostname, tls.mode: Terminate и certificateRefs на Secret в том же неймспейсе, cert-manager создаст Certificate: имя и secretName — из certificateRefs, dnsNames — из hostname. Слушатели с одинаковым Secret дают один Certificate с объединённым списком имён. Самоподписанный apps-example-com-tls, созданный руками, будет перезаписан сертификатом Let's Encrypt на *.apps.example.com, и по селектору издателя он пойдёт через DNS-01.

На стенде механизм проверен без обращения к Let's Encrypt — с издателем internal-ca:

$ kubectl -n traefik annotate gateway apps cert-manager.io/cluster-issuer=internal-ca
gateway.gateway.networking.k8s.io/apps annotated
$ kubectl -n traefik get certificate
NAME                   READY   SECRET                 AGE
apps-example-com-tls   True    apps-example-com-tls   4s
$ kubectl -n traefik get certificate apps-example-com-tls -o yaml
...
  annotations:
    acme.cert-manager.io/http01-parentrefkind: Gateway
    acme.cert-manager.io/http01-parentrefname: apps
...
  ownerReferences:
  - apiVersion: gateway.networking.k8s.io/v1
    blockOwnerDeletion: true
    controller: true
    kind: Gateway
    name: apps
...
spec:
  dnsNames:
  - '*.apps.example.com'
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: internal-ca
  secretName: apps-example-com-tls
  usages:
  - digital signature
  - key encipherment
$ kubectl -n traefik describe certificate apps-example-com-tls | sed -n '/^Events:/,$p'
Events:
  Type    Reason     Age   From                                       Message
  ----    ------     ----  ----                                       -------
  Normal  Issuing    3s    cert-manager-certificates-trigger          Issuing certificate as Secret was previously issued by "Issuer.cert-manager.io/"
  Normal  Generated  3s    cert-manager-certificates-key-manager      Stored new private key in temporary Secret resource "apps-example-com-tls-zn9pj"
  Normal  Requested  3s    cert-manager-certificates-request-manager  Created new CertificateRequest resource "apps-example-com-tls-1"
  Normal  Issuing    2s    cert-manager-certificates-issuing          The certificate has been successfully issued

Владелец Certificate — Gateway: убрали шлюз, ушёл и Certificate. Триггер честно пишет, почему перевыпускает: Secret выпущен «издателем» без имени, то есть не cert-manager. Аннотации http01-parentref… говорят решателю HTTP-01 вешать маршрут прямо на этот шлюз.

С Ingress — так же, аннотацией cert-manager.io/cluster-issuer (или cert-manager.io/issuer): Certificate создаётся по каждому элементу spec.tls, имена берутся из hosts, Secret — из secretName.

Явный Certificate. Пишем объект сами:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: hello-ingress-tls
  namespace: demo
spec:
  secretName: hello-ingress-tls
  dnsNames:
    - hello-ingress.apps.example.com
  issuerRef:
    kind: ClusterIssuer
    name: letsencrypt-staging
  privateKey:
    algorithm: ECDSA
    size: 256
    rotationPolicy: Always
  secretTemplate:
    labels:
      app.kubernetes.io/part-of: hello

Когда что выбирать. Аннотация удобна, когда сертификат нужен ровно для этого Gateway или Ingress и ничего особенного в нём нет: имена не надо повторять дважды. Явный Certificate — когда сертификат используется не прокси, а приложением (под монтирует Secret сам), когда нужны поля, которых нет в аннотациях, когда один Secret нужен нескольким потребителям, и когда хочется видеть сертификат в Git отдельным объектом. Аннотации тоже умеют многое (cert-manager.io/duration, cert-manager.io/private-key-algorithm и другие), но все параметры в аннотациях — те же строки без схемы, что и у Ingress.

Ключ. Если алгоритм не задан, cert-manager создаёт ключ RSA 2048; ECDSA P-256 короче, быстрее в рукопожатии и поддерживается всеми современными клиентами. rotationPolicy: Always — при каждом продлении создавать новый ключ; с версии 1.18 это значение по умолчанию, и менять его на Never стоит только тогда, когда ключ где-то закреплён (pinning) и его смена что-то сломает.


Продление и где лежит ключ

Время продления cert-manager считает сам и пишет в status.renewalTime. Если ничего не задавать, продление начинается на двух третях срока действия выданного сертификата: для 90-дневного — за 30 дней до конца, для 64-дневного — примерно за 21 день, для 160-часового shortlived — примерно за двое суток. Задать его можно двумя полями:

  • renewBefore — фиксированный запас («за 360 часов до истечения»);
  • renewBeforePercentage — доля от фактического срока («за 33 % до истечения»).

duration для ACME — лишь пожелание: Let's Encrypt не принимает запрошенный срок и выдаёт сертификат на срок своего профиля, а cert-manager считает продление от фактического срока выданного сертификата. Поэтому с публичным ACME лучше вообще не трогать duration и renewBefore, а если запас нужен — задать его в процентах. Фиксированный renewBefore: 720h при 64-дневном сертификате ещё разумен, при 45-дневном означает продление уже через 15 дней после выдачи, а если запас не меньше всего срока (6-дневный сертификат), cert-manager его молча игнорирует и возвращается к двум третям — настройка перестаёт делать то, что написано.

В 1.21 появился ещё один способ, пока альфа за feature gate ACMEUseARI: ARI (ACME Renewal Information, RFC 9773) — расширение ACME, через которое сервер сам подсказывает клиенту окно для продления, например когда собирается массово отозвать сертификаты. С ним cert-manager выбирает время случайно внутри предложенного окна; Let's Encrypt ARI поддерживает и рекомендует.

Сертификат и ключ хранятся в обычном Secret типа kubernetes.io/tls:

Ключ Что
tls.crt сертификат и следом промежуточные сертификаты цепочки
tls.key закрытый ключ в PEM
ca.crt сертификат CA, если издатель его знает (у ca и selfSigned — да, у ACME — нет)

На Secret cert-manager вешает аннотации cert-manager.io/certificate-name, cert-manager.io/issuer-name, cert-manager.io/issuer-kind, cert-manager.io/alt-names, cert-manager.io/common-name и другие — по ним видно, откуда он взялся, — и метку controller.cert-manager.io/fao: "true", по которой контроллер следит только за своими Secret. Новый ключ до выдачи сертификата хранится во временном Secret со случайным суффиксом, и только когда сертификат готов, оба попадают в целевой Secret одной записью — потребитель никогда не видит новый ключ со старым сертификатом.

Кто читает Secret. Traefik следит за Secret через watch и подменяет сертификат на лету, без перезапуска: на стенде новый сертификат отдавался уже через двадцать секунд после выдачи. Приложение, которое монтирует Secret как том, тоже получит новые файлы — kubelet обновляет такие тома, — но перечитает ли их процесс, зависит от приложения; если нет, помогает перезапуск по изменению, например контроллер Reloader, перезапускающий поды при изменении Secret, из части 12.

Ключ лежит в etcd. Без шифрования etcd на диске он хранится в base64, то есть открытым текстом, и все, у кого есть право get на Secret в этом неймспейсе, могут его прочитать. Это ещё один довод держать сертификаты шлюза в неймспейсе traefik, куда командам приложений вход закрыт.


Перенаправление на HTTPS и HTTP-01

Частый вопрос: ломает ли перенаправление HTTP → HTTPS проверку HTTP-01? Сам по себе — нет. Let's Encrypt следует перенаправлениям (до десяти, только на схемы http и https и только на порты 80 и 443) и при переходе на HTTPS не проверяет сертификат: проверка и нужна, чтобы получить первый настоящий сертификат. Самопроверка cert-manager, о которой ниже, тоже идёт по перенаправлению. Ломается другое: после перенаправления запрос должен попасть на маршрут решателя, а он висит только на HTTP.

В Traefik на стенде это выглядит так:

  • Перенаправление маршрутом — Ingress hello-redirect с middleware redirectScheme или HTTPRoute с фильтром RequestRedirect из прошлой части. Проблем нет: маршрут решателя совпадает с точным путём /.well-known/acme-challenge/<токен> и у Traefik получает приоритет выше, чем правило с префиксом /. На стенде Ingress hello-redirect для того же имени стоял на месте, и, как только Traefik применил HTTPRoute решателя, запросы проверки пошли к решателю, а все остальные — по-прежнему на перенаправление (301 на GET).
  • Перенаправление на точке входа (ports.web.http.redirections.entryPoint в values) — маршрутизатор перенаправления получает приоритет MaxInt-1 и забирает все запросы на web, включая проверку. Дальше запрос приходит на websecure, где маршрута решателя нет (он висит на слушателе http), и проверка получает 404. На стенде с таким перенаправлением запрос к пробному HTTPRoute с точным путём /.well-known/acme-challenge/test-token получал 301 на HTTPS. Для этого случая у точки входа есть параметр allowACMEByPass: true (ports.web.allowACMEByPass в values чарта): маршрутизатор перенаправления перестаёт трогать путь /.well-known/acme-challenge/, и маршрут решателя срабатывает на HTTP — с ним тот же запрос попал на HTTPRoute, а запрос к / по-прежнему получал 301.
  • Решатель через Ingress (http01.ingress) — его Ingress без аннотаций Traefik попадает на все точки входа, в том числе на websecure, поэтому проверка может пройти и после перенаправления. Работает, но полагаться на это не стоит; явный allowACMEByPass надёжнее.

Смотрим руками

Создадим явный Certificate с издателем staging и посмотрим, как разворачивается цепочка. Через шесть секунд после apply:

$ kubectl apply -f hello-ingress-tls.yaml
certificate.cert-manager.io/hello-ingress-tls created
$ kubectl -n demo get certificate,certificaterequest,order,challenge
NAME                                            READY   SECRET              AGE
certificate.cert-manager.io/hello-ingress-tls   False   hello-ingress-tls   6s

NAME                                                     APPROVED   DENIED   READY   ISSUER                REQUESTER                                         AGE
certificaterequest.cert-manager.io/hello-ingress-tls-1   True                False   letsencrypt-staging   system:serviceaccount:cert-manager:cert-manager   6s

NAME                                                        STATE     AGE
order.acme.cert-manager.io/hello-ingress-tls-1-3799463221   pending   5s

NAME                                                                       STATE     DOMAIN                           AGE
challenge.acme.cert-manager.io/hello-ingress-tls-1-3799463221-2553886130   pending   hello-ingress.apps.example.com   4s

Имена говорят о происхождении: запрос -1 — первая ревизия сертификата, заказ и испытание носят имя запроса и хеш. Решатель тоже появился:

$ kubectl -n demo get pods,svc,httproute -l acme.cert-manager.io/http01-solver=true
NAME                            READY   STATUS              RESTARTS   AGE
pod/cm-acme-http-solver-frw5d   0/1     ContainerCreating   0          1s

NAME                                TYPE       CLUSTER-IP    EXTERNAL-IP   PORT(S)          AGE
service/cm-acme-http-solver-4ntl5   NodePort   10.99.81.36   <none>        8089:31457/TCP   1s

NAME                                                            HOSTNAMES                            AGE
httproute.gateway.networking.k8s.io/cm-acme-http-solver-2gg8b   ["hello-ingress.apps.example.com"]   1s

HTTPRoute решателя — обычный маршрут Gateway API: parentRefs из издателя, имя из испытания, одно правило type: Exact с путём к токену и backendRefs на сервис решателя, владелец — Challenge. Traefik принял его (Accepted: True, ResolvedRefs: True), потому что неймспейс demo помечен для allowedRoutes шлюза ещё в прошлой части. Испытание в деталях:

$ kubectl -n demo describe challenge hello-ingress-tls-1-3799463221-2553886130
...
Spec:
  Authorization URL:  https://acme-staging-v02.api.letsencrypt.org/acme/authz/123456789/6267561333
  Dns Name:           hello-ingress.apps.example.com
  Issuer Ref:
    Kind:  ClusterIssuer
    Name:  letsencrypt-staging
  Key:     FXgzGQZcP6xg….EL36boxmInjy…
  Solver:
    http01:
      Gateway HTTPRoute:
        Parent Refs:
          Group:         gateway.networking.k8s.io
          Kind:          Gateway
          Name:          apps
          Namespace:     traefik
          Section Name:  http
  Token:                 FXgzGQZcP6xg…
  Type:                  HTTP-01
  URL:                   https://acme-staging-v02.api.letsencrypt.org/acme/chall/123456789/6267561333/041Rwg
  Wildcard:              false
Status:
  Presented:     true
  Presented At:  2026-10-29T13:18:14Z
  Processing:    true
  Reason:        Waiting for HTTP-01 challenge propagation: wrong status code '404', expected '200'
  State:         pending
Events:
  Type    Reason     Age   From                     Message
  ----    ------     ----  ----                     -------
  Normal  Started    2s    cert-manager-challenges  Challenge scheduled for processing
  Normal  Presented  0s    cert-manager-challenges  Presented challenge using HTTP-01 challenge mechanism

Key — та самая строка <токен>.<отпечаток ключа учётной записи>, которую решатель отдаст в ответ (87 байт). Reason — это самопроверка: прежде чем просить Let's Encrypt проверить имя, cert-manager сам делает запрос на http://hello-ingress.apps.example.com/.well-known/acme-challenge/<токен> и ждёт правильного ответа — так он не тратит попытки из лимита неудач впустую. Через десять секунд причина сменилась на wrong status code '503', а ещё через десять испытание прошло. Что происходило, видно в журналах обоих подов Traefik:

$ kubectl -n traefik logs traefik-6b7fb4b765-qb2wb | grep acme-challenge
172.20.211.24 - - [29/Oct/2026:13:18:14 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 301 17 "-" "-" 1 "demo-hello-redirect-hello-ingress-apps-example-com@kubernetes" "-" 0ms
172.20.211.24 - - [29/Oct/2026:13:18:14 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 301 17 "-" "-" 2 "demo-hello-redirect-hello-ingress-apps-example-com@kubernetes" "-" 0ms
172.20.211.24 - - [29/Oct/2026:13:18:14 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 404 19 "-" "-" 3 "demo-hello-hello-ingress-apps-example-com@kubernetes" "http://172.20.211.17:8080" 0ms
172.20.211.24 - - [29/Oct/2026:13:18:24 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 503 20 "-" "-" 4 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "-" 0ms
172.20.211.24 - - [29/Oct/2026:13:18:34 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 5 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
172.20.211.24 - - [29/Oct/2026:13:18:36 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 6 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
172.20.211.24 - - [29/Oct/2026:13:18:40 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 7 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
$ kubectl -n traefik logs traefik-6b7fb4b765-f972j | grep acme-challenge
...
172.20.211.24 - - [29/Oct/2026:13:18:42 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 9 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
66.133.109.36 - - [29/Oct/2026:13:18:42 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 10 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
13.60.200.158 - - [29/Oct/2026:13:18:43 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 11 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
44.252.78.39 - - [29/Oct/2026:13:18:43 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 12 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms
...
18.141.229.111 - - [29/Oct/2026:13:18:44 +0000] "GET /.well-known/acme-challenge/FXgzGQZcP6xg… HTTP/1.1" 200 87 "-" "-" 16 "httproute-demo-cm-acme-http-solver-2gg8b-gw-traefik-apps-ep-web-0-e3f853cb74c1116d3e60@kubernetesgateway" "http://172.20.211.29:8089" 0ms

Читаем по порядку. 172.20.211.24 — под cert-manager, это самопроверка изнутри кластера. В 13:18:14 Traefik ещё не применил HTTPRoute решателя, запрос попал на Ingress hello-redirect, получил 301, самопроверка пошла по перенаправлению на HTTPS и получила 404 уже от самого hello, у которого такого пути нет. В 13:18:24 маршрут есть, но под-решатель ещё скачивает образ — 503 с пустым бэкендом. С 13:18:34 решатель отвечает 200 и 87 байт; самопроверка прошла пять раз подряд с интервалом две секунды, и только после этого cert-manager сказал Let's Encrypt «проверяйте». Следом пришли серверы проверки Let's Encrypt: первый 66.133.109.36 и ещё шесть с других адресов в течение двух секунд — та самая проверка из нескольких точек. Их адреса в журнале настоящие, потому что у сервиса Traefik externalTrafficPolicy: Local; заголовка User-Agent нет — чарт Traefik по умолчанию не пишет заголовки в журнал доступа.

Чтобы самопроверка вообще дошла, под cert-manager должен попасть на шлюз по тому же имени. На стенде внутренние DNS-серверы сети, на которые CoreDNS пересылает внешние имена (часть 16), отвечают на hello-ingress.apps.example.com частным адресом 192.168.10.200; без этого под получил бы публичный 203.0.113.10 и пошёл бы на роутер снаружи, а это работает, только если роутер умеет «возвращать» такие соединения обратно в локальную сеть (hairpin NAT). Наш роутер этого не умеет.

Сорок четыре секунды после создания — сертификат готов:

$ kubectl -n demo get certificate,certificaterequest,order,challenge
NAME                                            READY   SECRET              AGE
certificate.cert-manager.io/hello-ingress-tls   True    hello-ingress-tls   45s

NAME                                                     APPROVED   DENIED   READY   ISSUER                REQUESTER                                         AGE
certificaterequest.cert-manager.io/hello-ingress-tls-1   True                True    letsencrypt-staging   system:serviceaccount:cert-manager:cert-manager   45s

NAME                                                        STATE   AGE
order.acme.cert-manager.io/hello-ingress-tls-1-3799463221   valid   44s
$ kubectl -n demo get pods,svc,httproute -l acme.cert-manager.io/http01-solver=true
No resources found in demo namespace.

Испытание, под-решатель, Service и HTTPRoute после проверки удалены, Order остался в состоянии valid. Подробности сертификата:

$ kubectl -n demo describe certificate hello-ingress-tls | sed -n '/^Status:/,$p'
Status:
  Conditions:
    Last Transition Time:  2026-10-29T13:18:52Z
    Message:               Certificate is up to date and has not expired
    Observed Generation:   1
    Reason:                Ready
    Status:                True
    Type:                  Ready
  Not After:               2027-01-01T12:20:17Z
  Not Before:              2026-10-29T12:20:18Z
  Renewal Time:            2026-12-11T04:20:17Z
  Revision:                1
Events:
  Type    Reason     Age   From                                       Message
  ----    ------     ----  ----                                       -------
  Normal  Issuing    58s   cert-manager-certificates-trigger          Issuing certificate as Secret does not exist
  Normal  Generated  58s   cert-manager-certificates-key-manager      Stored new private key in temporary Secret resource "hello-ingress-tls-nnz59"
  Normal  Requested  58s   cert-manager-certificates-request-manager  Created new CertificateRequest resource "hello-ingress-tls-1"
  Normal  Issuing    14s   cert-manager-certificates-issuing          The certificate has been successfully issued

Срок 64 дня — staging уже выдаёт сертификаты по графику 2027 года, — и Renewal Time ровно на двух третях: через 42 дня и 16 часов после Not Before. Not Before на час раньше выдачи — Let's Encrypt ставит начало срока с запасом на расхождение часов. События показывают ту же цепочку: триггер «Secret не существует», временный Secret с ключом, запрос, выдача.

cmctl status собирает всё связанное в один вывод:

$ cmctl status certificate hello-ingress-tls -n demo
Name: hello-ingress-tls
Namespace: demo
Created at: 2026-10-29T16:18:08+03:00
Conditions:
  Ready: True, Reason: Ready, Message: Certificate is up to date and has not expired
DNS Names:
- hello-ingress.apps.example.com
Events:
  Type    Reason     Age   From                                       Message
  ----    ------     ----  ----                                       -------
  Normal  Issuing    58s   cert-manager-certificates-trigger          Issuing certificate as Secret does not exist
  Normal  Generated  58s   cert-manager-certificates-key-manager      Stored new private key in temporary Secret resource "hello-ingress-tls-nnz59"
  Normal  Requested  58s   cert-manager-certificates-request-manager  Created new CertificateRequest resource "hello-ingress-tls-1"
  Normal  Issuing    14s   cert-manager-certificates-issuing          The certificate has been successfully issued
Issuer:
  Name: letsencrypt-staging
  Kind: ClusterIssuer
  Conditions:
    Ready: True, Reason: ACMEAccountRegistered, Message: The ACME account was registered with the ACME server
  Events:  <none>
Secret:
  Name: hello-ingress-tls
  Issuer Country: US
  Issuer Organisation: Let's Encrypt
  Issuer Common Name: (STAGING) Baloney Bulgur YE2
  Key Usage: Digital Signature
  Extended Key Usages: Server Authentication
  Public Key Algorithm: ECDSA
  Signature Algorithm: ECDSA-SHA384
  Subject Key ID: …
  Authority Key ID: deda4225f234c0279b6616cf656a90ac21d53087
  Serial Number: …
  Events:  <none>
Not Before: 2026-10-29T15:20:18+03:00
Not After: 2027-01-01T15:20:17+03:00
Renewal Time: 2026-12-11T07:20:17+03:00
No CertificateRequest found for this Certificate

cmctl показывает время в часовом поясе машины, с которой его запустили. Последняя строка — не ошибка: запрос уже выполнен, и cmctl показывает только незавершённые. Пока сертификат выпускается, здесь же видны CertificateRequest, Order и Challenge с их состояниями — это самый быстрый способ понять, на каком звене всё застряло. Выпустил сертификат промежуточный CA staging (STAGING) Baloney Bulgur YE2, а в tls.crt лежат четыре сертификата: наш и цепочка тестовой иерархии вплоть до (STAGING) Bogus Broccoli X2, подписанного (STAGING) Pretend Pear X1.

Принудительное продление проверяет весь путь, не дожидаясь renewalTime, — и делать его разумно именно на staging:

$ cmctl renew hello-ingress-tls -n demo
Manually triggered issuance of Certificate demo/hello-ingress-tls
$ cmctl status certificate hello-ingress-tls -n demo
...
CertificateRequest:
  Name: hello-ingress-tls-2
  Namespace: demo
  Conditions:
    Approved: True, Reason: cert-manager.io, Message: Certificate request has been approved by cert-manager.io
  Ready: False, Reason: Pending, Message: Waiting on certificate issuance from order demo/hello-ingress-tls-2-3799463221: "ready"
  Events:
    Type    Reason              Age   From                                                Message
    ----    ------              ----  ----                                                -------
    Normal  WaitingForApproval  3s    cert-manager-certificaterequests-issuer-acme        Not signing CertificateRequest until it is Approved
    Normal  WaitingForApproval  3s    cert-manager-certificaterequests-issuer-venafi      Not signing CertificateRequest until it is Approved
    Normal  WaitingForApproval  3s    cert-manager-certificaterequests-issuer-ca          Not signing CertificateRequest until it is Approved
    Normal  WaitingForApproval  3s    cert-manager-certificaterequests-issuer-selfsigned  Not signing CertificateRequest until it is Approved
    Normal  WaitingForApproval  3s    cert-manager-certificaterequests-issuer-vault       Not signing CertificateRequest until it is Approved
    Normal  cert-manager.io     3s    cert-manager-certificaterequests-approver           Certificate request has been approved by cert-manager.io
    Normal  OrderCreated        3s    cert-manager-certificaterequests-issuer-acme        Created Order resource demo/hello-ingress-tls-2-3799463221
Order:
  Name: hello-ingress-tls-2-3799463221
  State: ready, Reason: 
  Authorizations:
    URL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/123456789/6267561333, Identifier: hello-ingress.apps.example.com, Initial State: valid, Wildcard: false
No Challenges found for this Certificate
$ kubectl -n demo get certificaterequest,order
NAME                                                     APPROVED   DENIED   READY   ISSUER                REQUESTER                                         AGE
certificaterequest.cert-manager.io/hello-ingress-tls-2   True                True    letsencrypt-staging   system:serviceaccount:cert-manager:cert-manager   5s

NAME                                                        STATE   AGE
order.acme.cert-manager.io/hello-ingress-tls-2-3799463221   valid   5s

Здесь видно сразу несколько вещей. Одобрение — отдельный шаг: каждый контроллер издателя ждёт Approved, и только после встроенного одобрителя ACME-контроллер создаёт Order. Испытаний на этот раз не было вовсе — Initial State: valid: Let's Encrypt помнит, что эта учётная запись недавно доказала владение именем, и переиспользует авторизацию (у профиля classic до 30 дней, у коротких профилей — 7 часов), поэтому новый сертификат выдан за пять секунд. Запрос -1 исчез вместе со своим Order — сработал revisionHistoryLimit: 1. А открытый ключ в Secret сменился: rotationPolicy: Always.

Staging работает — меняем issuerRef.name на letsencrypt-prod. Издатель другой, значит, сертификат не соответствует желаемому, и cert-manager выпускает новый сразу, не дожидаясь срока:

$ kubectl -n demo patch certificate hello-ingress-tls --type=merge -p '{"spec":{"issuerRef":{"name":"letsencrypt-prod"}}}'
certificate.cert-manager.io/hello-ingress-tls patched
$ kubectl -n demo get certificate,certificaterequest,order,challenge
NAME                                            READY   SECRET              AGE
certificate.cert-manager.io/hello-ingress-tls   False   hello-ingress-tls   2m35s

NAME                                                     APPROVED   DENIED   READY   ISSUER                REQUESTER                                         AGE
certificaterequest.cert-manager.io/hello-ingress-tls-2   True                True    letsencrypt-staging   system:serviceaccount:cert-manager:cert-manager   82s
certificaterequest.cert-manager.io/hello-ingress-tls-3   True                False   letsencrypt-prod      system:serviceaccount:cert-manager:cert-manager   4s

NAME                                                        STATE     AGE
order.acme.cert-manager.io/hello-ingress-tls-2-3799463221   valid     82s
order.acme.cert-manager.io/hello-ingress-tls-3-2743847525   pending   4s

NAME                                                                       STATE     DOMAIN                           AGE
challenge.acme.cert-manager.io/hello-ingress-tls-3-2743847525-2581890648   pending   hello-ingress.apps.example.com   2s

У учётной записи prod авторизации для этого имени ещё нет, поэтому испытание проходит заново, тем же путём. Параллельно запущенный kubectl get -w показал смену состояния:

$ kubectl -n demo get certificate hello-ingress-tls -w
NAME                READY   SECRET              AGE
hello-ingress-tls   True    hello-ingress-tls   2m28s
hello-ingress-tls   True    hello-ingress-tls   2m30s
hello-ingress-tls   True    hello-ingress-tls   2m30s
hello-ingress-tls   False   hello-ingress-tls   2m31s
hello-ingress-tls   False   hello-ingress-tls   2m31s
hello-ingress-tls   False   hello-ingress-tls   3m
hello-ingress-tls   False   hello-ingress-tls   3m
hello-ingress-tls   True    hello-ingress-tls   3m
hello-ingress-tls   True    hello-ingress-tls   3m

Строки повторяются, потому что watch печатает каждое изменение объекта, в том числе только его status. В событиях Certificate триггер объясняет причину: Issuing certificate as Secret was previously issued by "ClusterIssuer.cert-manager.io/letsencrypt-staging". Что лежит в Secret:

$ kubectl -n demo get secret hello-ingress-tls -o jsonpath='{.data.tls\.crt}' | base64 -d \
    | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
subject=CN=hello-ingress.apps.example.com
issuer=C=US, O=Let's Encrypt, CN=YE1
notBefore=Oct 29 12:22:36 2026 GMT
notAfter=Jan 27 12:22:35 2027 GMT
X509v3 Subject Alternative Name: 
    DNS:hello-ingress.apps.example.com
$ kubectl -n demo get secret hello-ingress-tls -o jsonpath='{.data.tls\.crt}' | base64 -d \
    | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout
subject=CN=hello-ingress.apps.example.com
issuer=C=US, O=Let's Encrypt, CN=YE1

subject=C=US, O=Let's Encrypt, CN=YE1
issuer=C=US, O=ISRG, CN=Root YE

subject=C=US, O=ISRG, CN=Root YE
issuer=C=US, O=Internet Security Research Group, CN=ISRG Root X2

subject=C=US, O=Internet Security Research Group, CN=ISRG Root X2
issuer=C=US, O=Internet Security Research Group, CN=ISRG Root X1

Production выдал 90 дней — профиль classic, продление cert-manager назначил на 28 декабря, через 60 дней. Подписал его промежуточный CA YE1 нового поколения иерархии Let's Encrypt (ECDSA, корень Root YE). Root YE ещё не попал в хранилища доверенных корней браузеров и систем, поэтому цепочка по умолчанию длинная: Root YE подписан перекрёстно корнем ISRG Root X2, а X2 — корнем ISRG Root X1, которому доверяют везде.

Проверим с ноутбука, что Traefik отдаёт именно этот сертификат:

$ curl -v --resolve hello-ingress.apps.example.com:443:192.168.10.200 https://hello-ingress.apps.example.com/
...
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=hello-ingress.apps.example.com
*  start date: Oct 29 12:22:36 2026 GMT
*  expire date: Jan 27 12:22:35 2027 GMT
*  subjectAltName: host "hello-ingress.apps.example.com" matched cert's "hello-ingress.apps.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=YE1
*  SSL certificate verify ok.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA384
*   Certificate level 1: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using ecdsa-with-SHA384
*   Certificate level 2: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using ecdsa-with-SHA384
*   Certificate level 3: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using ecdsa-with-SHA384
...
< HTTP/2 200 
< content-type: text/plain; charset=utf-8
...
< x-app-version: 1.0
...
hello from hello-6d5c6db6b4-w98sn

SSL certificate verify ok — curl проверил цепочку по системным корням без всяких -k. Ключ — тот ECDSA P-256, что мы заказали в privateKey. Та же цепочка глазами openssl s_client:

$ openssl s_client -connect 192.168.10.200:443 -servername hello-ingress.apps.example.com -verify_return_error </dev/null
depth=3 C = US, O = Internet Security Research Group, CN = ISRG Root X2
verify return:1
depth=2 C = US, O = ISRG, CN = Root YE
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = YE1
verify return:1
depth=0 CN = hello-ingress.apps.example.com
verify return:1
CONNECTED(00000003)
---
Certificate chain
 0 s:CN = hello-ingress.apps.example.com
   i:C = US, O = Let's Encrypt, CN = YE1
   a:PKEY: id-ecPublicKey, 256 (bit); sigalg: ecdsa-with-SHA384
   v:NotBefore: Oct 29 12:22:36 2026 GMT; NotAfter: Jan 27 12:22:35 2027 GMT
 1 s:C = US, O = Let's Encrypt, CN = YE1
   i:C = US, O = ISRG, CN = Root YE
   a:PKEY: id-ecPublicKey, 384 (bit); sigalg: ecdsa-with-SHA384
   v:NotBefore: Sep  3 00:00:00 2025 GMT; NotAfter: Sep  2 23:59:59 2028 GMT
 2 s:C = US, O = ISRG, CN = Root YE
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
   a:PKEY: id-ecPublicKey, 384 (bit); sigalg: ecdsa-with-SHA384
   v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep  2 23:59:59 2032 GMT
 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
   a:PKEY: id-ecPublicKey, 384 (bit); sigalg: RSA-SHA256
   v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep  2 23:59:59 2032 GMT
---
...

Сервер отдал все четыре сертификата из tls.crt, а проверка на ноутбуке остановилась на depth=3: ISRG Root X2 у него уже есть в хранилище доверенных корней, и до X1 идти не понадобилось.

Внутренний сертификат — например, чтобы сам под hello принимал TLS от других сервисов кластера, — выпускается так же, только мгновенно, без Order и Challenge:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: hello-internal-tls
  namespace: demo
spec:
  secretName: hello-internal-tls
  dnsNames:
    - hello.demo.svc
    - hello.demo.svc.cluster.local
  duration: 720h
  renewBeforePercentage: 33
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    kind: ClusterIssuer
    name: internal-ca

Здесь срок задаём сами: свой CA выдаёт столько, сколько попросили, и короткий срок с автоматическим продлением лучше длинного.

$ kubectl -n demo get certificaterequest hello-internal-tls-1
NAME                   APPROVED   DENIED   READY   ISSUER        REQUESTER                                         AGE
hello-internal-tls-1   True                True    internal-ca   system:serviceaccount:cert-manager:cert-manager   2s
$ kubectl -n demo get secret hello-internal-tls -o jsonpath='{.data.ca\.crt}' | base64 -d | openssl x509 -noout -subject
subject=CN=example.com Internal Root CA
$ kubectl -n demo get secret hello-internal-tls -o jsonpath='{.data.tls\.crt}' | base64 -d \
    | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
subject=
issuer=CN=example.com Internal Root CA
notBefore=Oct 29 13:22:51 2026 GMT
notAfter=Nov 28 13:22:51 2026 GMT
X509v3 Subject Alternative Name: critical
    DNS:hello.demo.svc, DNS:hello.demo.svc.cluster.local

Поле subject пустое: commonName мы не задавали, и cert-manager его не придумывает — имена живут только в расширении Subject Alternative Name, и поэтому оно помечено critical, как требует стандарт для сертификата без имени в subject. Современные клиенты смотрят только на SAN. Продление — renewalTime: 2026-11-18T15:46:51Z, через 67 % из 30 дней.

Частые ошибки

Симптом Причина Что делать
Challenge в pending, wrong status code '404' маршрут решателя не присоединился: неймспейс без метки для allowedRoutes, не тот sectionName, перенаправление на точке входа; первые секунды — Traefik ещё не применил маршрут kubectl get httproute -n <ns> -o yaml — смотреть Accepted; метка неймспейса; allowACMEByPass
wrong status code '503' маршрут есть, а под-решатель ещё не готов (скачивает образ) подождать; если не проходит — смотреть под cm-acme-http-solver-…
то же, но для Certificate из аннотации шлюза Certificate создаётся в неймспейсе шлюза (traefik), там же создаётся и HTTPRoute решателя, а неймспейс traefik не входит в allowedRoutes пометить и его либо выпускать сертификат шлюза через DNS-01
самопроверка проходит, Let's Encrypt — нет; Order invalid, в Challenge Timeout during connect или Connection refused порт 80 не доступен из интернета: нет проброса на роутере, файрвол проверить с внешнего адреса; для внутренних сервисов — DNS-01
Challenge DNS-01 в pending, DNS record ... not yet propagated cert-manager проверяет TXT-запись через DNS кластера (в журнале при старте — configured acme dns01 nameservers ... ["10.96.0.10:53"]), а внутренний DNS обслуживает свою копию зоны example.com (split-horizon) и публичной записи не видит флаги контроллера --dns01-recursive-nameservers-only и --dns01-recursive-nameservers=1.1.1.1:53,9.9.9.9:53
NXDOMAIN или неверный адрес в Order публичной записи нет или она указывает не туда проверить публичный ответ: dig +short hello-ingress.apps.example.com @1.1.1.1
Order invalid: CAA record ... prevents issuance в DNS есть запись CAA (список центров сертификации, которым разрешено выпускать сертификаты для домена), и Let's Encrypt в ней нет добавить CAA 0 issue "letsencrypt.org"
ClusterIssuer Ready: False, invalidContact ... forbidden domain в email адрес в несуществующем или зарезервированном домене (example.com) указать настоящий адрес или убрать поле
too many failed authorizations / too many certificates исчерпан лимит prod после серии ошибок отлаживать на staging, ждать окна лимита
Challenge DNS-01 не создаёт запись, в нём ошибка о ненайденном Secret cloudflare-api-token для ClusterIssuer Secret создан не в неймспейсе cert-manager; готовность издателя это не ловит — она зависит только от регистрации учётной записи перенести Secret
сертификаты для Gateway не создаются Gateway API не включён (в журнале skipping disabled controller ... gateway-shim) или CRD поставлены после старта cert-manager --enable-gateway-api, перезапуск cert-manager

Источники


Теперь у каждого сайта на *.apps.example.com может быть настоящий сертификат, который продлевается сам, а у внутренних сервисов есть свой центр сертификации и способ ему доверять. Трафик снаружи к подам защищён, но внутри кластера по-прежнему любой под может обратиться к любому.

Дальше — NetworkPolicy: как ограничить, кто с кем может разговаривать внутри кластера, что ломается после запрета по умолчанию и как Calico превращает политики в правила на узлах.

Оставить комментарий могут только зарегистрированные пользователи.

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