00. О чём

Вашему вниманию предлагается вольное описание работы некоторых компонентов Кубернетес Операторов, с которыми приходится иметь дело как в эксплуатации уже написанных кем-то, так и при разработке собственного Оператора. Чтобы лучше разобраться, как реализован функционал этих компонентов, для наглядности, позволю себе рассмотреть Golang сорс-код Prometheus Оператора для мониторинга и Оператора Hashicorp Vault для управления секретами в Кубернетес, архитектура которых разработана с применением лучших практик использования Kubernetes API и продолжает стабильно обновляться сообществом.

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

Это ещё не туториал, но относительно подробный гайд о том, как компоненты Операторов взаимодействуют с Кубернетес АПИ на уровне кода, чтобы реализовать заложенную в них логику для автоматизации вполне определённых практических задач. И да, всем известно, максимальный уровень автоматизации работы в Кубернетес ограничен лишь желанием самих разработчиков избавиться от рутины в близкой для них предметной области, потому я разделил текст на три части, чтобы вы могли без промедления промотать к наиболее актуальной, в которой узреете нечто достойное вашего внимания:

  1. Кастомные ресурсы - мониторы Прометеус-оператора

  2. Информеры и контроллеры

  3. Сайдкар-Инжекторы с помощью аннотаций и вебхуков

01. Кастомные ресурсы. Мониторы Прометеус-оператора

Прометеус Оператор позволяет управлять мониторингом не только установленных приложений, но и самих компонентов Kubernetes. К примеру, в вашем кластере для maintainance команды установлен Прометеус Оператор, чтобы отслеживать общее состояние кластера. Приложение Прометей будет представлено в виде нестандартного (кастомного) Kubernetes ресурса, подготовленного специально для взаимодействия с Оператором в отдельной группе ресурсов аписервера.

$ k api-resources --api-group=monitoring.coreos.com

NAME                  SHORTNAMES   APIVERSION                       NAMESPACED   KIND
...
podmonitors           pmon         monitoring.coreos.com/v1         true         PodMonitor
probes                prb          monitoring.coreos.com/v1         true         Probe
prometheuses          prom         monitoring.coreos.com/v1         true         Prometheus
...
servicemonitors       smon         monitoring.coreos.com/v1         true         ServiceMonitor
thanosrulers          ruler        monitoring.coreos.com/v1         true         ThanosRuler

Эта апигруппа (принадлежит) обслуживается Прометеус Оператором, и вместе с конфигами Прометея требует дополнительных, разработанных специально для Оператора, об их реализации пойдёт речь.

$ k get prometheuses.monitoring.coreos.com -o yaml

kind: Prometheus
...
podMonitorSelector:
    matchLabels: { team: main }
serviceMonitorNamespaceSelector: {}
serviceMonitorSelector:
    matchLabels: { team: main }
...

Обычная ситуация, в некий случайный момент времени, отдельная команда инженеров желает в этом же кластере разместить и замониторить своё приложение (пусть будет mongodb) для каких-то своих MLops задач. Простой способ "навесить" мониторинг для приложения, это использовать готовые экспортеры с сайта проекта https://prometheus.io/docs/instrumenting/exporters/

Список ресурсов, которые мы для этого должны установить в куб-кластер, можно перечислить в виде цепочки Х-А-В-С

Х- deploy app mongodb + service // default app
A- deploy exporter = /metrics // +1 POD
B- expose exporter // +1 SVC
C- deploy servicemonitor // +1 custom resource

Классическая инсталляция экспортера в виде пода (А) и сервиса (В), это скрипт (или микросервис), умеющий связаться с подом основного приложения (mongodb), получить оттуда нативные метрики, сконвертировать их в Prometheus-like формат и выдать на свой порт (например, /metrics), который "ожидает" Прометей. Создание отдельного сервиса (В) для получения метрик позволяет реализовать, так называемую, "прозрачность", которая ценится тем, что не затрагивает конфигурацию деплоя основного приложения, ни пода, ни его основного сервиса, а, значит, не влияет на стабильность работы и обновления.

Немного уточню про сервисы, они "из коробки" умеют распределять (с равной вероятностью) трафик на "свои" поды. Например, сервис для mongodb-exporter

$ k get service | grep mongodb

NAME										AGE
my-exporter-prometheus-mongodb-exporter		11s

$ k describe service my-exporter-prometheus-mongodb-exporter

Name:              my-exporter
...
Labels:            app.kubernetes.io/instance=my-exporter
                   app.kubernetes.io/managed-by=Helm
                   app.kubernetes.io/name=prometheus-mongodb-exporter
...
Selector:          app.kubernetes.io/instance=my-exporter,
                   app.kubernetes.io/name=prometheus-mongodb-exporter
Type:              ClusterIP
...
Port:              metrics  9216/TCP
TargetPort:        metrics/TCP
Endpoints:         192.168.1.8:9216

Для этого сервисы "хранят" (и обновляют) информацию о Подах в виде Ендпоинтов, что можно заметить в выводе ниже.
Лейбл в сервисы инженеры прописывают статично (один раз), чтобы "под капотом" сервис получал IP-адреса подов динамически на протяжении всего остального времени работы (много раз).

$ k get endpoints my-exporter-prometheus-mongodb-exporter

NAME                    ENDPOINTS           AGE
my-exporter-...         192.168.1.8:9216	11s

$ k get pods --selector name=prometheus-mongodb-exporter -o wide

NAME                    READY	STATUS 		AGE		IP
my-exporter-...         1/1		Running		11s		192.168.1.8

Теперь можно вернуться к цепочке (А,В,С). Чтобы мониторинг нашего приложения заработал, Прометеус Оператору необходим нестандартный (кастомный) ресурс servicemonitor (звено С), который позволяет "узнавать и информировать" о сервисе (В). Как только мы создадим servicemonitor, Оператор сразу узнает о его существовании, через него получит инфо о сервисе экспортера с метриками, из него заберёт селекторы подов, обновит конфиг Прометея. Ну а дальше Прометей с помощью своего сервис-дискавери стянет endpointы и заведёт в качестве Таргетов.

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

Для установки мы решили использовать готовый экспортер с сайта https://prometheus.io/docs/instrumenting/exporters/ Установим предлагаемый коммьюнити Хельм-чарт, в конфиге укажем, что за этот мониторинг отвечает команда {team: mlops}

# set configs for MLops team

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm show values prometheus-community/prometheus-mongodb-exporter > values.yaml


# values.yaml
...
  serviceMonitor:
    additionalLabels:		#<-- discover new ServiceMonitor
	  team: mlops			#<-- match labels key-value
...

# install exporter MLops team

helm install mongodb-exporter prometheus-community/prometheus-mongodb-exporter -f values.yaml

Хельм создал для экспортера отдельный service (звено В) и специальный servicemonitor (звено С)

$ k get service | grep exporter

NAME											AGE
mongodb-exporter-prometheus-mongodb-exporter	10s ...
...

