Часть 1: Строим конвейер — как использовать cert-manager для выпуска и доставки TLS-сертификатов Let’s Encrypt сервисам за пределами кластера
Последняя проверка: апрель 2026 года.
Недооценённый паттерн
В вашем кластере Kubernetes работает cert-manager. Он незаметно обновляет TLS-сертификаты для всего, что живёт внутри кластера. Ваш OpenVPN-сервер на EC2-инстансе за пределами кластера снова доживает до очередного истечения TLS-сертификата — а с учётом того, что сроки действия сертификатов год за годом сокращаются, никто в команде уже не помнит, чья сейчас очередь его обновлять.
Вот в чём дело: cert-manager умеет выпускать и этот сертификат тоже. Он может выпускать сертификаты для чего угодно, на что у вас есть DNS-имя, — неважно, работает ли это в Kubernetes или нет. Большинство команд об этом не подозревают.
Дело не только в VPN-серверах. Устаревшие приложения на отдельных EC2-инстансах. Сторонние устройства, которым нужен TLS, но которые сами не могут запустить cert-manager. Фронтенды балансировщиков нагрузки, не управляемые Kubernetes. Всем этим сервисам нужны TLS-сертификаты, и большинство команд управляют ими отдельно — ручное обновление, cron-скрипты, обращающиеся к Let’s Encrypt напрямую, или дорогие wildcard-сертификаты.
Механизм прост: cert-manager с DNS-01-валидацией никак не привязан к тому, где именно будет использоваться сертификат. Всё, что ему нужно, — возможность создать DNS TXT-запись в вашей зоне. Сам сертификат можно отправить куда угодно.
Недостающий элемент — механизм доставки: способ переместить сертификат из Kubernetes Secret, где cert-manager его хранит, к внешнему сервису, которому он нужен. Именно это и строит данная статья: переиспользуемый конвейер с AWS Secrets Manager в роли посредника.
Архитектура: выпустить, опубликовать, получить
Конвейер состоит из трёх независимых этапов:
-
Выпуск (Issue) — cert-manager получает и обновляет сертификат через DNS-01-challenge и сохраняет его как Kubernetes Secret.
-
Публикация (Publish) — CronJob читает Secret и записывает его в AWS Secrets Manager (только при изменении сертификата).
-
Получение (Consume) — cron-задание на внешнем сервисе забирает данные из Secrets Manager и устанавливает сертификат (только если он новый).
Каждый этап ничего не знает об остальных. Kubernetes-сторона не знает и не заботится о том, кто потребляет секрет. Внешний сервис не знает и не заботится о том, как был выпущен сертификат. Secrets Manager — это граница контракта.
Почему такое разделение важно:
-
Один публикатор, много потребителей. Publish-сторону пишете один раз. Каждый внешний сервис нуждается лишь в небольшом скрипте-потребителе, адаптированном под то, как именно он устанавливает сертификаты.
-
Публикатор переиспользуется. Один и тот же образ контейнера, шаблон CronJob и паттерн RBAC подходят для любого сертификата. Меняются только переменные окружения.
-
Secrets Manager — правильная абстракция. Он обеспечивает шифрование данных в покое, контроль доступа через IAM, аудит через CloudTrail и версионирование. Всё это не придётся строить самостоятельно.
Далее статья описывает полную реализацию на примере OpenVPN Access Server в роли внешнего сервиса. Но паттерн применим к чему угодно: SMTP-шлюз, отдельный прокси для базы данных, сетевое устройство, Jenkins-сервер. Словом, везде, где иначе пришлось бы устанавливать сертификат вручную.
|
Примечание
|
Живой пример: Полная реализация находится по адресу github.com/mpechner/iac-aws-org-k8s — production-ориентированное окружение AWS + RKE2 Kubernetes с полным Terraform IaC. |
Конкретный пример: OpenVPN Access Server
Мой OpenVPN Access Server работает на EC2-инстансе в публичной подсети. У него был статический TLS-сертификат, который я обновлял раз в год. В кластере уже работал cert-manager для Traefik ingress, Rancher и тестовых приложений. Вопрос был прост: зачем поддерживать отдельный жизненный цикл сертификата для VPN?
Вот конкретная схема:
Kubernetes-сторона — выпуск и публикация
|
Примечание
|
В репозитории: Вся publish-сторона — это единственный Terraform-модуль по пути deployments/modules/tls-issue. Он вызывается из deployments/rke-apps/2-applications/openvpn-cert.tf. YAML-эквиваленты для |
Эта половина полностью переиспользуется для любых сервисов. Здесь нет ничего специфичного для OpenVPN.
Шаг 1: Создать выделенный ClusterIssuer
Можно повторно использовать существующий ClusterIssuer, но рекомендую создавать отдельный для каждой зоны. Это следует принципу минимальных привилегий: каждый issuer может изменять DNS-записи только в своей зоне.
resource "kubernetes_manifest" "service_clusterissuer" {
manifest = {
apiVersion = "cert-manager.io/v1"
kind = "ClusterIssuer"
metadata = {
name = "letsencrypt-myservice-prod"
}
spec = {
acme = {
server = "https://acme-v02.api.letsencrypt.org/directory"
email = "you@example.com"
privateKeySecretRef = {
name = "letsencrypt-myservice-prod"
}
solvers = [
{
dns01 = {
route53 = {
region = "us-west-2"
hostedZoneID = "ZXXXXXXXXXXXXX"
}
}
}
]
}
}
}
}
Ключевое решение: DNS-01 против HTTP-01. DNS-01 — правильный выбор для внешних сервисов, потому что:
-
Целевой сервис не должен обслуживать ACME-challenge — cert-manager полностью берёт это на себя через DNS TXT-записи.
-
Работает для сервисов в приватных подсетях, за файрволами или в отдельных сетях.
-
Работает для wildcard-сертификатов.
-
Единственное требование — cert-manager должен уметь изменять вашу DNS-зону (в данном случае Route53).
Подводный камень №1: IAM-права для cert-manager. Если узлы кластера работают на EC2, cert-manager может использовать instance profile узла для доступа к Route53. Никаких статических ключей доступа, никаких Kubernetes Secrets с AWS-кредами на ротацию. IAM-роль узла требует route53:ChangeResourceRecordSets с ограничением по конкретной зоне, плюс ListHostedZonesByName и GetChange на * (AWS требует, чтобы эти действия не были ограничены ресурсом).
Шаг 2: Создать ресурс Certificate
resource "kubernetes_manifest" "service_cert" {
manifest = {
apiVersion = "cert-manager.io/v1"
kind = "Certificate"
metadata = {
name = "myservice-tls"
namespace = "myservice-certs"
}
spec = {
secretName = "myservice-tls"
duration = "2160h" # 90 дней
renewBefore = "720h" # обновление на 60-й день — 30 дней запаса для повторных попыток
commonName = "myservice.dev.example.com"
dnsNames = ["myservice.dev.example.com"]
issuerRef = {
name = "letsencrypt-myservice-prod"
kind = "ClusterIssuer"
group = "cert-manager.io"
}
}
}
}
Для каждого сервиса я использую отдельный namespace, чтобы границы RBAC были чёткими — CronJob-публикатор для этого сервиса может читать только этот секрет.
Подводный камень №2: арифметика renewBefore. Сертификаты Let’s Encrypt действительны 90 дней. При renewBefore: 720h (30 дней) cert-manager начинает пытаться обновить сертификат на 60-й день. Если DNS-01-challenge не проходит, cert-manager повторяет попытки с экспоненциальной задержкой. Окно в 30 дней означает, что даже недельный сбой Route53 не приведёт к истечению срока сертификата.
Шаг 3: Собрать контейнер-публикатор
|
Примечание
|
В репозитории: deployments/modules/tls-issue/publisher — содержит Python-скрипт, Dockerfile, requirements.txt и README с инструкциями по настройке IAM и сборке. |
Публикатор — небольшой Python-скрипт (~200 строк). Он читает tls.crt и tls.key из примонтированного тома, вычисляет SHA256-отпечаток листового сертификата, проверяет существующий отпечаток в Secrets Manager и записывает данные только при изменении отпечатка.
def main() -> int:
fullchain_pem = load_pem(Path("/etc/tls/tls.crt"))
privkey_pem = load_pem(Path("/etc/tls/tls.key"))
leaf_der = get_leaf_der(fullchain_pem)
fingerprint = fingerprint_sha256(leaf_der)
_, chain_pem = split_fullchain(fullchain_pem)
payload = {
"fqdn": fqdn,
"fingerprint_sha256": fingerprint,
"fullchain_pem": fullchain_pem.decode("utf-8"),
"privkey_pem": privkey_pem.decode("utf-8"),
"chain_pem": chain_pem.decode("utf-8") if chain_pem else "",
}
client = boto3.client("secretsmanager", region_name=region)
try:
current = client.get_secret_value(SecretId=secret_name)
existing_fp = json.loads(current["SecretString"]).get("fingerprint_sha256")
if existing_fp == fingerprint:
logger.info("Fingerprint unchanged — skipping update.")
return 0
except ClientError as e:
if e.response["Error"]["Code"] == "ResourceNotFoundException":
pass # Первый запуск — будет создан новый секрет
client.put_secret_value(SecretId=secret_name, SecretString=json.dumps(payload))
return 0
Подводный камень №3: лимиты Docker Hub. Используйте public.ecr.aws/docker/library/python:3.12-slim вместо Docker Hub. Тот же образ, без ограничений на загрузку.
Этот образ контейнера полностью переиспользуется. Он параметризован исключительно через переменные окружения — ничего специфичного для OpenVPN. Чтобы публиковать сертификат для другого сервиса, достаточно сменить переменные окружения.
Шаг 4: Развернуть CronJob
CronJob монтирует Kubernetes Secret напрямую в /etc/tls как том — без kubectl, без обращений к API-серверу, без широких прав ServiceAccount. RBAC для ServiceAccount cert-publisher ограничен правом get на единственный именованный Secret. Полный манифест CronJob — в репозитории.
Шаг 5: IAM-права
|
Примечание
|
В репозитории: IAM-роль узла находится в RKE-cluster/modules/ec2/main.tf. |
CronJob использует instance profile EC2-узла. Роль узла требует прав на Route53 (для DNS-01 в cert-manager) и права на запись в Secrets Manager (для публикатора). Никаких отдельных IAM-пользователей, никаких статических ключей доступа.
Важная оговорка: подход с ролью узла означает, что каждый под на каждом узле наследует эти права. Для dev-окружения это приемлемо. В production обязательно использовать IRSA, чтобы ограничить права только теми подами, которым они нужны. Часть 2 этой серии подробно разбирает этот вопрос.
Сторона потребителя — забрать и установить
Это сервис-специфичная половина. Пример ниже — для OpenVPN Access Server, но паттерн применим к чему угодно.
Паттерны потребителей для разных сервисов
Сервисы, на которые заходят по SSH — потребитель запускается как системный cron на инстансе:
-
OpenVPN Access Server — установка через
sacli ConfigPut, перезапуск черезsacli start(только веб-интерфейс; туннели не затрагиваются). -
Nginx (отдельный) — запись в
/etc/nginx/ssl/, перезагрузка черезnginx -s reload. -
HAProxy — объединение сертификата и ключа в один PEM-файл, перезагрузка через
systemctl reload haproxy. -
PostgreSQL — запись по пути
ssl_cert_file, перезагрузка черезpg_ctl reload.
Управляемые AWS-сервисы — потребитель запускается как K8s CronJob или Lambda (только API-вызов, SSH не требуется):
-
ALB/NLB —
aws acm import-certificate, обновление listener; перезапуск не нужен, listener подхватывает новый сертификат автоматически.
Думайте о своём окне обслуживания. Nginx и HAProxy умеют перезагружаться без разрыва соединений. sacli start у OpenVPN ненадолго прерывает работу веб-интерфейса, но не VPN-туннели. Подстройте расписание cron под время, когда перезапуск безопасен — для OpenVPN это означало ограничение синхронизации окном с полуночи до трёх ночи, чтобы кратковременный сбой консоли администратора никто не заметил.
Проблема с VPC Endpoint
|
Примечание
|
В репозитории: VPC endpoints — в VPC/modules/vpc/mainf.tf, правило SG — в openvpn/devvpn/main.tf. |
Это касается любого внешнего сервиса в публичной подсети, которому нужно обращаться к Secrets Manager.
Когда для VPC interface endpoint задано private_dns_enabled = true (значение по умолчанию), имя хоста secretsmanager.us-west-2.amazonaws.com разрешается в IP-адреса приватного endpoint’а для каждого хоста в VPC — включая публичные подсети. Если SG вашего endpoint’а разрешает только CIDR приватных подсетей, хосты из публичных подсетей получают молчаливый таймаут.
Я применил оба исправления:
-
Правило SG, разрешающее SG OpenVPN обращаться к SG endpoint’а по 443 (трафик остаётся внутри сети AWS).
-
Явный
--endpoint-urlв скрипте синхронизации как запасной вариант (обходит DNS VPC endpoint полностью, идёт через публичный интернет с TLS + SigV4).
Подводный камень №4: VPC endpoints с приватным DNS. Это бьёт по любому сервису в публичной подсети, вызывающему любой AWS API, для которого у вас есть interface endpoint, — не только Secrets Manager.
Развёртывание потребителя OpenVPN через Ansible
|
Примечание
|
В репозитории: Плейбук — openvpn/ansible/openvpn-tls-sync.yml, обёртка — openvpn/ansible/setup-tls-sync.sh. |
Плейбук устанавливает AWS CLI v2 (фиксированная версия с проверкой контрольной суммы), создаёт скрипт синхронизации и настраивает cron-задание (каждые 30 минут, только с полуночи до 3:00). Скрипт синхронизации забирает данные из Secrets Manager, сравнивает отпечатки и устанавливает сертификат через sacli ConfigPut только при изменении. Активные VPN-туннели не прерываются.
Подводный камень №5: sacli против копирования файлов. Более ранняя версия (всё ещё есть в репозитории как install-cert-via-ssh.sh) копировала PEM-файлы напрямую. sacli ConfigPut — правильный метод: он обновляет внутреннюю базу данных и корректно обрабатывает цепочку сертификатов.
Подводный камень №6: временный каталог Ansible. У пользователя openvpnas ограничены права на домашний каталог. Решение: ansible_remote_tmp: /tmp/.ansible-${USER}. Обратите внимание: /tmp используется только для служебных файлов Ansible — скрипт синхронизации никогда не пишет приватные ключи в /tmp. Данные сертификата помещаются во временный каталог с правами root (mktemp -d /root/.openvpn-tls-sync-XXXXXX, chmod 700) с очисткой через trap "rm -rf" EXIT.
Общий контракт — формат JSON секрета
Публикатор и потребитель согласованы на едином JSON-формате:
{
"fqdn": "vpn.dev.example.com",
"fingerprint_sha256": "a1b2c3d4e5f6...",
"fullchain_pem": "-----BEGIN CERTIFICATE-----\n...",
"privkey_pem": "-----BEGIN PRIVATE KEY-----\n...",
"chain_pem": "-----BEGIN CERTIFICATE-----\n..."
}
fingerprint_sha256 — ключ идемпотентности: публикатор пропускает запись, а потребитель пропускает установку, если отпечаток не изменился. Оба cron-задания могут запускаться часто, не совершая лишних API-вызовов и не перезапуская сервисы без нужды.
Пошаговая инструкция по развёртыванию
|
Примечание
|
В репозитории: Пути указаны относительно github.com/mpechner/iac-aws-org-k8s. README содержит полный 12-шаговый процесс развёртывания от VPC до приложений. Ниже — только часть, относящаяся к конвейеру сертификатов. |
-
Развернуть VPC с
enable_vpc_endpoints = true(VPC/dev). -
Развернуть OpenVPN (openvpn/devvpn) — включает правило SG и IAM для Secrets Manager.
-
Развернуть узлы K8s (RKE-cluster/dev-cluster/ec2) — включает IAM для Route53 и Secrets Manager.
-
Развернуть RKE2 (RKE-cluster/dev-cluster/RKE) — требует подключения по VPN.
-
Собрать образ публикатора (deployments/rke-apps/2-applications/scripts —
make). -
Развернуть конвейер сертификатов (deployments/rke-apps/2-applications —
terraform apply). -
Запустить Ansible (openvpn/ansible —
./setup-tls-sync.sh) или позволитьnull_resourceсделать это автоматически. -
Проверить:
kubectl get certificate, вручную запустить публикатор, проверить Secrets Manager, запустить синхронизацию на OpenVPN, убедиться в корректности сертификата в браузере.
Сводка подводных камней
Стоимость
Что дальше
cert-manager обычно преподносят как инструмент исключительно для Kubernetes: выпускает сертификаты для Ingress-ресурсов и всё. Но DNS-01-валидация отделяет выпуск сертификата от места его использования, а CronJob-публикатор соединяет кластер с внешним хранилищем секретов. В результате получается универсальный конвейер для управления сертификатами всей инфраструктуры.
Если cert-manager уже работает в вашем кластере, у вас готово 90% этого конвейера. Оставшиеся 10% — это сантехника, и скрипт-потребитель для каждого типа сервиса пишется один раз.
Но мы ещё не говорили о безопасности. Этот конвейер хранит приватные ключи в Secrets Manager и использует IAM на уровне узлов, что даёт каждому поду на каждом узле доступ на запись ваших сертификатов. Для dev — нормально. Для production — нет.
Часть 2: Защита конвейера — выйдет в ближайшее время. В ней разбирается, кто должен иметь доступ к вашим секретам (а кто не должен), resource policies, ключи KMS под управлением клиента, IRSA для ограничения IAM-прав на уровне подов и почему IMDSv1 — это неприемлемо.
Полная реализация опубликована с открытым исходным кодом по адресу github.com/mpechner/iac-aws-org-k8s. Переиспользуемый Terraform-модуль — в deployments/modules/tls-issue, контейнер-публикатор — в deployments/modules/tls-issue/publisher, Ansible-потребитель для OpenVPN — в openvpn/ansible. Репозиторий включает полный VPC, кластер RKE2, стек Traefik ingress и инструкцию по развёртыванию — не только конвейер сертификатов.
О том, как написана эта статья
При работе над проектом активно использовались AI-агенты (Claude в Cursor) — не только для написания этой статьи, но и для создания самой инфраструктуры. Terraform-модули, Python-публикатор, Ansible-плейбуки и значительная часть отладки разрабатывались в сотрудничестве с агентным AI.
Архитектурные решения рождались из понимания среды: cert-manager уже работал в кластере, и возник вопрос — зачем обновлять сертификат VPN вручную, если автоматизация уже рядом? Проблемы, описанные здесь, обнаружились в ходе реального развёртывания: поведение DNS при использовании VPC endpoint потребовало нескольких часов расследования до прояснения причины, а различие между sacli и прямым копированием файлов пришлось освоить на собственном опыте, когда после замены файлов сертификаты перестали работать.
AI помогал с реализацией: генерировал аккуратный Terraform, структурировал Python-публикатор в идиоматичном стиле, переводил требования в работающий Ansible и превращал разрозненные заметки в читаемую статью. На практике это работало как мультипликатор возможностей — но системе всё равно нужен был человек, понимающий архитектуру, способный распознать, когда что-то идёт не так, и итерировать до получения результата.
Весь проект опубликован на github.com/mpechner/iac-aws-org-k8s. История коммитов отражает эволюцию: первоначальные реализации, неудачи и исправления, которые в итоге сделали всё работающим.