Пишем оператор Kubernetes на Python без фреймворков и SDK

Нужен оператор Kubernetes, но нет времени учить Go? В этой статье мы покажем, как создать надёжный оператор, используя Python.

Пишем оператор Kubernetes на Python без фреймворков и SDK

На сегодня Go – фактический монополист в реализации Kubernetes-операторов. Вот почему так сложилось:

  1. Operator SDK – это мощный фреймворк, который создан специально для реализации операторов на Go.
  2. Docker и Kubernetes реализованы на Go, и это меняет правила игры. Оператор, реализованный на Go, позволяет взаимодействовать со всей экосистемой.
  3. В языке Go есть простой механизм использования параллельности, поэтому приложения получаются высокопроизводительными.

Наверняка вам не захочется учить Go, если вы уже знаете Python. Поэтому мы сделаем надёжный оператор, используя язык программирования Python.

Copyrator – это наш оператор копирования!

Мы напишем простой оператор, спроектированный, чтобы копировать ConfigMap, когда появляется новое пространство имён или при изменении состояния объектов ConfigMap, Secret. Нашим оператором удобно производить массовые обновления настроек приложения, а также сбрасывать им секреты, например, ключи репозитория образов Docker (когда Secret добавлен в пространство имён).

Так какие функции должен выполнять хороший оператор Kubernetes? Вот они:

  1. Взаимодействие с оператором производится с помощью Custom Resource Definitions (далее CRD).
  2. Оператор поддерживает настройку. Мы можем использовать флаги в командной строке и переменные окружения для настройки.
  3. Образ Docker и чарты Helm создаются с учётом облегчения установки для пользователей (обычно одной командой) в их кластер Kubernetes.

CRD

Чтобы оператор знал, где и какие ресурсы искать, нужно настроить некоторые правила. Каждое правило будет представлено как особый CRD-объект. Какие поля должен иметь CRD-объект?

  1. Тип ресурсов, которые нам интересны (ConfigMap или Secret).
  2. Список пространств имён, хранящих ресурсы.
  3. Селектор, который помогает нам искать ресурсы в конкретном пространстве.

Давайте определим наш CRD:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: copyrator.flant.com
spec:
  group: flant.com
  versions:
  - name: v1
    served: true
    storage: true
  scope: Namespaced
  names:
    plural: copyrators
    singular: copyrator
    kind: CopyratorRule
    shortNames:
    - copyr
  validation:
    openAPIV3Schema:
      type: object
      properties:
        ruleType:
          type: string
        namespaces:
          type: array
          items:
            type: string
        selector:
          type: string

И немедленно добавим простое правило для выбора ConfigMaps с метками, совпадающими с copyrator: "true" в пространстве имён по умолчанию:

apiVersion: flant.com/v1
kind: CopyratorRule
metadata:
  name: main-rule
  labels:
    module: copyrator
ruleType: configmap
selector:
  copyrator: "true"
namespace: default

Отлично! Теперь нам нужно как-то получить информацию о наших правилах. Пришло время признаться: мы не будем делать запросы API нашего кластера вручную. Для этих целей есть Python библиотека – kubernetes-client:

import kubernetes
from contextlib import suppress


CRD_GROUP = 'flant.com'
CRD_VERSION = 'v1'
CRD_PLURAL = 'copyrators'


def load_crd(namespace, name):
    client = kubernetes.client.ApiClient()
    custom_api = kubernetes.client.CustomObjectsApi(client)

    with suppress(kubernetes.client.api_client.ApiException):
        crd = custom_api.get_namespaced_custom_object(
            CRD_GROUP,
            CRD_VERSION,
            namespace,
            CRD_PLURAL,
            name,
        )
    return {x: crd[x] for x in ('ruleType', 'selector', 'namespace')}

Выполнив код выше, получим следующий результат:

{'ruleType': 'configmap', 'selector': {'copyrator': 'true'}, 'namespace': ['default']}

Здорово! Теперь у нас есть особое правило для оператора. Важно, что мы смогли сделать это принятым для Kubernetes способом.

Переменные окружения или флаги? Всё сразу!

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

С помощью параметров командной строки вы сможете извлекать настройки с большей гибкостью и с поддержкой проверки типов данных. Используем модуль argparser из стандартной библиотеки Python. Детали и примеры использования доступны в официальной документации Python.

Вот пример настройки поиска флагов командной строки, адаптированных для нашего случая:

parser = ArgumentParser(
        description='Copyrator - copy operator.',
        prog='copyrator'
    )
    parser.add_argument(
        '--namespace',
        type=str,
        default=getenv('NAMESPACE', 'default'),
        help='Operator Namespace'
    )
    parser.add_argument(
        '--rule-name',
        type=str,
        default=getenv('RULE_NAME', 'main-rule'),
        help='CRD Name'
    )
    args = parser.parse_args()

С другой стороны, вы можете легко передать служебную информацию о модуле в контейнер с помощью переменных окружения в Kubernetes. Например, вы можете получить информацию о пространстве имён, в котором запущен модуль с помощью следующей структуры:

env:
- name: NAMESPACE
  valueFrom:
     fieldRef:
         fieldPath: metadata.namespace

Операционная логика оператора

Давайте используем специальные карты для разделения методов работы с ConfigMap и Secret. Они позволят нам определиться с методом, необходимым для отслеживания и создания объектов:

LIST_TYPES_MAP = {
    'configmap': 'list_namespaced_config_map',
    'secret': 'list_namespaced_secret',
}

CREATE_TYPES_MAP = {
    'configmap': 'create_namespaced_config_map',
    'secret': 'create_namespaced_secret',
}

Затем вы должны получать события от сервера API. Мы реализуем эту функциональность следующим образом:

def handle(specs):
    kubernetes.config.load_incluster_config()
    v1 = kubernetes.client.CoreV1Api()
# Получить метод для отслеживания объектов
    method = getattr(v1, LIST_TYPES_MAP[specs['ruleType']])
    func = partial(method, specs['namespace'])

    w = kubernetes.watch.Watch()
    for event in w.stream(func, _request_timeout=60):
        handle_event(v1, specs, event)

После получения события мы приступаем к основной логике обработки:

# Типы событий, на которые мы будем отвечать
ALLOWED_EVENT_TYPES = {'ADDED', 'UPDATED'}
def handle_event(v1, specs, event):
    if event['type'] not in ALLOWED_EVENT_TYPES:
        return

    object_ = event['object']
    labels = object_['metadata'].get('labels', {})
    # Искать совпадения с помощью селектора
    for key, value in specs['selector'].items():
        if labels.get(key) != value:
            return
    # Получить активные пространства имён
    namespaces = map(
        lambda x: x.metadata.name,
        filter(
            lambda x: x.status.phase == 'Active',
            v1.list_namespace().items
        )
    )
    for namespace in namespaces:
        # Очистить метаданные, задать пространство имён
        object_['metadata'] = {
            'labels': object_['metadata']['labels'],
            'namespace': namespace,
            'name': object_['metadata']['name'],
        }
        # Вызвать метод создания/обновления объекта
        methodcaller(
            CREATE_TYPES_MAP[specs['ruleType']],
            namespace,
            object_
        )(v1)

Базовая логика завершена! Теперь нужно упаковать её в единый пакет Python. Создаём setup.py и добавляем туда метаданные о проекте:

from sys import version_info
from sys import version_info

from setuptools import find_packages, setup

if version_info[:2] < (3, 5):
    raise RuntimeError(
        'Unsupported python version %s.' % '.'.join(version_info)
    )


_NAME = 'copyrator'
setup(
    name=_NAME,
    version='0.0.1',
    packages=find_packages(),
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Programming Language :: Python',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.5',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
    ],
    author='Flant',
    author_email='maksim.nabokikh@flant.com',
    include_package_data=True,
    install_requires=[
        'kubernetes==9.0.0',
    ],
    entry_points={
        'console_scripts': [
            '{0} = {0}.cli:main'.format(_NAME),
        ]
    }
)

Сейчас наш проект имеет следующую структуру:

copyrator
├── copyrator
│ ├── cli.py # Операционная логика комнадной строки
│ ├── constant.py # Константы, описанные выше
│ ├── load_crd.py # Логика загрузки CRD
│ └── operator.pyк # Базовая логика оператора
└── setup.py # Описание пакета

Docker и Helm

Результирующий Dockerfile до смешного прост: мы берём базовый образ python-alpine и устанавливаем наш пакет (давайте отложим его оптимизацию на лучшее время):

FROM python:3.7.3-alpine3.9
ADD . /app
RUN pip3 install /app
ENTRYPOINT ["copyrator"]

Развернуть Copyrator тоже очень легко:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
spec:
  selector:
    matchLabels:
      name: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        name: {{ .Chart.Name }}
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: privaterepo.yourcompany.com/copyrator:latest
        imagePullPolicy: Always
        args: ["--rule-type", "main-rule"]
        env:
        - name: NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
      serviceAccountName: {{ .Chart.Name }}-acc

Наконец, создаём соответствующую роль для оператора с необходимыми разрешениями:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ .Chart.Name }}-acc

---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  name: {{ .Chart.Name }}
rules:
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["get", "watch", "list"]
  - apiGroups: [""]
    resources: ["secrets", "configmaps"]
    verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: {{ .Chart.Name }}
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: {{ .Chart.Name }}
subjects:
- kind: ServiceAccount
  name: {{ .Chart.Name }}

Заключение

В этой статье мы показали, как создать ваш собственный оператор Kubernetes на Python. Конечно, он ещё сыроват: вы можете обогатить его возможностями обработки нескольких правил, самостоятельным мониторингом изменений в своих CRD, извлечь выгоду из возможностей параллелизма.

Весь код лежит в репозитории.

Автор статьи

А какой язык предпочли бы вы в задачах DevOps?

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