$ k get servicemonitors

NAME											AGE
mongodb-exporter-prometheus-mongodb-exporter	11s
prometheus-kube-prometheus-alertmanager			2...
prometheus-kube-prometheus-apiserver			2...
...
prometheus-kube-prometheus-kube-etcd			2...
prometheus-kube-prometheus-kube-proxy			2...
prometheus-kube-prometheus-kube-scheduler		2...
prometheus-kube-prometheus-operator				2...
prometheus-kube-prometheus-prometheus			2...
...

Чтобы обеспечить мониторинг (цепочка А,В,С), мы через Хельм установили 3 ресурса и указали лейбл команды для servicemonitor (С)

# A. exporter-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  ...
  labels:
    app.kubernetes.io/instance: mongodb-exporter
    app.kubernetes.io/name: prometheus-mongodb-exporter
  name: mongodb-exporter-prometheus-mongodb-exporter-86cfc677c9-ml4d6
  namespace: gpt-24
...
spec:
  containers:
  - name: mongodb-exporter
    args:
    - --web.listen-address=:9216
    ...
    env:
    ...
    ports:
    ...

# B. exporter-service.yaml
apiVersion: v1
kind: Service
metadata:
  ...
  labels:
    app.kubernetes.io/instance: mongodb-exporter
    app.kubernetes.io/name: prometheus-mongodb-exporter
  name: mongodb-exporter-prometheus-mongodb-exporter
  namespace: gpt-24
  ...
spec:
  clusterIP: ...
  ports:
  - ...
  selector:
    app.kubernetes.io/instance: mongodb-exporter
    app.kubernetes.io/name: prometheus-mongodb-exporter
  sessionAffinity: None
  type: ClusterIP

# C. smon.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  ...
  labels:
    team: mlops                     #<--- TEAM LABEL
    app.kubernetes.io/instance: mongodb-exporter
    app.kubernetes.io/name: prometheus-mongodb-exporter
  name: mongodb-exporter-prometheus-mongodb-exporter
  namespace: gpt-24                 #<--- EXPORTER NAMESPACE
  ...
spec:
  ...
  namespaceSelector:
  ...
  selector:                         #<--- DISCOVER SERVICE(B)
    matchLabels:
      app.kubernetes.io/instance: mongodb-exporter
      app.kubernetes.io/name: prometheus-mongodb-exporter

Cправедливо заметить, всё, что должен уметь Прометей, это скрейпить Таргеты и обрабатывать данные метрик. Таргеты Прометей формирует, благодаря своему сервис-дискавери, из конфига, который кто-то тогда должен создавать и постоянно обновлять. "Слушать" обновления ресурсов в Кубернетес и подкладывать актуальные конфиги в Прометей позволяет функционал Оператора, из возникающих событий куб-кластера он ищет инфо про обновление определённых ресурсов (объектов) и дальше кладёт их в конфиг Прометея. Можно сказать, благодаря возможностям Оператора в кубернетес, Прометей обнаруживает свои цели.

Технически, Оператор Прометея это кастомный контроллер (набор контроллеров, информеров и некоторых других компонентов, написанных на Go), который, с одной стороны, "слушает" изменения в куб-кластере, а, с другой стороны, "принимает на вход" конфиги от инженеров, чтобы фильтровать нужные объекты, и устанавливается в кластер стандартной инсталляцией вида deployment + service + конфиги, которую инженер ручками может обновлять, создавая новые конфиги и правила. Благодаря этим конфигам мы в привычном YAML-формате "сообщаем" Оператору, какие из ресурсов ему следует отслеживать и обрабатывать. В основном, они касаются конфигов и секретов, чтобы управлять каждым компонентом системы мониторинга, в которую входят Алертменеджер со всеми правилами, Графана с дэшбордами и компоненты Прометея по работе с базой данных для хранения метрик.

Тем не менее, из всего многообразия за обнаружение ендпоинтов для Таргетов отвечают два ресурса - ServiceMonitor и Podmonitor, это кастомные (нестандартные) ресурсы, специфичные для стека Прометея, которые разработаны специально, чтобы в явном виде хранить набор фильтров, с помощью которых Прометей должен обнаруживать сервисы, поды и их ендпоинты с метриками. ServiceMonitor реализован как отдельный тип, ниже приведу метод создания нового сервисмонитора из сорс-кода Прометей Оператора:

servicemonitor.go
//<--- https://github.com/prometheus-operator/.../v1

package v1
import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	...
)

type ServiceMonitorSpec struct {
	// ...label from the Kubernetes Service
	// will be used as the job label for all metrics.
	JobLabel 			string              `json:"jobLabel,omitempty"`

	// List of endpoints...
	Endpoints		    []Endpoint		    `json:"endpoints"`

	// ...select the Endpoints objects.
	Selector 			metav1.LabelSelector `json:"selector"`
	
	// ...namespaces the Endpoints objects are discovered from.
	NamespaceSelector	NamespaceSelector	`json:"namespaceSelector,omitempty"`
    
    // ...
	...
}


//--- kubectl apply -f servicemonitor.yaml


package v1
import (
	...
	v1 "k8s.io/client-go/applyconfigurations/meta/v1"
)

// ...declarative configuration of the ServiceMonitor type for... apply.
type ServiceMonitorApplyConfiguration struct {
	v1.TypeMetaApplyConfiguration    `json:",inline"`
	*v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"`
	Spec  *ServiceMonitorSpecApplyConfiguration `json:"spec,omitempty"`
}


func ServiceMonitor(name, namespace string) *ServiceMonitorApplyConfiguration {
	b := &ServiceMonitorApplyConfiguration{}
	b.WithName(name)
	b.WithNamespace(namespace)			//<--- мониторы "живут" в Неймспейсах
	b.WithKind("ServiceMonitor")
	b.WithAPIVersion("monitoring.coreos.com/v1")
	return b
}

В целях дополнительной практики, как спроектировать собственный кастомный ресурс для Кубернетес, предлагаю рассмотреть случайный пример для другого Оператора, которому на вход инженеры ручками добавляют лейбл своей команды и соответствующий ей список сервисных аккаунтов (например main: <LIST-1>, mlops: <LIST-2>, frontend: <LIST-... ), а Оператор, в свою очередь, должен "слушать" изменения Namespace ресурсов в кластере, и, если обнаружит известный ему лейбл, должен определить, для какой это команды, какой список сервисных аккаунтов ей соответствует, и пройтись по выбранному списку, чтобы каждому из аккаунтов накинуть некие права для работы в обнаруженном неймспейсе. Для такого Оператора можно создать кастомный ресурс TeamLabelMonitor, в нём команды будут добавлять свои лейблы и сервисные аккаунты, например:

teamlabelmonitor-example.yaml
apiVersion: ...
kind: TeamLabelMonitor
...
spec:
	namespace: ...
	teamReferences:
	- team: mlops
	  accounts: [ "kubeflow", "spark", "sa-gpu", "mlops", ... ]
	- team: frontend
	  accounts: [ "web", "frontend", ...]
	- ...

