
Меня зовут Игорь Латкин, я архитектор в компании KTS.
Сегодня хочу поделиться нашей внутренней разработкой — Kubernetes-контроллером mirrors. Мы создали его внутри нашего DevOps-отдела для копирования Kubernetes-секретов между неймспейсами кластера. В итоге mirrors превратился в универсальный инструмент синхронизации данных из разных источников.
В статье расскажу, с чего все начиналось и к чему мы в итоге пришли. Возможно, статья вдохновит вас на написание собственного контроллера под ваши задачи.
Что будет в статье:
С чего все началось
? Перед нами стояла единственная большая задача — обеспечение TLS в динамических окружениях в dev-контуре KTS.
Нашим dev-кластером пользуются все команды разработки, поэтому процесс обеспечения защищенного соединения должен быть полностью автономным. В дальнейшем мы поняли, что можем решать с помощью разработанного решения и другие задачи, о которых подробнее расскажу ниже.
Начнем с динамических окружений.
Динамические окружения с TLS
Задача копировать секреты между неймспейсами в Kubernetes кластере возникла в KTS уже давно. Наши процессы построены так, что каждая команда — даже каждый разработчик в компании — достаточно независим и самостоятелен.
Каждый может сам себе создать репозиторий в Gitlab, подключить общий CI/CD пайплайн, указав желаемые доменные имена в настройках проекта, и буквально за несколько минут получить задеплоенный проект для dev- и production-окружений. И необходимости привлекать для этого 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 в проекте превращается в развернутый пайплайн, покрывающий задачи сборки и раскладки проекта в dev- и prod-окружении:

Для dev-окружения практически все проекты раскладываются на одном и том же поддомене, в дальнейшем будем ссылаться на него как dev.example.com. То есть проекты могут быть разложены на такие поддомены:
-
projectA.dev.example.com
-
projectB.dev.example.com
-
projectC…
Также необходимо иметь в виду, что некоторые приложения состоят из нескольких микросервисов, которые объединены разными ingress-правилами. Например:
-
Фронтенд-приложение обслуживает все пути домена projectA.dev.example.com/
-
API-приложение обслуживает все пути домена projectA.dev.example.com/api
Так как они обслуживают один и тот же домен, для этих ingress желательно корректно прописать один и тот же TLS-сертификат. А они деплоятся в разные неймспейсы для большей изоляции и просто потому, что так по дефолту работает интеграция Gitlab с Kubernetes через сертификаты.
Интеграция Gitlab с Kubernetes через сертификаты, вообще говоря, уже deprecated и надо бы переходить на Gitlab Agent. Но сейчас пока не об этом.
Проблемы большого количества сертификатов
Казалось бы, можно в каждом ingress каждого проекта просто выписывать свой собственный сертификат, и проблема решена. Именно так мы и жили какое-то время. Но в конце концов уперлись в ограничения Let’s Encrypt по количеству выписываемых сертификатов. Это особенно остро ощутилось в период массового переезда с одного кластера на другой, когда все сертификаты надо было перевыпустить.
Второй минус этого решения: нужно ждать, пока сертификат выпишется при создании новой ветки. Этот процесс может еще и зафейлиться. Поэтому кажется естественной идея держать один единственный сертификат и давать всем к нему доступ.
Но тогда всплывает другая проблема.
Сертификат, выписанный на *.dev.example.com, валиден для домена projectA.dev.example.com, но не валиден для feature1.projectA.dev.example.com. Поэтому когда мы захотим построить динамические окружения с поддоменами, то окажемся в заложниках такого решения.
Поэтому сначала мы сформулировали такую задачу:
? Задача 1
Для обеспечения TLS в динамических окружениях проектов необходимо выписывать сертификат для ветки main каждого проекта и копировать Secret во все остальные неймспейсы этого проекта

