Kubernetes — превосходная платформа для развертывания приложений и управления ими, но иногда простые задачи, такие как наличие одних и тех же данных — в нашем случае секретов — между пространствами имен, могут вызвать небольшие проблемы, и вам придется создавать собственные решения этих проблем.

Привет! Меня зовут Игорь Латкин, я архитектор решений в КТС.

Сегодня я хотел бы поделиться проектом, который мы разработали внутри компании — контроллер зеркала Kubernetes. Мы создали его внутри нашего отдела DevOps, чтобы решить проблему копирования секретов Kubernetes между пространствами имен кластера. В результате зеркала превратились в инструмент общего назначения, предназначенный для синхронизации данных из разных источников. В статье я расскажу историю с чего все началось и к чему мы пришли в конце пути. Надеюсь, это вдохновит вас на создание собственного контроллера, отвечающего вашим потребностям.

Это план статьи:

  1. Как все началось
  2. Динамические среды с TLS
  3. Проблема слишком большого количества сертификатов
  4. Проблема единого сертификата
  5. Существующие решения и их недостатки
  6. SecretMirror для спасения
  7. Архитектура контроллера
  8. Копирование секретов между пространствами имен
  9. Копирование секретов из хранилища HashiCorp в кластер Kubernetes
  10. Копирование секретов из кластера Kubernetes в хранилище HashiCorp
  11. Бонус
  12. Результаты и сравнительные диаграммы

Как все началось

💡 Перед нами стояла одна большая задача — включить TLS в динамических средах в среде разработки KTS.

Все команды разработчиков используют наш dev-кластер, поэтому процесс обеспечения безопасного соединения должен быть полностью автономным. Позже мы поняли, что могли бы использовать разработанное решение и для других задач, о которых я также расскажу в этой статье позже.

Начнем с динамических сред.

Динамические среды с TLS

KTS уже некоторое время сталкивался с задачей копирования секретов между пространствами имен в кластере Kubernetes. Наши процессы построены так, что каждая команда — и даже каждый разработчик в компании — вполне самостоятельны и самодостаточны.

Каждый разработчик может создать репозиторий в Gitlab, подключить общий конвейер CI/CD, определить необходимые доменные имена для проекта в локальном файле gitlab-ci.yml и развернуть проект на нескольких платформах. и производственных сред за пару минут. И им вообще не нужно привлекать для этого команду DevOps.

include:
  project: mnt/ci
  file: front/ci.yaml
  ref: ${CI_REF}

variables:
  DEV_BASE_DOMAIN: projectA.dev.example.com
  DEV_API_DOMAIN: projectA.dev.example.com
  PROD_DOMAIN: projectA.prod.example.com

Этот gitlab-ci.yml в проекте превращается в гораздо более крупный пайплайн, охватывающий задачи по сборке и развертыванию проекта в различных средах:

Почти все проекты в среде разработки развертываются на поддомене одного домена, который мы будем называть dev.example.com. То есть проекты можно развернуть на следующие поддомены:

  • projectA.dev.example.com
  • проектB.dev.example.com
  • проектC.dev.example.com

Следует также отметить, что некоторые приложения состоят из нескольких микросервисов, объединенных разными правилами входа. Например:

Поскольку они размещены на одном и том же доменном имени, желательно правильно указать один TLS-сертификат для этих входов. Они развертываются в разных пространствах имен для улучшения изоляции и просто потому, что именно так работает «на основе сертификатов» интеграция Gitlab с Kubernetes.

Вообще говоря, интеграция Gitlab с Kubernetes на основе сертификатов уже устарела, и ее стоит перейти на Gitlab Agent (или на другой инструмент GitOps, такой как ArgoCD или FluxCD). Но это не то, что мы собираемся рассказать в этой статье.

Проблема слишком большого количества сертификатов