teamlabelmonitor.go
package v1
import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	...
)

type TeamLabelMonitor struct {
	metav1.TypeMeta				`json:",inline"`
	metav1.ObjectMeta			`json:"metadata,omitempty"`
	Spec TeamLabelMonitorSpec	`json:"spec"`
}
...
type TeamLabelMonitorSpec struct {
	...
	// Team selector... TeamReference objects.
	TeamReferences 		[]TeamReference	`json:"teamReferences"`
	
	// Self object namespace where monitor located.
	Namespace			string			`json:"namespace"`
	...
}

type TeamReference struct {
	// will be used as value of label to match Namespaces.
	Team	 	        string			`json:"team"`
	
	// List of Kubernetes Service Account resources.
	Accounts		    []string		`json:"accounts"`
}

Кастомные ресурсы предоставляют инженерам понятный интерфейс, чтобы передавать сложную конфигурацию в простом виде в Оператор. Дабы защитить кластер от деплоя кастомных ресурсов с неожиданным функционалом и назначением, в Кубернетес существуют custom resource definitions (CRD), то есть спецификации кастомных объектов, через которые разработчик Оператора должен сперва описать json-схему полей для своего определённого кастомного ресурса по заданным правилам. После этого, при каждой попытке клиентов задеплоить очередной кастомный ресурс, у куб-кластера появляется способность сверять, соответствуют ли поля манифеста, который предлагается задеплоить, той схеме, которая ожидается в CRD. Учитывая, что возможность устанавливать кастомные ресурсы доступна (практически) любым пользователям кластера, в качестве дополнительного уровня защиты используется иерархия ролей, при которой установку и изменение схем CRD можно выполнить исключительно с помощью аккаунта, обладающего админскими правами в кластере.

А чтобы снизить оверхэд на систему кластера, разработчики Кубернетес АПИ рекомендуют соблюдать архитектурные принципы, при которых Операторы должны использовать ресурсы кластера относительно скромно, вот некоторые из них

  • группировать отслеживаемые ресурсы (объекты) кластера по какому-то признаку

  • на 1 вид/группу ресурсов (объектов) использовать 1 контроллер

  • события изменения ресурсов (объектов) получать чтением из кэша

В силу своей специфики, Прометеус-Оператор обязан "уметь" отслеживать максимально широкий ассортимент разных видов ресурсов (объектов) в кластере, и для соблюдения обозначенных принципов ему требуются особые методы синхронизации, приведу пример из сорс-кода

operator.go
//<--- https://github.com/prometheus-operator/.../server

...
// waits for the informers' caches to be synced.
func (c *Operator) waitForCacheSync(ctx context.Context) error {
	for _, infs := range []struct {
		name                 string
		informersForResource *informers.ForResource
	}{
		{"PrometheusAgent", c.promInfs},
		{"ServiceMonitor", c.smonInfs}, 	//<--- обновления от сервисмониторов
		{"PodMonitor", c.pmonInfs},			//<--- обновления от подмониторов
		{"Probe", c.probeInfs},	
		{"ScrapeConfig", c.sconInfs},
		{"ConfigMap", c.cmapInfs},
		{"Secret", c.secrInfs},
		{"StatefulSet", c.ssetInfs},
	} {
		// Skipping informers that were not started.
		if infs.informersForResource == nil {
			continue
		}
	...
	}
}

Помимо способности управлять всеми компонентами мониторинга, Оператор ещё и реализует более высокий уровень для сервис-дискавери Прометея. Если стандартные сервисы по "статичным" лейблам оперируют "динамическими" ендпоинтами, то в Prometheus Оператор инженеры передают в мониторе верхнеуровневые лейблы, которыми Оператор обнаруживает множество сервисов, и, тем самым, поддерживает в актуальном виде набор фильтров для сервис-дискавери Прометея, периодически запуская весь набор рутины: обнаруживать мониторы, получать из них селекторы и обновлять конфиги Прометея. Чтобы передать эти верхнеуровневые "статичные" лейблы в Оператор, из которых появятся ("динамические") Таргеты в Прометее, используют кастомные ресурсы (ServiceMonitor, Podmonitor). Так реализуется паттерн (любого) Оператора в Кубернетес.

02. Информеры и контроллеры

В случайно выбранный момент времени конфигурация Прометея в Кубернетес со всеми его компонентами состоит из 2 логических частей

  • статической // множество конфигмапов, мониторов и правил в виде YAML-файлов, которые создаются ручками (редко)

  • динамической // конфиги ресурсов, адреса целей, правила обработки метрик, генерятся "под капотом" в кластере (часто)

Из сорс-кода про синхронизацию кэшей (приведён выше, smonInfs, pmonInfs, informers...) нетрудно догадаться, что мониторы для Прометея, сами по себе, содержат "статичное" знание конфига, обнаружив которое, Прометеус Оператор получает из него конфигурацию и добавляет к уже сохранённой собственной конфигурации в виде нового информера, тем самым расширяя и обновляя свою текущую конфигурацию. Информер представляет собой интерфейс клиентской библиотеки рантайм-окружения кубернетес и позволяет Оператору "слушать" события изменений ресурсов, тем самым, получать "знания" динамического состояния. Prometheus Operator это контроллер, который "заточен" работать с событиями (persisted) объектов Кубернетес рантайма и обеспечивает мониторинг "пост-фактум" событий (изменений, которые в кластере уже произошли).

Мониторы (статичные конфиги) являются важной абстракцией Прометеус Оператора, чтобы формировать Таргеты в Прометее (динамические конфиги из рантайма).

Он отслеживает более 10 разных видов ресурсов (объектов), но только два из них относятся к мониторам и позволяют обнаруживать ендпоинты целей.

Обнаружив новый монитор, Оператор должен произвести несколько довольно определённых действий, чтобы реализовать логику обновления своих конфигов и продолжить передавать информацию об изменениях ресурсов (объектов) кластера в Прометей уже с учётом этой новой конфигурации. В попытке изобразить последовательность действий я набрал 5 пунктов:

Сначала задаём конфиги (ручками в YAML)
1. в Prometheus прописываем селекторы
# инфо из каких мониторов будут ему доступны
2. в Monitors прописываем селекторы
# как обнаруживать сервисы (или поды)
3. Cервисы (или поды) должны содержать конечные данные
# ендпоинты с метриками (это тоже сущности рантайма)
Дальше действия продолжатся Оператором самостоятельно
4. на servicemonitor регистрируется информер
# копируются фильтры из servicemonitor, начинает "слушать" эти объекты
5. на informer навешивается хэндлер
# какую функцию вызвать, чтобы обновить конфиги сервис-дискавери Прометея

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

operator.go
//<--- https://github.com/prometheus-operator/.../server