Для проекта <project_name>-<project_id>-<branch_name>-<some_hash> неймспейсы получат примерно такие названия:
-
project-a-1120-main-23hf
-
project-a-1120-dev-4hg5
-
project-a-1120-feature-1-aafd
-
project-b-1200-main-7ds9
-
project-b-1200-feature1-42qq
То есть физически объект Certificate создается только в неймспейсах 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...
Проблема единственного сертификата
Теперь рассмотрим вторую задачу, которую нам удалось покрыть. Она достаточно близка к первому случаю, но имеет свои особенности.
Представим, что мы говорим о неком prod-окружении, и это окружение расположено в Kubernetes-кластере. Допустим, оно состоит из нескольких кластеров, распределенных по разным географическим зонам. И все эти кластеры обслуживают один и тот же домен, например myshop.example.com. Мы очень хотим, чтобы наш конечный пользователь видел один и тот же сертификат, независимо от того, на какой из кластеров попадет. И тут уже могут быть варианты, откуда он изначально берется:
-
Сертификат может быть куплен.
-
Сертификат может быть выписан также через cert-manager в одном из кластеров. Тогда встает задача по его доставке в соседние кластеры.
Естественно предположить, что логично использовать некое централизованное хранилище и из него переливать сертификат в нужные места использования.
До того, как мы внедрили наше решение, задача решалась в лоб, но в целом жизнеспособно: сертификат накатывался как часть инфраструктуры через helm во все нужные кластеры. Чтобы обновить сертификат, достаточно было обновить его в helm-чарте и заново выкатить. Но хотелось больших автоматизации и безопасности: например, нам не нравилось хранить сертификат в git-репозитории.
Конечно, проблемы возникают не только с сертификатами. Это могут быть любые данные, которые хочется иметь сразу в нескольких местах — credentials для registry образов, логины/пароли от баз данных и многое другое.
Теперь мы сформулировали вторую задачу:
? Задача 2
Уметь автоматически синхронизировать Secret из централизованного хранилища во все нужные Kubernetes-кластеры и выбранные внутри них неймспейсы.
Существующие решения и их недостатки
Поняв, что именно хотим сделать, мы начали искать удовлетворительные решения.
Два проекта, которые мы рассматривали для решения первой задачи:
-
kubed от AppsCode
-
kubernetes-reflector от EmberStack
kubed нам не подошел сразу, т.к. он полагается на лейблы, которые нужно проставить как на сами Secret или ConfigMap, так и на неймспейсы, в которые их нужно скопировать. Ставить лейблы в динамике при создании неймспейса у нас не было возможности. Подробнее про работу с kubed расписано тут.
kubernetes-reflector работает похожим образом, но умеет сам следить за объектами Сertificate и прописывать дополнительные аннотации/лейблы, чтобы активировать синхронизацию сeкрета.
Также в нем была возможность указать регулярное выражение, под которое должен попасть неймспейс, чтобы в него скопирован был скопирован сeкрет. Необходимости навешивать лейблы на сами объекты неймспейса нет. Посмотрим на пример:
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. Благодаря ей мы должны были легко решать первую задачу: деплоили сертификат только для ветки main, во все остальные он автоматически скопирован, а куда именно, идеально описывает маска project-a-1120-*.
Мы настроили все проекты и задеплоили это решение в наш dev-кластер. В целом kubernetes-reflector хорошо справлялся со своей задачей, пока мы не начали получать жалобы от разработчиков по типу: «Сертификат не выписывается, помогите». На деле сертификат-то, конечно, был выписан, но почему-то не копировался в новый фича-бранч.
На графиках мы видели следующую картину (цвета обозначают разные поды):



Из-за высокого потребления памяти и того, что график разноцветный, уже понятно, что OOM просто убивал reflector, после чего он начинал работу заново. Потребление сетевых ресурсов также удивляло.
К сожалению, с ростом числа проектов недостатки внутренней архитектуры reflector давали о себе знать. На тот момент в кластере было около 10к различных Secret. Судя по всему, reflector следил за каждым из них и достаточно неэффективно использовал информацию о неймспейсе. Поэтому мы видели большое использование сетевого трафика на графиках.
В более новых версиях reflector были адресованы проблемы с перфомансом, но это было уже спустя 2 месяца нашего переезда на собственное решение
Еще нам очень хотелось, чтобы Secret в нужном неймспейсе появлялся сразу же после его создания в Kubernetes-кластере, а не спустя какой-то внутренний период синхронизации.
В конечном счете мы решили в кратчайшие сроки разработать собственный инструмент, который действовал бы согласно нашим задачам и учитывал проблемы с производительностью reflector. За 2 недели нам удалось это сделать, выкатить в наш dev-кластер и пересадить все команды на его использование.
SecretMirror for the rescue

Итак, какие требования предъявлялись к новому контроллеру:
-
Должен работать со своим собственным CRD. Он будет назван SecretMirror. Это требование радикально снижает нагрузку на API-сервер и производительность контроллера: сущностей типа SecretMirror в кластере априори в разы меньше, чем самих Secret.
-
В SecretMirror должен задаваться список регулярных выражений неймспейса, в которые указанный Secret должен быть скопирован. Это нам позволит гибко управлять целевым местоположением ресурсов.
-
Контроллер должен следить не только за SecretMirror, но и за объектами неймспейса. Это позволит копировать Secret в новый неймспейс сразу после его появления, а не после периода синхронизации.
-
Должен поддерживать актуальный список неймспейсов в кэше в памяти. Тогда мы экономим на походах за этим списком всякий раз, когда нужно синхронизировать Secret в указанные неймспейсы. Такой кэш как раз легко поддерживать благодаря выполнению пункта 3, и мы можем динамично добавлять в него неймспейс и удалять.
-
При удалении SecretMirror все Secret, созданные контроллером, автоматически должны быть удалены. Однако необходимо оставить возможность отключить такое поведение.
-
Синхронизация Secret не должна происходить моментально при его изменении. Иначе нам придется опять таки следить за всеми секретами в кластере. Достаточно, если синхронизация будет происходить раз в какой-то период: например, 3 минуты. При этом должна быть возможность изменить этот интервал для каждого отдельного SecretMirror.
-
Контроллер должен быть расширяем. Тогда в качестве источника или назначения Secret можно использовать внешние системы, например Vault.
Архитектура контроллера

