cert-manager для внешних сервисов: TLS вне Kubernetes

Часть 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 в роли посредника.

Архитектура: выпустить, опубликовать, получить

Конвейер состоит из трёх независимых этапов:

  1. Выпуск (Issue) — cert-manager получает и обновляет сертификат через DNS-01-challenge и сохраняет его как Kubernetes Secret.

  2. Публикация (Publish) — CronJob читает Secret и записывает его в AWS Secrets Manager (только при изменении сертификата).

  3. Получение (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?

Вот конкретная схема:

Схема развёртывания OpenVPN Access Server с cert-manager

Kubernetes-сторона — выпуск и публикация

Примечание

В репозитории: Вся publish-сторона — это единственный Terraform-модуль по пути deployments/modules/tls-issue. Он вызывается из deployments/rke-apps/2-applications/openvpn-cert.tf. YAML-эквиваленты для kubectl apply без Terraform находятся в подкаталоге cert-manager/.

Эта половина полностью переиспользуется для любых сервисов. Здесь нет ничего специфичного для 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 приватных подсетей, хосты из публичных подсетей получают молчаливый таймаут.

Я применил оба исправления:

  1. Правило SG, разрешающее SG OpenVPN обращаться к SG endpoint’а по 443 (трафик остаётся внутри сети AWS).

  2. Явный --endpoint-url в скрипте синхронизации как запасной вариант (обходит DNS VPC endpoint полностью, идёт через публичный интернет с TLS + SigV4).

Схема сетевого взаимодействия с VPC endpoint для Secrets Manager

Подводный камень №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 до приложений. Ниже — только часть, относящаяся к конвейеру сертификатов.

  1. Развернуть VPC с enable_vpc_endpoints = true (VPC/dev).

  2. Развернуть OpenVPN (openvpn/devvpn) — включает правило SG и IAM для Secrets Manager.

  3. Развернуть узлы K8s (RKE-cluster/dev-cluster/ec2) — включает IAM для Route53 и Secrets Manager.

  4. Развернуть RKE2 (RKE-cluster/dev-cluster/RKE) — требует подключения по VPN.

  5. Собрать образ публикатора (deployments/rke-apps/2-applications/scripts — make).

  6. Развернуть конвейер сертификатов (deployments/rke-apps/2-applications — terraform apply).

  7. Запустить Ansible (openvpn/ansible — ./setup-tls-sync.sh) или позволить null_resource сделать это автоматически.

  8. Проверить: 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. История коммитов отражает эволюцию: первоначальные реализации, неудачи и исправления, которые в итоге сделали всё работающим.

© 2026 meganuke