// ...creates a new controller.
func New(ctx context.Context, c operator.Config,..) (*Operator, error) {
	...
	o := &Operator{
		config: prompkg.Config{
			...
			Annotations:      c.Annotations,	//<--- селектор Annotations
			Labels:           c.Labels,			//<--- селектор Labels
		},
		...
	}
	...
	o.smonInfs, err = informers.NewInformersForResource(  //<--- сервис-мониторы 
		informers.NewMonitoringInformerFactories(
			c.Namespaces.AllowList,			//<--- ограничение доступных НС
			c.Namespaces.DenyList,			//<--- или ограничение недоступных
			mclient,
			resyncPeriod,					//<--- ограничение частоты обновлений
			...
		),
		monitoringv1.SchemeGroupVersion.WithResource(monitoringv1.ServiceMonitorName),
	)
	if err != nil {
		return nil, fmt.Errorf("error creating servicemonitor informers: %w", err)
	}
	...
}


// addHandlers adds the eventhandlers to the informers.
func (c *Operator) addHandlers() {
	...
	c.smonInfs.AddEventHandler(operator.NewEventHandler(
		c.logger,
		...
		monitoringv1.ServiceMonitorsKind,	//<--- хэндлер для сервисмониторов
		c.enqueueForMonitorNamespace,		//<--- группировка по неймспейсу
	))

	c.pmonInfs.AddEventHandler(operator.NewEventHandler(
		c.logger,
		...
		monitoringv1.PodMonitorsKind,		//<--- хэндлер для подмониторов
		c.enqueueForMonitorNamespace,		//<--- группировка по неймспейсу
	))
	...
}


// Run the controller.
func (c *Operator) Run(ctx context.Context) error {
	go c.rr.Run(ctx)					//<--- главный цикл Reconciler
	defer c.rr.Stop()
	...
	go c.smonInfs.Start(ctx.Done())		//<--- старт для сервисмониторов
	go c.pmonInfs.Start(ctx.Done())		//<--- старт информеров подмониторов
	...
	if err := c.waitForCacheSync(ctx); err != nil {		//<--- синхронизация
		return err
	}
	c.addHandlers()						//<--- добавление хэндлеров
	...
	<-ctx.Done()
	return nil
}

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

Пример проблемы мисконфигурации Прометеус Оператора, это когда servicemonitor и нужные ему сервисы находятся в разных неймспейсах. Или более экзотический, когда ограничения сервисного аккаунта, под которым работает Прометей, не позволят "дотянуться" до неймспейса, где задеплоен servicemonitor, и тогда Оператор не станет отслеживать этот servicemonitor, не зарегистрирует под него информер и не получит конфиги, Таргет не обновится в Прометее, в Графану перестанут поступать метрики, и тому подобное.

Такие проблемы появляются при очередном изменении конфигов, которые в Прометеус Операторе предлагается заводить ручками (более-менее, статично), когда идея создания нового конфига не учитывает созданных ранее, особенно с ростом количества команд, неймспейсов и кластеров мониторинг различных видов приложений начнёт обрастать спецификой и требовать всё больше времени для поиска мисконфигурации, например:

  1. неожиданные требования маркировки
    Прометей должен уметь слушать мониторы не только с matchLabels: { team=aaa }, но и с лейблами { team=bbb }, а позже и с лейблами { team=ссс }, к этому моменту team=bbb надо поменять на team=ttt...

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

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

  4. неожиданный способ деплоя
    в неймспейсах с шаренными ресурсами нужно мониторить приложения, которые запускаются и умирают в разный период времени, под управлением разных Операторов разными командами

Чтобы избежать мисконфигурации, между моментом создания основного приложения (которое мы хотим замониторить) и началом "автоматического" скрейпинга метрик Прометеем нужно, как-то, избежать ручной работы по созданию мониторов и соответствующих ролей, на примере той цепочки ресурсов для мониторинга случайного приложения (А,В,С)

Х- deploy app mongodb + service //
A- deploy exporter = /metrics //
B- expose exporter //
C- deploy servicemonitor // +1 custom resource <-- auto?

Логика создания новых мониторов и обновления доступов для ролей в кластере лежит за пределами возможностей Прометеус Оператора и довольно тривиально перекладывается в инструменты CICD, что, в принципе, небезуспешно и способно экономить время. Тем не менее, запуск Helm-чарта ручками или с воркера CICD не позволит избавиться от ручного контроля за пунктом (С).

Если есть Оператор, который слушает сервисмониторы в специальных неймспейсах, то почему другой Оператор не может слушать неймспейсы и автоматически создавать в них сервисмониторы? Иными словами, нужно разработать отдельный Кубернетес Оператор, который будет устанавливать мониторы в Неймспейс с нужными лейблами:

  • команде (team:mlops) нужно включить мониторинг для приложений в Неймспейсе

  • для этого в Неймспейс навешивается лейбл команды (team:mlops)

  • в этом Неймспейс создаётся servicemonitor с лейблом (team:mlops)

  • все роли для сервис-аккаунтов команды (team:mlops) также обновляются, чтобы получить доступ в этот Неймспейс

Попробуем рассмотреть эту задачу подробнее и спроектировать такой Kubernetes Operator, который сможет автоматизировать эту логику. Например, при создании неймспейса для (team:mlops)

namespace-mlops.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: gpt-24
  labels:
    team: mlops
    app.kubernetes.io/name: gpt-24

наш Оператор должен сделать 2 вещи самостоятельно (то есть автоматически)
1) создать кастомный ресурс в виде сервисмонитора с лейблом (team:mlops)

servicemonitor-mlops.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  ...
  labels:
    team: mlops                     #<--- TEAM LABEL
  name: mlops-gpt-24
  namespace: gpt-24                 #<--- NAMESPACE TO INSTALL
  ...
spec:
  ...
  namespaceSelector:                #<--- NAMESPACE TO LISTEN
    matchLabels:
      app.kubernetes.io/name: gpt-24
  selector:                         #<--- MATCH ALL SERVICES
  ...

2) обновить доступы к этому неймспейсу для всех указанных сервисных аккаунтов команды (team:mlops). Например, биндингом определённых кластерных ролей.

cluster-role-binding.yaml
---
# shared-cluster-role
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: watcher
rules:
- apiGroups: [""]
  resources: ["pods", "jobs", "namespaces"]
  verbs: ["get", "watch", "list"]
- apiGroups: ["monitoring.coreos.com"]
  resources: ["smon", "pmon"]
  verbs: ["get", "watch", "list"]
- ...

---
# service-accounts-binding
apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
      name: gpt-24-rolebinding
      namespace: gpt-24
    roleRef:
      apiGroup: rbac.authorization.k8s.io
      kind: ClusterRole
      name: watcher
    subjects:
    - kind: ServiceAccount
      name: mlops
      namespace: mlops
    - kind: ServiceAccount
      name: mlops-prom
      namespace: mlops-metrics
	... #<--- add mlops accounts