Все достаточно просто. Внутри mirrors запущены 2 контроллера, которые отслеживают любые изменения двух GVK — mirrors.kts.studio/v1alpha2.SecretMirror и v1.Namespace. Все изменения, касающиеся неймспейса, сохраняются в памяти контроллера, чтобы минимизировать походы в Kubernetes API.
Вспомним вторую задачу: синхронизировать данные между несколькими кластерами. Внутри KTS мы активно пользуемся HashiCorp Vault для хранения секретных данных, по этому же пути решили пойти и здесь.
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-.+
Его задача — копировать Secret с именем mysecret из неймспейса default во все неймспейсы, которые будут начинаться либо с project-a-, либо с project-b-. Применим манифест и выведем список всех SecretMirror:
$ 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
Задеплоим Secret, который ожидает SecretMirror:
apiVersion: v1 kind: Secret metadata: name: mysecret namespace: default type: Opaque stringData: username: hellothere password: generalkenobi
Статус SecretMirror при этом изменится c 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
В describe 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 Vault в 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-.+
Благодаря такой конфигурации контроллер mirrors будет синхронизировать Vault-секрет с именем myvaultsecretв Kubernetes Secret с именем myvaultsecret-sync в неймспейсах, имена которых начинаются с project-c-.
На данный момент наша интеграция с Vault поддерживает 2 вида аутентификации:
-
Token
-
AppRole
Подробнее про то, как настроить аутентификацию, можно прочитать в README проекта.
Описанный выше второй сценарий с легкостью решает задачу синхронизации секретных данных с использованием централизованного хранилища. В частности, в Vault мы можем положить данные tls.crt и tls.key, настроить SecretMirror и получить возможность всегда иметь актуальное состояние сертификата в одном или нескольких кластерах.
Копирование секрета из Kubernetes-кластера в HashiCorp Vault

Возвращаясь к одной из наших исходных задач, можно вспомнить, что условный TLS-сертификат может быть также выписан с помощью cert-manager. Хочется иметь возможность синхронизировать его с остальными кластерами нашего production-контура. Здесь можно воспользоваться той же интеграцией с Vault. Только на этот раз мы будем синхронизировать секрет не из Vault, а в него из Kuberentes Secret.
Меньше слов, больше 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
В данном случае видно, что в качестве source будет использован Secret mysecret, а в качестве назначения — Secret myvaultsecret в Vault.
Для синхронизации Secret в остальные кластеры в них нужно будет создать SecretMirror, как в предыдущем сценарии: для синхронизации из Vault в Secret Kubernetes.
Бонусы
Посмотрим на несколько «бонусных» сценариев, которые можно решить с помощью SecretMirror ввиду заложенной в него архитектуры.
1. Распространение динамических секретов из Vault в Kubernetes Secret
HashiCorp Vault известен также тем, что умеет «на лету» генерировать данные для входа в ту или иную поддерживаемую базу данных. Например, сгенерировать временный пароль для доступа к PostgreSQL или MongoDB для какого-нибудь скрипта, создающего бэкап БД. Статические логины/пароли могут утекать самыми разными способами: в логах, в мессенджере, просто храниться в открытом виде на компьютере разработчика. Динамические секреты позволяют избежать этой проблемы, создавая временные доступы и самостоятельно уничтожая их по истечении таймаута.
Так выглядит пример 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
Обратите внимание, что в каждый момент синхронизации mirrors будет продлевать так называемый lease, а не генерировать каждый раз новый пароль. Поэтому данные для входа будут одинаковые на протяжении всего периода
max_ttl, задаваемого в Vault.
2. Копирование из Vault в Vault
Вы могли догадаться, что есть возможность указать source.type = vault и destination.type = vault. Это действительно так, и в данном случае Kubernetes Secret вообще не используются. Одно из возможных применений — копирование конкретного секрета из одного кластера Vault в другой, или копирование ключа из одного места в другое в рамках одного Vault.
Пример копирования между кластерами 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-клиентов синхронизируются без каких-либо ручных действий, и еще остается поле для улучшений и доработок.
Использование CPU, памяти и сети нашим контроллером находится на очень низком уровне и на кластер не оказывает практически никакой дополнительной нагрузки.
Графики для сравнения с kubernetes-reflector



Это был первый Kubernetes-контроллер, который мы разработали самостоятельно для своих нужд.
Как оказалось, это не так уж сложно и позволяет создавать очень кастомные сценарии использования Kubernetes, а также просто шире открывает глаза на его внутреннее устройство.
Если интересно попробовать mirrors у себя, вот несколько ссылок:
-
https://github.com/ktsstudio/mirrors — GitHub репозиторий контроллера.
-
Helm-чарт с инструкцией по установке.
-
Terraform-модуль для установки чарта выше.
ссылка на оригинал статьи https://habr.com/ru/company/kts/blog/682062/
Добавить комментарий