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с middlewareredirectSchemeили HTTPRoute с фильтромRequestRedirectиз прошлой части. Проблем нет: маршрут решателя совпадает с точным путём/.well-known/acme-challenge/<токен>и у Traefik получает приоритет выше, чем правило с префиксом/. На стенде Ingresshello-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 |
Источники
- cert-manager: Installation with kubectl и with Helm — манифест выпуска, чарт,
crds.enabled; Supported Releases — выпуски и сроки поддержки. - cert-manager: cmctl —
check api,status certificate,renew; cert-manager/cmctl — выпуски утилиты. - cert-manager: Issuer, CertificateRequest и ACME Orders and Challenges — цепочка объектов, одобрение, ревизии.
- cert-manager: Certificate resource —
renewBefore,renewBeforePercentage,rotationPolicy,revisionHistoryLimit,secretTemplate, временный Secret. - cert-manager: ACME —
privateKeySecretRef,email, решатели и селекторы,profile; HTTP01 — решателиingressиgatewayHTTPRoute; DNS01 — провайдеры,cnameStrategy, серверы самопроверки. - cert-manager: SelfSigned и CA — внутренний центр сертификации.
- cert-manager: Annotated Gateway resource и Annotated Ingress resource — gateway-shim и ingress-shim, правила для слушателей, включение Gateway API.
- cert-manager: trust-manager — Bundle, пакет публичных корней, предупреждение о Secret корня.
- cert-manager: Troubleshooting Problems with ACME — диагностика Order и Challenge.
- Let's Encrypt: Staging Environment и Rate Limits — окружения и лимиты.
- Let's Encrypt: Challenge Types — HTTP-01, DNS-01, перенаправления; Certificate Authority Authorization (CAA); Certificate Transparency (CT) Logs.
- Let's Encrypt: Profiles и Decreasing Certificate Lifetimes to 45 Days — профили, сроки и график сокращения.
- Let's Encrypt: Chains of Trust — корни и промежуточные CA, Root YE и YE1, перекрёстные подписи.
- Let's Encrypt: Expiration Notification Service Has Ended — конец писем об истечении.
- RFC 8555: Automatic Certificate Management Environment (ACME) — протокол, испытания HTTP-01 и DNS-01; RFC 9773: ACME Renewal Information (ARI) Extension; RFC 2136: Dynamic Updates in the Domain Name System.
- Kubernetes: Secrets — TLS Secrets, Ingress — TLS, Gateway API, Dynamic Admission Control и Encrypting Confidential Data at Rest.
- Gateway API: HTTPRoute — типы совпадения пути и их приоритет.
- Traefik: EntryPoints — перенаправление на точке входа и
allowACMEByPass.
Теперь у каждого сайта на *.apps.example.com может быть настоящий сертификат, который продлевается сам, а у внутренних сервисов есть свой центр сертификации и способ ему доверять. Трафик снаружи к подам защищён, но внутри кластера по-прежнему любой под может обратиться к любому.
Дальше — NetworkPolicy: как ограничить, кто с кем может разговаривать внутри кластера, что ломается после запрета по умолчанию и как Calico превращает политики в правила на узлах.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.