В самом начале мы уже настроили в кластере Прометей, чтобы обнаруживать мониторы в любых неймспейсах с лейблом { team: main }.

$ k get prometheuses.monitoring.coreos.com -o yaml

kind: Prometheus
...
podMonitorSelector:
    matchLabels: { team: main }
serviceMonitorNamespaceSelector: {}
serviceMonitorSelector:
    matchLabels: { team: main }
...

Поэтому, чтобы наша автоматизация сработала, нужно обновить настройки Прометея. В правилах хорошего тона, будет оставить системный мониторинг в покое и создать отдельную инсталляцию Прометея с настройкой serviceMonitorNamespaceSelector, в которую добавить лейблы команд пользователей куб-кластера. Стоит отметить, что каждый Прометей взаимодействует с Kubernetes аписервером, используя собственный сервисный аккаунт, и для "особенных" команд потребуется собственная инсталляция Прометея, "заточенная" под лейблы этой команды. Чтобы следить за Неймспейс ресурсами, подсмотрим сорс-код Прометеус Оператора:

listwatch.go
//<--- https://github.com/prometheus-operator/.../listwatch

package listwatch
import (
	...
	"k8s.io/apimachinery/pkg/watch"
	"k8s.io/client-go/tools/cache"
	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
	
	"github.com/prometheus-operator/prometheus-operator/pkg/k8sutil"
)


func NewNamespaceListWatchFromClient(
	ctx context.Context, corev1Client corev1.CoreV1Interface, ...map[string]struct{}
) (cache.ListerWatcher, bool, error) {
	...
	listWatchAllowed, reasons, err := k8sutil.IsAllowed(
		ctx,
		ssarClient,
		nil, // namespaces is a cluster-scoped resource.
		k8sutil.ResourceAttribute{
			Resource: "namespaces",
			Verbs:    []string{"list", "watch"},
		},
	)
	...
		return cache.NewFilteredListWatchFromClient(
			corev1Client.RESTClient(), "namespaces", metav1.NamespaceAll,..
		), true, nil
	...
}

При этих настройках наш кастомный Оператор будет устанавливать мониторы с нужными лейблами для всех команд, а разные Прометеусы смогут обнаруживать свои мониторы и работать, не мешая друг другу. Тогда наш Оператор должен обладать широкими правами доступа, чтобы по всему кластеру изменять RBAC-ресурсы и создавать, обновлять кастомные ресурсы (ServiceMonitor, TeamLabelMonitor), а, значит, он будет представлять из себя контроллер, "заточенный" на работу с постфактум-событиями рантайма в кластере, и, значит, должен уметь "слушать" рантайм с помощью нескольких информеров:

  • Namespace, ServiceAccount
    // например, как в Golang пакете listwatch (код обозначен выше)

  • ServiceMonitor
    // для этого импортировать библиотеку Прометеус-Оператора с типами мониторов

  • TeamLabelMonitor
    // для собственного кастомного ресурса, где хранить метчинг лейблов и ролей (см.выше)

Как водится, из примеров сорс-кода Прометея выше, на информеры нужно прицепить хэндлеры (функции), дабы реализовать остальную магию Оператора:

  • сметчить лейбл из обнаруженного Namespace с конфигом из TeamLabelMonitor

  • сформировать патч для добавления прав на Namespace в выбранный ServiceAccount

  • отправить патч в аписервер на деплой
    // об этом в третьей части (см. ниже)

  • сформировать манифест и создать ServiceMonitor
    // было в первой части про кастомные ресурсы # client-go/applyconfigurations

После разработки функций хэндлеров останется установить в кластер TeamLabelMonitor CRD, создать под него кастомный ресурс и расшарить между командами, которые внесут туда свои лейблы и списки нужных им ролей для работы в кластере. Дальше наш Оператор начнёт делать всё сам: автоматически реагировать на появление нужных лейблов в неймспейсах, деплоить сервисмониторы и обновлять доступы для ролей. Теперь обнаружение новых приложений в мониторинге будет зависеть только от того, указан ли в Неймспейсе лейбл {team:xxx}. Чтобы инженеры не забывали указывать эти лейблы при создании Namespace ресурсов, Кубернетес, очень кстати, предоставляет удобный способ валидации, который происходит на шаг раньше, до наступления (persisted) события в базе кластера. Этим этапом ведают адмишен-контроллеры Admission Controllers.

03. Сайдкар-Инжекторы с помощью аннотаций и вебхуков

Применение любого манифеста в кластере проходит цепочку Admission проверок. В случае, если этап проходит успешно, аписервер применяет объект (persisted), кластер изменяет свой статус и публикует об этом событие в рантайм, которое, дальше, с удовольствием подхватывают информеры и контроллеры постфактум-рантайма. А пока запрос не дошёл до публикации (persisted), кластер всё ещё находится в прежнем состоянии, и запрос должен пройти через адмишен-контроллеры, каждый из которых валидирует или изменяет его по заданным правилам и передаёт дальше по Admission цепочке.

Возвращаясь к публикации Namespace ресурсов, Кубернетес 1.30 из коробки позволяет автоматизировать проверку наличия лейблов {team:xxx} на адмишен-этапе, просто описав Admission Validation Policy в виде стандартного YAML. Подробности настройки адмишен-полиси я рассматривал в другом посте https://habr.com/ru/articles/811075/

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

admission-validating-policy-namespaces.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
...
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["namespaces"]
  variables:
  - name: labels
    expression: "object.metadata.labels"
  validations:
    - expression: "!('team' in labels)"
      messageExpression: "'Missing Label: team'"

---
# TODO:
# kind: ValidatingAdmissionPolicyBinding

После установки полиси в кластер наш Оператор, наконец-то, выдохнет и начнёт успешно создавать сервисмониторы, потому что проверка Неймспейс ресурсов на обозначенные лейблы теперь вынесена на адмишен этап, о котором Оператору ничего знать не придётся; но в определённый момент и это покажется недостаточным, ибо в нашей цепочке мониторинга приложений осталось звено exporter (А,В), которое всё ещё создаётся ручками:

Х- deploy app mongodb + service //
A- deploy exporter = /metrics // +1 POD
B- expose exporter // +1 SVC
C- deploy servicemonitor //

В самом начале мы установили экспортер, который "встроен" в Хельм-чарт деплоя приложения, согласно приличным DevOps практикам, это помогает избежать мисконфигов и гарантировать, что запрос на создание экспортера (А,В) произойдёт одновременно с запросом на деплой приложения. Но время открытий в области работы с данными, ML/AI даже для, казалось бы, стандартных приложений требует нестандартных инсталляций, а то и вовсе, "самописных" Операторов. А к мониторингу нестандартных приложений могут обнаружиться нестандартные требования, например:

  1. инженеры, использующие приложение, не являются мейнтейнерами Хельм-чарта
    // вносить изменения в Хельм-чарт деплоя приложения нежелательно

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

  3. для разных приложений требуются разные виды экспортеров
    // нужно явным образом указать подходящий имидж экспортера для приложения

  4. одни экспортеры требуют значительных ресурсов, остальные минималистичны
    // некоторые виды экспортеров устанавливать отдельным подом неэффективно

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