Можно подумать, что можно просто выдать сертификат при каждом входе каждого проекта, и проблема решена. Именно так мы и жили какое-то время. Но в итоге мы уперлись в Ограничения Let’s Encrypt по количеству выданных сертификатов. Особенно ярко мы это испытали, когда у нас была массовая миграция с одного кластера на другой, или когда по разным причинам приходилось перевыпускать все сертификаты.

Второй недостаток этого решения заключается в том, что вам нужно ждать определенное время, пока сертификат действительно не будет выдан, например, при создании новой ветки проекта. Кроме того, этот процесс может завершиться неудачей, и у вас может вообще не быть сертификата. Вот почему кажется естественным хранить только один сертификат и предоставлять доступ к нему всем.

Но тогда возникает другая проблема.

Сертификат, выданный *.dev.example.com, действителен для projectA.dev.example.com, но недействителен для feature1.projectA.dev.example.com. Поэтому, если мы хотим построить динамическое окружение с субдоменами, мы будем заложниками этого решения.

Итак, мы сформулировали задачу следующим образом:

💡 Задание №1

Для поддержки TLS в динамических средах проекта необходимо выпустить сертификат для ветки main каждого проекта и скопировать секрет, содержащий данные сертификата, во все остальные пространства имен этого проекта.

Для проекта с именем ‹project_name›, идентификатором ‹project_id› и именем ветки, равным ‹branch_name›, пространства имен будут иметь имена как следующее:

‹project_name›-‹project_id›-‹branch_name›-‹some_hash›

Некоторые примеры:

  • проект-А-1120-основной-23ХФ
  • проект-а-1120-dev-4hg5
  • проект-а-1120-функция-1-aafd
  • проект-б-1200-основной-7ds9
  • проект-b-1200-feature1–42qq

То есть физически объект Сертификат создается только в project-a-1120-main-23hf и project-b-1200-main -7ds9 пространства имен. Результат выдачи сертификата — Secret, содержащий tls.crt и tls.key — необходимо скопировать во все остальные соответствующие пространства имен:

apiVersion: v1
kind: Secret
type: kubernetes.io/tls
metadata:
  name: ktsdev-wild-cert
data:
  tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0F...
  tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkF...

Проблема единого сертификата

Теперь давайте рассмотрим вторую задачу, которую мы смогли охватить. Он достаточно близок к первому случаю, но имеет свои особенности.

Представьте, что мы говорим о какой-то производственной среде, и эта среда находится в кластере Kubernetes. Предположим, он состоит из нескольких кластеров, распределенных по географическим областям. И все эти кластеры обслуживают одно доменное имя, например, myshop.example.com. Мы действительно хотим, чтобы наш конечный пользователь видел один сертификат TLS, независимо от того, в какой кластер он попадает. Первоначально сертификат может исходить из различных источников:

  1. Вы можете купить сертификат.
  2. Сертификат можно выдать с помощью различных инструментов, таких как cert-manager в одном из кластеров. Затем мы должны решить, как доставить его на все производственные кластеры.

Естественно, имеет смысл использовать централизованное хранилище для хранения сертификата и использовать какой-либо инструмент для его доставки в нужные вам места использования.

До того, как мы внедрили наше обсуждаемое решение, у нас было прямое, но жизнеспособное решение: сертификат был развернут с помощью helm на все необходимые кластеры в составе инфраструктуры. Для обновления сертификата достаточно было обновить его в хелм-чарте и развернуть заново. Но мы хотели повысить автоматизацию и безопасность: например, нам не нравилось хранить сертификат в репозитории git.

Конечно, проблемы возникают и за пределами области сертификатов. Это могут быть любые данные, к которым вы хотите получить доступ в нескольких местах одновременно — учетные данные для реестра образов, логины/пароли баз данных и многое другое.

Итак, теперь мы сформулируем обе задачи, которые у нас были:

💡 Задание №1 (просто запомнить)

