Kubernetes построен на контроллерах.

Сейчас мы рассмотрим, как расширить кластер, чтобы он начал решать не общие задачи, а именно наши.

Механика контроллеров и операторов

В паре слов о том, как это всё работает.

Предполагается, что вы уже базово знакомы с Kubernetes и понимаете, как раскатить в кластер манифест или Helm-чарт 🧑‍💻

В Kubernetes есть, так называемый, Control Plane - фактически, центр управления.

В его рамках работают контроллеры.

Контроллер - это процесс, который следит за состоянием кластера и предпринимает действия по приведению его в желаемое состояние.

Например:

  • Мы создали в кластере объекты Deployment, Service и Ingress, чтобы выкатить наш сервис
  • Контроллер это увидит и породит ReplicaSet
  • По наличию ReplicaSet, будет создано желаемое количество Pod-ов
  • По наличию объекта Service, определенные Pod-ы будут поставлены под раздачу трафика
  • Увидев объект Ingress, принимающее трафик ПО добавит правила по его перенаправлению в наш Service
  • Наш сервис станет доступен извне, успех!

Схематично оно выглядит так:

graph LR object controller subgraph actions[some actions] action-A action-B action-C end object -.-|control loop| controller controller -->|perform| actions

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

Есть ещё операторы.

Операторы - это частный случай контроллера, работающего с 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

  • Контроллеры дают практически безграничные возможности для автоматизации рутинных действий