Чтобы передавать нестандартные команды в YAML-спецификациях, Куберенетес АПИ предлагает особое поле с Аннотациями. Аннотации позволяют разработчикам в удобном виде в YAML-манифестах описывать вызов различных (любых) функций, чтобы подходящий контроллер мог их "распознать" и выполнить заданную логику. Например, Ingress Controller, который был заранее установлен в кластер в специальный неймспейс, умеет "видеть" Ingress ресурс, который установлен в рабочем неймспейсе для некоего бэкенд-приложения.

ingress.yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
	name: my-test-ingress
spec:
    ingressClassName: nginx
    rules:
    - http:
        paths:
        - path: /records
          pathType: Prefix
          backend:
            service:
              name: records-back

На основе настроек этого Ingress ресурса контроллер направит нас на указанный сервис

$ k get svc -n ingress-nginx

NAME                                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                   
ingress-nginx-controller             NodePort    10.10.88.88    <none>        80:30080/TCP,..
ingress-nginx-controller-admiss...

$ curl -L http://10.10.88.88:80/records

<!doctype html>
<title>Hello from Records Backend</title>
<body style="...">

C помощью Аннотации мы можем "сообщить", что мы хотим перенаправлять весь трафик на субдомен

$ REDIRECT="nginx.ingress.kubernetes.io/temporal-redirect"
$ k annotate ingress my-test-ingress $REDIRECT='https://apps.company.ru'

ingress-with-annotaion.yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
	name: my-test-ingress
	annotations:
		nginx.ingress.kubernetes.io/temporal-redirect: https://apps.company.ru
spec:
    ingressClassName: nginx
    rules:
    - http:
        paths:
        - path: /records
	...

Ingress Controller распознает эту аннотацию и "сам" проделает необходимые действия, в поде ingress-nginx-controller в специальном неймспейсе откроет настройки /etc/.../nginx.conf и пропишет правила редиректа на новый адрес, и тот самый вызов вернёт уже другой ответ

$ curl -L http://10.10.88.88:80/records

<!doctype html>
<head><meta content="...Company has many features what you're looking for."
<title>Apps Company Ru</title>
<body style="...">

Эти действия произошли "под капотом" и были от нас скрыты под нужную Аннотацию в YAML-файле. Для гибкой настройки разных правил контроллер Ingress Nginx поддерживает большой набор Аннотаций https://github.com/kubernetes/ingress-nginx/blob/main/docs/user-guide/nginx-configuration/annotations.md

Hashicorp Vault-K8S, это другой Кубернетес Оператор, который на адмишен-этапе успевает распознать Аннотации в Подах и поменять их спецификацию, чтобы "загрузить" (инжектить) указанные секреты, а, может, и дополнительный контейнер добавить (агент), который будет следить за актуальностью этих самых секретов. Vault использует богатый набор Аннотаций (больше 50) и с их помощью управляет нужными параметрами agent-cache, ca, cert, cpu, mem, auth, и т.д. В сорс-коде они хранятся в виде констант для удобства парсинга:

annotations.go
//<--- https://github.com/hashicorp/vault-k8s/.../agent

package agent
import ...

// Agent is the top level structure holding all the
// configurations for the Vault Agent container.
type Agent struct {
	Annotations map[string]string
	Inject bool						// flag
	Namespace string				// request from
	Pod *corev1.Pod					// origin pod spec
	Secrets []*Secret				// path in Vault & filename
	Vault Vault						// vault config
	...
}

const (
	// ...is added to a pod after an injection is done.
	// There's only one valid status we care about: "injected".
	AnnotationAgentStatus = "vault.hashicorp.com/agent-inject-status"

	// ...should be true or false value, as parseable by parseutil.ParseBool
	AnnotationAgentInject = "vault.hashicorp.com/agent-inject"

	// ...any unique string after "vault.hashicorp.com/agent-inject-secret-",
	// path in Vault where the secret is located.
	AnnotationAgentInjectSecret = "vault.hashicorp.com/agent-inject-secret"

	// ...configures what template to use for rendering the secrets.
	// should map to ...value provided in "vault.hashicorp.com/agent-inject-secret-".
	AnnotationAgentInjectTemplate = "vault.hashicorp.com/agent-inject-template"

	// ...configures what template on disk to use for rendering the secrets.
	// should map to ...value provided in "vault.hashicorp.com/agent-inject-secret-".
	AnnotationAgentInjectTemplateFile = "vault.hashicorp.com/agent-inject-template-file"

	// ...configures ...run a command after the secret is rendered.
	// should map to ...value provided in "vault.hashicorp.com/agent-inject-secret-".
	AnnotationAgentInjectCommand = "vault.hashicorp.com/agent-inject-command"
	...
)

На примере рандомного some-application Vault Operator распарсит аннотации для pod, разделяя их на 3 части

  • vault.hashicorp.com # название зарегистрированного веб-хука (об этом ниже)

  • /agent-inject # контроллер понимает, какую функцию запустить

  • "true" # значение, с которым контроллер её запустит

someapp-deployment.yaml
...
spec:
  template:
    metadata:
      annotaions:
        vault.hashicorp.com/role: "someapp"
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/agent-inject-secret-frontend-conf.txt: "someapp/data/frontend/conf"
...

В несколько строк Аннотаций Vault Operator "распознал", какие данные требуется стянуть в кластер из внешнего Vault и куда сохранить, чтобы смапить в нужный контейнер. Через секунду указанные секреты из Vault станут доступны приложению some-application в файлике с заданным названием, что, как бы, намекает на безграничные возможности Аннотаций "задекорировать" любой (нестандартный) функционал в Кубернетес

$ kubectl exec some-application -- cat /vault/secrets/frontend-conf.txt

  data: map[pass:my-password user:my-user]
  metadata: ...

Возвращаясь к автоматизации нашей цепочки, деплой экспортера метрик (А,В) тесно связан с деплоем основного приложения, и добавление Аннотаций в спеки подов позволит весьма элегантно передать нашему "самописному" контроллеру инструкции, какой именно экспортер мы "просим" его задеплоить.

Используя практики Vault Operator, попробуем составить Аннотации для кастомного адмишен-контроллера и удовлетворить требования команд, где для инструкции {inject:"true"} наш контроллер должен (заинжектить) создать экспортер в виде сайдкар-контейнера

  1. вносить изменения в Хельм-чарт деплоя приложения нежелательно

  2. нужно явным образом указать порт, откуда экспортер должен читать данные

  3. нужно указать какой именно имидж экспортера для приложения следует использовать

  4. некоторые виды экспортеров устаналивать отдельным подом неэффективно

