Зачем мы сделали собственный контроллер для копирования секретов в Kubernetes
Меня зовут Игорь Латкин, я архитектор в компании 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-модуль для установки чарта выше.
Другие статьи про DevOps для начинающих:
Другие статьи про DevOps для продолжающих:
За последние годы де-факто стандартом оркестрации и запуска приложений стал Kubernetes. Поэтому умение управлять Kubernetes-кластерами является особенно важным в работе любого современного DevOps-инженера.
Порог входа может казаться достаточно высоким из-за большого числа компонентов и связей между ними внутри системы. На курсе «Деплой приложений в Kubernetes» мы рассмотрим самые важные концепции, необходимые для управления кластерами любой сложности и научим применять эти знания на практике.
? Почитать про курс подробнее можно здесь: https://vk.cc/cn0ty6