Kubernetes построен на контроллерах.
Сейчас мы рассмотрим, как расширить кластер, чтобы он начал решать не общие задачи, а именно наши.
Механика контроллеров и операторов
В паре слов о том, как это всё работает.
Предполагается, что вы уже базово знакомы с Kubernetes и понимаете, как раскатить в кластер манифест или Helm-чарт 🧑💻
В Kubernetes есть, так называемый, Control Plane - фактически, центр управления.
В его рамках работают контроллеры.
Контроллер - это процесс, который следит за состоянием кластера и предпринимает действия по приведению его в желаемое состояние.
Например:
- Мы создали в кластере объекты
Deployment
,Service
иIngress
, чтобы выкатить наш сервис - Контроллер это увидит и породит
ReplicaSet
- По наличию
ReplicaSet
, будет создано желаемое количествоPod
-ов - По наличию объекта
Service
, определенныеPod
-ы будут поставлены под раздачу трафика - Увидев объект
Ingress
, принимающее трафик ПО добавит правила по его перенаправлению в нашService
- Наш сервис станет доступен извне, успех!
Схематично оно выглядит так:
При этом, действия могут производиться как внутри кластера, так и вне его.
Есть ещё операторы.
Операторы - это частный случай контроллера, работающего с Custom Resources
, пользовательскими объектами.
Мы, как пользователи кластера, можем добавить к стандартным объектам какой-нибудь мета-объект типа MyServiceDeployment
, который будет призван породить все необходимые для выкатки сервиса. И ещё бекап базы данных у провайдера заказать
Нет даже принципиальной проблемы создать внутри кластера объект
PizzaOrder
, а по нему отправить скрипт на сайт пиццерии, оформить там заказ, указать адрес и применить промо-код 🍕
Нам из этого важно понимать, что Kubernetes возможно расширять.
Способы написать контроллер
Kubernetes написан на Golang и наиболее нативный способ интегрироваться с ним это написать свою логику на этом языке, взяв за основу нативные библиотеки.
В то же время, есть kube-apiserver
, являющийся интерфейсом, отвязан от конкретного языка. Воспользоваться можно любым языком. Хоть на голом shell-е данные обрабатывайте и ответы рассылайте.
Существует shell-operator, и это не шутка 🛠️
Если мы пишем контроллер самостоятельно, то нам надо будет предусмотреть подобный цикл:
- Периодический опрос API либо подписка на набор обновлений
- Анализ текущего состояния
- При наличии изменений, реакция на них
Вот пример такого цикла в контроллере, который ищет и перезапускает Pod-ы с подтекающей памятью:
# NOTE: Adopted from here:
# https://github.com/agrrh/kube-viktor/blob/13312cf/kube-viktor/controller.py#L35
pods = selector.get_all_suitable(labels)
pods_selected = filter(analyzer.select_for_eviction, pods)
for pod in pods_selected:
evictor.handle(pod)
Проект Metacontroller
Так как задача достаточно типовая, то естественным образом напрашивается выделить зону “описать цикл” в некоторый готовый пакет, оставив пользователю только описать логику конкретного контроллера.
И тут на сцену выходит проект Metacontroller, вышедший из Google Cloud Platform.
Он предоставляет всю обвязку вокруг контроллеров, а пользователю останется только описать свою логику, не слишком погружаясь в подкапотное пространство.
Я попробовал им воспользоваться и хочу поделиться с вами тем, насколько оно быстро и удобно.
Всё дальнейшее - это адаптированный пересказ этой статьи, можно смело закрыть эту страницу и прочитать оригинал 📖
Воображаемая ситуация
Допустим, вы DevOps в небольшой компании и у вас есть ряд разработчиков-тестировщиков, которые периодически создают новые проекты.
Создание проекта сопровождается рядом действий:
- Создать проект в Jira
- Выдать права
- Подключить какие-нибудь шаблоны задач
- Создать Namespace в Kubernetes-кластере
- Выдать права
- Создать репозиторий в GitLab
- Выдать права
- Запустить и подключить GitLab CI Runner-а для выполнения сборок и тестов
И так далее.
Всё это можно делать вручную, но это неудобно.
Было бы эффективно написать k8s-оператора, который по наличию объекта Project
, уже и произведет все необходимые действия.
Мы же, для начала, научимся просто создавать Namespace
в том же кластере, где появился объект Project
.
Подготовимся
Во-первых, надо поставить в кластер сам Metacontroller.
Я сделал это с использованием этого Helm-чарта cowboysysop/metacontroller, никак не меняя его параметры.
Вкатите и вы этот чарт в свой тестовый кластер.
Создадим рабочее пространство
Metacontroller позволяет нам создать свой контроллер. Для него нам потребуется пространство имён.
Создадим его:
apiVersion: v1
kind: Namespace
metadata:
name: meta-project
После применения этого манифеста, в кластере должно пространство имён meta-project
.
В него мы далее заведем всю обвязку для обработки нашей логики.
Опишем пользовательский ресурс
Мы хотим создавать объект Project
.
Пусть у него будет пара каких-нибудь полей - например, “описание” и “лицензия”, а далее мы сможем использовать их при создании репозитория.
Пользовательские объекты в Kubernetes могут иметь область применимости, Scope
.
Можно сделать эту сущность привязанной к пространству имён, scope: Namespaced
.
Но т.к. наша сущность Project
сама будет порождать пространство имён, мы сделаем её общбекластерной, scope: Cluster
.
Опишем пользовательский ресурс:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: projects.agrrh.com
spec:
group: agrrh.com
names:
kind: Project
plural: projects
singular: project
scope: Cluster
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
description:
type: string
license:
type: string
subresources:
status: {}
Теперь кластер будет готов принять объект типа Project
.
Опишем контроллер
Опишем контроллер, который имеет подвязку на процесс синхронизации.
Так события из кластера, по изменению объектов Project
будут направляться по ссыке, указанной в spec.hooks.sync.webhook.url
.
apiVersion: metacontroller.k8s.io/v1alpha1
kind: CompositeController
metadata:
name: controller-project
spec:
generateSelector: true
parentResource:
apiVersion: agrrh.com/v1
resource: projects
childResources:
- apiVersion: v1
resource: namespaces
hooks:
sync:
webhook:
url: http://controller.meta-project.svc/sync
Далее мы опишем принимающий запросы сервис.
Создадим сервис-обработчик
У нас будет сам сервис:
apiVersion: v1
kind: Service
metadata:
name: controller
namespace: meta-project
spec:
selector:
app: controller
ports:
- port: 80
Под ним, через Deployment
, будет развернут Pod
с некоторым скриптом, описывающим нашу логику:
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller
namespace: meta-project
spec:
replicas: 1
selector:
matchLabels:
app: controller
template:
metadata:
labels:
app: controller
spec:
containers:
- name: controller
image: python:3
command: ["python3", "/hooks/sync.py"]
volumeMounts:
- name: hooks
mountPath: /hooks
volumes:
- name: hooks
configMap:
name: controller
А вот сам скрипт:
apiVersion: v1
kind: ConfigMap
metadata:
name: controller
namespace: meta-project
data:
sync.py: |
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Controller(BaseHTTPRequestHandler):
def sync(self, parent, children):
# Compute status based on observed state.
desired_status = {
"namespaces": len(children["Namespace.v1"])
}
# Generate the desired child object(s).
description = parent.get("spec", {}).get("description", "")
license = parent.get("spec", {}).get("license", "WTFPL")
desired_namespaces = [
{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {
"name": "-".join(("proj", parent["metadata"]["name"])),
"annotations": {
"description": parent["spec"]["description"],
"license": parent["spec"]["license"],
}
}
}
]
return {"status": desired_status, "children": desired_namespaces}
def do_POST(self):
# Serve the sync() function as a JSON webhook.
observed = json.loads(self.rfile.read(int(self.headers.get("content-length"))))
desired = self.sync(observed["parent"], observed["children"])
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(desired).encode())
HTTPServer(("", 80), Controller).serve_forever()
Он принимает текущее состояние кластера, а на выход отдаёт Namespace
-объекты, которые хочет создать.
Так как мы хотим делать 100500 вещей, то чуть правильней было не создавать сразу
Namespace
, а сначала создаватьPod
, который затем уже создастNamespace
Так для интеграции с новым сервисом, мы потом просто добавили бы ещё один Pod и спрятали в него всю потенциально объемную логику
Но это усложнило бы всю схему, а мы пока просто учимся, так что пропустим этот слой 👨🔬
В данном случае, мы смотрим на объекты типа Project
, и для каждого должен быть создан Namespace
с именем proj-%projectName%
и аннотациями description
и license
.
Ожидаемые объекты на выходе:
apiVersion: v1
kind: Namespace
metadata:
name: "proj-%projectName%"
annotations:
description: "%projectDescription%"
license: "%projectLicense%"
Посмотрим, как это сработает
Проверяем работу
Теперь, я создаю сам объект Project
:
apiVersion: agrrh.com/v1
kind: Project
metadata:
name: test
spec:
description: test project
license: WTFPL
Смотрю в логи контроллера project
:
%redacted-ip% - - [12/Aug/2023 09:57:37] "POST /sync HTTP/1.1" 200 -
%redacted-ip% - - [12/Aug/2023 09:57:38] "POST /sync HTTP/1.1" 200 -
Он что-то там принял и обработал.
А теперь в логи metacontroller
-а:
{"level":"info","ts":1691834257.8590329,"msg":"Waiting for caches to sync for Project\n"}
{"level":"info","ts":1691834257.9618316,"msg":"Caches are synced for Project \n"}
{"level":"info","ts":1691834258.0969346,"msg":"Creating","parent":{"apiVersion":"agrrh.com/v1","kind":"Project","name":"test"},"child":{"apiVersion":"v1","kind":"Namespace","name":"proj-test"}}
{"level":"info","ts":1691834258.47916,"logger":"KubeAPIWarningLogger","msg":"unknown field \"status\""}
{"level":"info","ts":1691834258.7607808,"msg":"Caches are synced for HelloWorld \n"}
Видно, что по наличию объекта Project
с именем test
, был создан Namespace
-объект proj-test
.
Проверим:
➜ kubectl get ns proj-test
NAME STATUS AGE
proj-test Active 3m2s
А появились ли аннотации?
➜ kubectl get ns proj-test -o yaml | yq -y '.metadata'
name: proj-test
annotations:
description: test project
license: WTFPL
[...]
Да, аннотации на месте, как и ряд технических полей.
Как это можно развить
Как было сказано в нашей воображаемой задачке, процесс комплексный и включает в себя множество шагов.
Создание Namespace - только один из них.
Далее на этот Namespace
нужно будет раздать права для пользователей и роботов, надо будет создать репозиторий, создать проект в системе ведения задач и так далее …
После выполнения всех этих вещей, пользователи смогут пойти и сделать что-нибудь полезное.
Как было сказано выше, кажется удобным и разумным сделать несколько Pod
-ов, которые могли бы производить действия каждый в своей области:
- Пусть один создаёт Jira-проект
- Второй пусть создаёт GitLab-репу
- А третий ждёт создания репы и подключает CI Runner
Контроллер будет только лишь создавать эти Pod
-ы.
Это будет более удобный дизайн, его удобней расширять настолько, насколько это надо вашему проекту, и насколько позволяет фантазия.
Подытожим
Kubernetes это прежде всего платформа
Контроллеры-операторы это основной способ расширять Kubernetes
Контроллеры дают практически безграничные возможности для автоматизации рутинных действий