Для поддержки TLS в динамических средах проекта необходимо выпустить сертификат для ветки main каждого проекта и скопировать секрет, содержащий данные сертификата, во все остальные пространства имен этого проекта.

💡 Задание №2

Чтобы иметь возможность автоматически синхронизировать Secret из централизованного хранилища со всеми кластерами Kubernetes и выбранными в них пространствами имен.

Существующие решения и их недостатки

После того, как мы поняли, что именно мы хотим сделать, мы начали искать удовлетворительные решения. Два проекта, которые мы имели в виду для решения первой задачи:

Мы сразу откладываем kubed, так как он зависит от меток, которые вам нужно добавить как в Secrets или ConfigMaps, так и от пространств имен, в которые вы их копируете. У нас не было возможности динамически добавлять метки при создании пространств имен. Узнайте подробнее о том, как работает kubed.

kubernetes-reflector работает аналогично, но может следовать за объектами Сertificate и добавлять дополнительные аннотации или метки, чтобы включить синхронизацию секретов.

Также допускается указывать регулярное выражение, которому должно соответствовать пространство имен, чтобы в него копировался секрет. Нет необходимости маркировать объекты пространства имен. Давайте посмотрим на пример:

apiVersion: v1
kind: Secret
metadata:
 name: source-secret
 annotations:
   reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
   reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "project-a-1120-*"
data:
 ...

Здесь наиболее важной частью является аннотация reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces. С его помощью мы должны были легко решить первую задачу: развернуть Сертификат только для основной ветки, чтобы он автоматически копировался во все остальные ветки. Маска project-a-1120-* прекрасно описывает, в какие ветки нужно скопировать секрет.

Мы настроили все проекты и развернули это решение в нашем кластере разработки. В общем, kubernetes-reflector делал свое дело, пока разработчики не начали жаловаться, что иногда сертификаты не выдаются. На самом деле сертификат был выдан, но почему-то не скопировался в новую фиче-ветку.

На графиках использования ресурсов kubernetes-reflector мы увидели следующую картину (разные цвета обозначают разные поды):

Из-за высокого потребления памяти и многоцветности графика было ясно, что отражатель был убит OOM, из-за чего он каждый раз перезапускался. Утилизация сетевых ресурсов также была неожиданной и означала, что контроллер пытался загрузить много данных из kube-api.

К сожалению, по мере роста числа проектов выявились недостатки внутренней архитектуры рефлектора. На тот момент в кластере было около 10 тысяч разных Секретов. Учитывая все обстоятельства, отражатель наблюдал за каждым из них и неэффективно использовал детали пространства имен. Вот почему мы наблюдали интенсивное использование сетевого трафика на графиках.

В более поздних версиях Reflector проблемы с производительностью были решены, но это произошло через два месяца после того, как мы перешли на собственное решение.

Кроме того, нам очень хотелось, чтобы Secret появлялся в нужном namespace только через мгновения после его создания в кластере Kubernetes, а не после какого-то периода внутренней синхронизации.

В конце концов, мы решили как можно скорее разработать наш внутренний инструмент. Мы хотели, чтобы этот инструмент справлялся с нашими задачами и решал проблемы с производительностью, которые были у рефлектора. Мы справились за две недели: развернули его на нашем dev-кластере и перевели все команды на этот инструмент.

SecretMirror для спасения