pod-with-annotations.yaml
...
    metadata:
      annotaions:
        some-exporter.io/inject: "true"
		some-exporter.io/inject-image: <EXPORTER-IMAGE>
        some-exporter.io/metrics-port: "28888"
        some-exporter.io/metrics-path: "/custom-metrics"
        some-exporter.io/team-123: "mlops"
...

sidecar-with-annotations.go
//<--- https://github.com/hashicorp/vault-k8s/.../agent

package agent
import ...

type Agent struct {
    Annotations map[string]string
	ImageName	string
    Inject      string
    Metrics     []string
    Pod         *corev1.Pod
    ...
    Team        []string
}


const (
    AnnotationInject      	= "some-exporter.io/inject"
	AnnotationInjectStatus	= "some-exporter.io/inject-status"
    AnnotationInjectImage	= "some-exporter.io/inject-image"
    AnnotationMetricsPath	= "some-exporter.io/metrics-path"
    AnnotationMetricsPort	= "some-exporter.io/metrics-port"
    AnnotationTeam			= "some-exporter.io/team"
)


func New(pod *corev1.Pod) (*Agent, error) {
	sa, err := serviceaccount(pod)
	...
	agent := &{
		Annotations:		 pod.Annotations,
		ImageName:			 pod.Annotations[AnnotationInjectImage],
		...
		Pod:				 pod,
		Containers:			 []string{},
		...
		ServiceAccountToken: sa,
		Status:				 pod.Annotations[AnnotationInjectStatus],
	}
	...
    agent.Inject  = agent.inject()
    agent.Metrics = agent.metrics()
    agent.Team    = agent.team()
	...
	return agent, nil
}


func (a *Agent) inject() (string, error) {
    raw, ok := a.Annotations[AnnotationInject]
    if !ok {
        return "false", nil
    }
    if raw == "true" || raw == "1" {
        return "true", nil
    }
    if raw == "false" || raw == "0" {
        return "false", nil
    }
    return "", errorBoolString()
}


func (a *Agent) metrics() ([]string, error) {
    path, ok := a.Annotations[AnnotationMetricsPath]
    ...
    port, ok := a.Annotations[AnnotationMetricsPort]
    ...
    return []string{port, path}, nil    //<--- ["28888", "/custom-metrics"]
}


func (a *Agent) team() ([]string, error) {
    var Team []string
    for annotation, value := range a.Annotations {
        team, ok := strings.CutPrefix( annotation, AnnotationTeam+"-" )
        if !ok {
            continue
        }
        Team = []string{team, value}    //<--- ["123", "mlops"]
        break
    }
    return Team, nil
}

Помимо обязательных Аннотаций полезно использовать техническую с проставлением статуса инжекта, как сделано в Vault (сорс-код был выше). Это понадобится, чтобы наш контроллер мог реализовать оба сценария:

  • some-exporter.io/inject-status: "injected"
    // когда наш самописный контроллер на адмишен-этапе будет инжектить сайдкар-экспортер

  • some-exporter.io/inject-status: "install"
    // так наш контроллер передаст в рантайм, что экспортер нужно отдельным подом постфактум

Теперь, когда контроллер распознал инструкции из Аннотаций, нужно, чтобы он изменил спецификацию пода, добавил в него sidecar-контейнер экспортера с указанным имиджем и настройками. В коде для этого берутся вэльюсы Аннотаций, заполняется структура апи-кубернетес ( corev1.Container ) и применяется стандартным патчем. Как водится, имидж указанного exporter-контейнера должен быть заранее доступен где-то в реджистри. К примеру, вот так инжектит сайдкар-контейнер Vault Operator

sidecar-container
//<--- https://github.com/hashicorp/vault-k8s/.../agent
package agent
import (
	...
	jsonpatch "github.com/evanphx/json-patch"
	corev1 "k8s.io/api/core/v1"
)


// ContainerSidecar creates a new container to be added to the pod...
func (a *Agent) ContainerSidecar() (corev1.Container, error) {
	...
	lifecycle := a.createLifecycle()	//<--- helper func

	newContainer := corev1.Container{
		Name:         "vault-agent",
		Image:        a.ImageName,		//<--- sidecar
		Env:          envs,
		Resources:    resources,
		VolumeMounts: volumeMounts,
		Lifecycle:    &lifecycle,
		Command:      []string{"/bin/sh", "-ec"},
		Args:         []string{arg},
	}
	...
	containerJson, err := json.Marshal(newContainer)
	...
	patch, err := jsonpatch.DecodePatch([]byte(a.JsonPatch))
	if err != nil { ..."failed to decode JSON patch: %w", err
	}
	newContainerJson, err := patch.Apply(containerJson)
	if err != nil { ..."failed to apply JSON patch: %w", err
	}
	newContainer = corev1.Container{}
	err = json.Unmarshal(newContainerJson, &newContainer)
	if err != nil {
		return newContainer, err
	}
	return newContainer, nil
}

Переходим к особенностям деплоя. Чтобы наш кастомный контроллер был способен выполнять возложенную на него рутину обнаруживать и парсить нужные Аннотации и инжектить экспортеры сайдкар-контейнером, он должен быть "заточен" для работы с событиями адмишен-контроля. Kubernetes API для таких целей предоставляет специальный адрес /mutate, запросы которого наш контроллер должен уметь обрабатывать. А чтобы обслуживать входящие адмишен-реквесты, наш контроллер должен работать в виде веб-сервера. Например, Vault Injector реализует в коде такое приложение (WebHook Server), чтобы поддерживать инжектор-вебхук

vault-injector
//<--- https://github.com/hashicorp/vault-k8s/.../injector

package injector
import ...

type Command struct {
	...
	flagListen      	string // Address of Vault Server
	flagVaultAuthType	string // Type of Vault Auth Method to use
	flagVaultAuthPath	string // Mount path of the Vault Auth Method
	flagVaultNamespace	string // Vault enterprise namespace
	...
	cert atomic.Value	//<--- rewrite support 
}