Итак, вот требования к нашему новому контроллеру:

  1. Он должен работать со своим CRD. Он будет называться SecretMirror. Это требование резко снижает нагрузку на сервер API и ресурсы производительности контроллера: очевидно, что количество сущностей SecretMirror гораздо меньше, чем самих секретов.
  2. SecretMirror должен принимать список регулярных выражений пространств имен, в которые необходимо скопировать секрет. Это позволяет нам гибко управлять целевым расположением ресурсов.
  3. Контроллер должен следить не только за SecretMirror, но и за объектами Namespace. Это позволяет копировать секрет в новое пространство имен сразу после создания пространства имен, а не после произвольного периода синхронизации.
  4. Он должен поддерживать актуальный список пространств имен в кэше памяти. Поэтому мы экономим много трафика, не извлекая один и тот же список каждый раз, когда нам нужно синхронизировать секрет с пространствами имен. Этот кеш прост в поддержке благодаря выполнению пункта № 3, и мы можем динамически добавлять и удалять пространство имен в него или из него.
  5. При удалении SecretMirror все секреты, созданные контроллером, должны быть автоматически удалены. Однако нам нужна возможность отключить такое поведение.
  6. В случае изменения исходного секрета его синхронизация не требуется немедленно. В противном случае нам пришлось бы снова наблюдать за всеми секретными объектами в кластере. Достаточно хорошо, если синхронизация происходит один раз в определенный промежуток времени: например, с интервалом в 3 минуты. Этот интервал должен изменяться для каждого конкретного SecretMirror.
  7. Контроллер должен быть расширяемым. Поэтому внешние системы, такие как HashiCorp Vault, могут использоваться в качестве источника или назначения секрета.

Архитектура контроллера

Все довольно просто. Внутри зеркал запускаются два контроллера, которые следят за любыми изменениями двух ГВКmirrors.kts.studio/v1alpha2.SecretMirror и v1.Пространство имен. Все изменения пространства имен сохраняются в памяти контроллера, чтобы свести к минимуму количество обращений к Kubernetes API.

Теперь вернемся к задаче №2: синхронизировать данные между несколькими кластерами. Здесь, в КТС, мы активно используем ХашиКорп Хранилище для хранения секретных данных, и здесь решили пойти по тому же пути.

Vault будет использоваться как система синхронизации состояний. Для этого нам нужно было научить SecretMirror читать и записывать секреты из/в Vault. В примерах использования ниже мы увидим, как это можно использовать.

Копирование секретов между пространствами имен

Для начала подробно рассмотрим первый сценарий, для которого изначально создавался SecretMirror. Манифест выглядит следующим образом:

apiVersion: mirrors.kts.studio/v1alpha2
kind: SecretMirror
metadata:
  name: mysecret-mirror
  namespace: default
spec:
  source:
    name: mysecret
  destination:
    namespaces:
      - project-a-.+
      - project-b-.+

Его задачей является копирование секрета с именем mysecret из пространства имен по умолчанию во все пространства имен, начинающиеся с префикса project-a- или project-b-. Применим манифест и выведем список всех SecretMirrors:

$ kubectl apply -f mysecret-mirror.yaml
$ kubectl get secretmirrors
NAME              SOURCE TYPE   SOURCE NAME   DESTINATION TYPE   DELETE POLICY   POLL PERIOD   MIRROR STATUS   LAST SYNC TIME         AGE
mysecret-mirror   secret        mysecret      namespaces         delete          180           Pending         1970-01-01T00:00:00Z   15s

SecretMirror находится в состоянии Pending — он ожидает появления исходного секрета. Давайте развернем его:

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
  namespace: default
type: Opaque
stringData:
  username: hellothere
  password: generalkenobi

Статус SecretMirror меняется с Pending на Active:

$ kubectl get secretmirrors
NAME              SOURCE TYPE   SOURCE NAME   DESTINATION TYPE   DELETE POLICY   POLL PERIOD   MIRROR STATUS   LAST SYNC TIME         AGE
mysecret-mirror   secret        mysecret      namespaces         delete          180           Active          2022-08-05T21:28:55Z   5m2s

Теперь давайте создадим пространства имен, в которые должен быть скопирован Secret:

$ kubectl create ns project-a-main
$ kubectl create ns project-b-main

Секреты немедленно копируются в эти новые пространства имен:

$ kubectl get secret -A | grep "mysecret"
NAMESPACE          NAME         TYPE      DATA   AGE
default            mysecret     Opaque    2      6m23s
project-a-main     mysecret     Opaque    2      23s
project-b-main     mysecret     Opaque    2      23s