func (c *Command) Run(args []string) int {
	ctx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()
	...
	// Build the HTTP handler and server
	injector := agentInject.Handler{
		VaultAddress:               c.flagVaultService,
		VaultAuthType:              c.flagVaultAuthType,
		VaultAuthPath:              c.flagVaultAuthPath,
		VaultNamespace:             c.flagVaultNamespace,	
		...
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/mutate", injector.Handle)
	mux.HandleFunc("/health/ready", c.handleReady)
	...
	mux.Handle(c.flagTelemetry.., promhttp.Handler())  // expose metrics
	...
	var handler http.Handler = mux
	server := &http.Server{
		Addr:      c.flagListen,
		Handler:   handler,
		TLSConfig: tlsConfig,
		...
	}
	injector.Log.Info("Starting handler..")
	...
	return 0
}

Теперь нужно имплементировать вебхук. По примеру, как сделано в пакете Vault Agent Inject, инжектор должен из адмишен-запроса получать Под с Аннотациями, передавать агенту, чтобы формировать спецификацию нового контейнера, патчем добавлять к схеме Пода и возвращать в виде admission responce дальше в цепочку адмишен-контроля, "предлагая" изменить спецификацию Пода и заинжектить сайдкар-контейнер с нужным имиджем экспортера.
Таким образом, инсталляция нашего адмишен-контроллера для экспортеров будет представлять собой приложение с логикой и веб-сервером (WebHook Server), и, с другой стороны, конфигом вебхука с ролью, которая имеет права работать с апигруппой admissionregistration в Kubernetes API. Важно, чтобы название, под которым наш контроллер будет указан (зарегистрирован) в конфиге вебхуков, совпало с обозначениями в Аннотациях, которые он умеет распознавать. Об этом позже в деплой манифестах.

mutate-webhook
//<--- https://github.com/hashicorp/vault-k8s/.../agent-inject

package agent_inject
import (
	...
	"github.com/hashicorp/vault-k8s/agent-inject/agent"
	admissionv1 "k8s.io/api/admission/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/client-go/kubernetes"
)

// Handler is the HTTP handler for admission webhooks.
type Handler struct {
	RequireAnnotation	bool
	VaultAddress        string
	VaultAuthPath       string
	...
	Clientset           *kubernetes.Clientset
	...
}


// http.HandlerFunc implementation webhook request for admission control.
// ...should be registered or served via an HTTP server.
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
	h.Log.Info("Request received", "Method", r.Method, "URL", r.URL)
	...
	var admResp admissionv1.AdmissionReview
	...
	admResp.Response = h.Mutate(admReq.Request)
	...
	resp, err := json.Marshal(&admResp)
	...
	if _, err := w.Write(resp); err != nil {
		h.Log.Error("error writing response", "Error", err)
	}
}


// Mutate takes an admission request and performs mutation if necessary,
// returning the final API response.
func (h *Handler) Mutate(
	req *admissionv1.AdmissionRequest
) *admissionv1.AdmissionResponse {
	// Decode the pod from the request
	var pod corev1.Pod
	if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
		h.Log.Error("could not unmarshal request to pod: %s", err)
		h.Log.Debug("%s", req.Object.Raw)
		return &admissionv1.AdmissionResponse{
			UID: req.UID,
			Result: &metav1.Status{
				Message: err.Error(),
			},
		}
	}
	...
	//<--- build the basic response
	resp := &admissionv1.AdmissionResponse{
		Allowed: true,
		UID:     req.UID,
	}
	
	//<--- checking if should inject agent
	inject, err := agent.ShouldInject(&pod)
	... agent.Init(&pod, cfg)
	agentSidecar, err := agent.New(&pod)
	
	//<--- validating agent configuration
	... agentSidecar.Validate()
	...

	//<--- creating patches for the pod
	patch, err := agentSidecar.Patch()
	resp.Patch = patch
	patchType := admissionv1.PatchTypeJSONPatch
	resp.PatchType = &patchType

	return resp
}

Этап проектирования вебхука завершён, основные части нашего контроллера для сайдкар-экспортеров готовы (если не хватает, напишите об этом в комментариях). Осталось "пояснить за неймспейс", в который нужно устанавливать наш кастомный контроллер, согласно хорошим DevOps практикам. Если посмотреть адмишен-этап подробнее, когда запрос на создание любого случайного пода попадает в цепочку адмишен-проверок, то в этой цепочке будут участвовать все приложения, которые аписервер "считает" веб-хуками, поэтому, чем больше вебхуков установлено в кластере, тем больше времени будет занимать публикация каждого пода. И если представить, что некий вебхук вдруг начнёт отвечать медленно (в силу разных причин), то время отклика команды kubectl apply -f ... начнёт заметно деградировать.

Чтобы с этим справиться, хорошие практики рекомендуют вынести деплоймент нашего контроллера в отдельный неймспейс с выделенными лимитами ресурсов кластера, увеличить число вебхук-серверов (реплик подов) и экспозить через сервис, чтобы получить "бесплатный" балансировщик запросов, и при регистрации вебхука ручку /mutate связать с этим сервисом. Теперь уже сервис сможет (более умно) распределять нагрузку между подами (WebHook Server) и не задерживать адмишен-проверку. Для потенциального траблшутинга наш контроллер, само собой, не должен обойтись без мониторинга, но, для полной уверенности, будет полезным завести индекс для логов кубернетес-апи, которые касаются вызовов webhook. Настройку мониторинга оставлю за пределами темы, поэтому сразу к схеме деплоя, приведу здесь в общем виде, чтобы была понятна их взаимосвязь.

Примеры деплой манифестов Кубернетес контроллера для автоматического создания экспортеров метрик.

deploy-exporter-injector.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: exporter-injector
...
spec:
  replicas: 2
  selector:
	...
  template:
	...
    spec:
      serviceAccountName: "exporter-injector"
      containers:
        - name: sidecar
          image: <EXPORTER-IMAGE>
          env:
		  ...

---
apiVersion: v1
kind: Service
metadata:
  name: exporter-agent-injector-svc
  namespace: some-exporter
...
spec:
  ports: ...
  selector: ...

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: exporter-injector
...

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: exporter-injector-clusterrole
...
rules:
- apiGroups: ["admissionregistration.k8s.io"]
  resources: ["mutatingwebhookconfigurations"]
  verbs: 
    - "get"
    - "list"
    - "watch"
    - "patch"

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: exporter-injector-binding
...
roleRef:
  kind: ClusterRole
  name: exporter-injector-clusterrole

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: exporter-injector-role
...
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs:
    - "get"
    - "patch"
    - "delete"

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: exporter-injector-rolebinding
...
roleRef:
  kind: Role
  name: exporter-injector-role

---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: exporter-agent-injector-cfg
...
webhooks:
  - name: some-exporter.io      //<--- поместить в Аннотации
    admissionReviewVersions:
      - "v1"
      - "v1beta1"
    clientConfig:
      service:
        name: exporter-agent-injector-svc
        path: "/mutate"
        namespace: "some-exporter"
		...
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["deployments", "jobs", "pods", "statefulsets"]

Если вы дошли до этих строк, то, с высокой вероятностью, уже имеете представление, как проектировать кастомные контроллеры в Кубернетес. Надеюсь, такое знание поможет вам без лишних усилий подступиться к разработке собственного Оператора, в том числе, осилить манифесты открытых изданий, например, по разработке на Golang с помощью куббилдера https://book.kubebuilder.io

Admission Review Request
Admission Review Request

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

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

Для ускорения работы с командной строкой кубернетес в примерах использовал алиас из одной буквы
$ alias k=kubectl

P.P.S. Если у вас есть идея разработать собственный Кубернетес Оператор на Go, особенно связанный с управлением тасками распределённого обучения LLM, но пока "не досуг", пишите об этом в комментариях, возможно, кто-то тоже собирается решить подобную задачу, и вместе мы что-нибудь придумаем, не исключено, в качестве опенсорс продукта.

Комментарии (0)