В kubectl description SecretMirror вы можете увидеть более подробную информацию, связанную с событиями объекта:

Name:         mysecret-mirror
Namespace:    default
Labels:       <none>
Annotations:  <none>
API Version:  mirrors.kts.studio/v1alpha2
Kind:         SecretMirror
Metadata:
  Creation Timestamp:  2022-08-05T21:23:55Z
  Finalizers:
    mirrors.kts.studio/finalizer
  Generation:  2
  Resource Version:  109673149
  UID:               825ded22-0e90-4576-9608-1b63a1b02428
Spec:
  Delete Policy:  delete
  Destination:
    Namespaces:
      project-a-.+
      project-b-.+
    Type:               namespaces
  Poll Period Seconds:  180
  Source:
    Name:  mysecret
    Type:  secret
Status:
  Last Sync Time:  2022-08-05T21:38:41Z
  Mirror Status:   Active
Events:
  Type     Reason    Age                 From                Message
  ----     ------    ----                ----                -------
  Warning  NoSecret  10m (x11 over 15m)  mirrors.kts.studio  secret default/mysecret not found, waiting to appear
  Normal   Active    10m                 mirrors.kts.studio  SecretMirror is synced

Копирование секретов из хранилища HashiCorp в кластер Kubernetes

Рассмотрим другой сценарий. Представьте, что в нашем кластере Vault есть секрет со следующим содержимым:

И наша цель — регулярно синхронизировать эти данные с Secret в кластере Kubernetes. Вот так выглядит манифест для SecretMirror, решающий эту задачу:

apiVersion: mirrors.kts.studio/v1alpha2
kind: SecretMirror
metadata:
  name: myvaultsecret-mirror
  namespace: default
spec:
  source:
    name: myvaultsecret-sync
    type: vault
    vault:
      addr: https://vault.example.com
      path: /secret/data/myvaultsecret
      auth:
        approle:
          secretRef:
            name: vault-approle
  destination:
    type: namespaces
    namespaces:
      - project-c-.+

Из-за этой конфигурации контроллер зеркал будет синхронизировать секрет хранилища с именем myvaultsecret с секретом Kubernetes с именем myvaultsecret-sync для пространства имен, имена которых начинаются с префикса project-c-. В настоящее время наша интеграция с Vault поддерживает 2 типа аутентификации:

  1. на основе токенов
  2. на основе AppRole

Подробнее о том, как настроить аутентификацию, вы можете узнать в README проекта.

Таким образом, задача №2 легко решается по описанному сценарию с использованием централизованного хранилища. В частности, мы можем разместить данные tls.crt и tls.key в Хранилище, настроить SecretMirror и получить возможность поддерживать сертификат в актуальном состоянии. в одном или нескольких кластерах в любое время, просто обновив сертификат в самом хранилище.

Копирование секретов из кластера Kubernetes в хранилище HashiCorp

Возвращаясь к одной из наших основных задач, можно вспомнить, что TLS-сертификат также может быть выдан cert-manager. Мы хотели бы синхронизировать его с другими кластерами нашей производственной среды. Здесь можно использовать ту же интеграцию с Vault. Только на этот раз мы будем синхронизировать секрет не изХранилища, а к немуиз Секрета Kubernetes.
Меньше слов, больше YAML:

apiVersion: mirrors.kts.studio/v1alpha2
kind: SecretMirror
metadata:
  name: myvaultsecret-mirror-reverse
  namespace: default
spec:
  source:
    name: mysecret
  destination:
    type: vault
    vault:
      addr: https://vault.example.com
      path: /secret/data/myvaultsecret
      auth:
        approle:
          secretRef:
            name: vault-approle

В этом случае должно быть понятно, что в качестве источника будет использоваться секрет mysecret, а в качестве назначения — секрет myvaultsecret в хранилище. Для синхронизации Secret с другими кластерами необходимо внутри них создать SecretMirror, как и в предыдущих сценариях, описывающих синхронизацию из Vault в Secret Kubernetes.

Бонус

Давайте рассмотрим несколько бонусных сценариев, в которых SecretMirror может помочь благодаря своему дизайну.

1. Распространение динамических секретов из Vault в Kubernetes Secret

HashiCorp Vault также известен тем, что может генерировать динамические учетные данные для доступа к поддерживаемым базам данных на лету. Например, он может сгенерировать временный пароль для доступа к кластеру PostgreSQL или MongoDB для некоторого сценария резервного копирования или другой процедуры cron. Статические логины/пароли могут утекать самыми разными способами: в логи, в мессенджер или могут храниться в незашифрованном виде на ПК разработчика. С помощью динамических секретов вы можете избежать этой проблемы, создав временный доступ и уничтожив его по истечении времени ожидания.

Ниже приведен пример того, как SecretMirror может помочь в синхронизации динамического пароля для MongoDB:

apiVersion: mirrors.kts.studio/v1alpha2
kind: SecretMirror
metadata:
  name: secretmirror-from-vault-mongo-to-ns
  namespace: default
spec:
  source:
    name: mongo-creds
    type: vault
    vault:
      addr: https://vault.example.com
      path: mongodb/creds/somedb
      auth:
        approle:
          secretRef:
            name: vault-approle
  destination:
    type: namespaces
    namespaces:
      - default

Обратите внимание, что в каждый момент синхронизации зеркала будут продлевать так называемую аренду, а не генерировать каждый раз новый пароль. Вот почему учетные данные останутся неизменными в течение периода max_ttl, установленного в хранилище.

2. Копирование секретов из Vault в Vault

Вы могли догадаться, что есть возможность указать также source.type = vault и destination.type = vault. Это действительно так, и секреты Kubernetes вообще не используются. Одним из возможных приложений является копирование определенного секрета из одного кластера Vault в другой или копирование ключа из одного места в другое в пределах одного кластера Vault.
Пример SecretMirror копирования между кластерами Vault:

apiVersion: mirrors.kts.studio/v1alpha2
kind: SecretMirror
metadata:
  name: secretmirror-from-vault-to-vault
  namespace: default
spec:
  source:
    name: mysecret
    type: vault
    vault:
      addr: https://vault1.example.com
      path: /secret/data/mysecret
      auth:
        approle:
          secretRef:
            name: vault1-approle

  destination:
    type: vault
    vault:
      addr: https://vault2.example.com
      path: /secret/data/mysecret
      auth:
        approle:
          secretRef:
            name: vault2-approle

Полученные результаты

Удалось ли нам решить основные задачи? Без сомнения.

Теперь все команды довольны — сертификаты появляются в фиче-ветках сразу, секреты между кластерами синхронизируются для нескольких DevOps-клиентов, ничего не делая вручную, и еще есть куда улучшать.

Наш контроллер потребляет очень мало ресурсов ЦП, памяти и сети и практически не имеет дополнительной нагрузки на кластер.

Диаграммы для сравнения с kubernetes-reflector:

Это был первый пользовательский контроллер Kubernetes, который мы разработали в команде, когда никакое другое решение не могло удовлетворить наши потребности.

Оказалось, что это довольно несложная процедура, позволяющая создавать множество пользовательских рабочих процессов внутри кластеров Kubernetes, но кроме того, она просто расширяет ваши знания о том, как работает Kubernetes внутри.

Если вы хотите сами попробовать зеркала, вот несколько полезных ссылок:

  1. https://github.com/ktsstudio/mirrors — основной репозиторий GitHub.
  2. Шлем-схема с инструкцией по установке.
  3. Терраформ-модуль, который устанавливает карту выше.

Спасибо за уделенное время и до скорой встречи :)