Kubernetes Operator на Python без фреймворків та SDK

Kubernetes Operator на Python без фреймворків та SDK

Go зараз є монополістом серед мов програмування, які люди вибирають для написання операторів для Kubernetes. Тому є такі об'єктивні причини, як:

  1. Існує найпотужніший фреймворк для розробки операторів на Go Operator SDK.
  2. На Go написані такі програми, що «перевернули гру», як Docker і Kubernetes. Писати свій оператор Go — говорити з екосистемою однією мовою.
  3. Висока продуктивність додатків на Go та прості інструменти для роботи з concurrency «з коробки».

NB: До речі, як написати свій оператор на Go, ми вже описували в одному із наших перекладів зарубіжних авторів.

Але що якщо вивчати Go вам заважає відсутність часу або, банально, мотивації? У статті наведено приклад того, як можна написати добротний оператор, використовуючи одну з найпопулярніших мов, яку знає практично кожен DevOps-інженер. Python.

Зустрічайте: Копіратор – копіювальний оператор!

Для прикладу розглянемо розробку простого оператора, призначеного для копіювання ConfigMap або з появою нового namespace, або за зміни однієї з двох сутностей: ConfigMap і Secret. З точки зору практичного застосування оператор може бути корисним для масового оновлення конфігурацій програми (шляхом оновлення ConfigMap) або для оновлення секретних даних - наприклад, ключів для роботи з Docker Registry (при додаванні Secret'а в namespace).

Отже, що має бути у хорошого оператора:

  1. Взаємодія з оператором здійснюється за допомогою Custom Resource Definitions (Далі - CRD).
  2. Оператор може налаштовуватись. Для цього будемо використовувати прапори командного рядка та змінні оточення.
  3. Складання Docker-контейнера та Helm-чарта проробляються так, щоб користувачі могли легко (буквально однією командою) встановити оператор у свій Kubernetes-кластер.

CRD

Щоб оператор знав, які ресурси та де йому шукати, нам потрібно задати для нього правило. Кожне правило буде подано у вигляді одного об'єкта CRD. Які поля мають бути у цього CRD?

  1. Тип ресурсу, який ми шукатимемо (ConfigMap або Secret).
  2. Список namespace'ів, у яких мають бути ресурси.
  3. Селектор, по якому ми шукатимемо ресурси в namespace'і.

Опишемо 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

І відразу ж створимо просте правило - На пошук у namespace'і з ім'ям default всіх ConfigMap з label'ами виду copyrator: "true":

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

Готово! Тепер потрібно якось отримати інформацію про наше правило. Відразу зазначу, що самостійно писати запити до API Server кластера ми не будемо. Для цього скористаємось готовою 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 way.

Змінні оточення чи прапори? Беремо все!

Переходимо до основної конфігурації оператора. Є два базові підходи до конфігурування додатків:

  1. використовувати параметри командного рядка;
  2. використовувати змінні оточення.

Параметри командного рядка дозволяють зчитувати налаштування гнучкіше, з підтримкою та валідацією типів даних. У стандартній бібліотеці Python'а є модуль argparser, Яким ми й скористаємося. Подробиці та приклади його можливостей доступні в офіційної документації.

Ось як для нашого випадку виглядатиме приклад налаштування зчитування прапорів командного рядка:

   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 можна легко перенести службову інформацію про pod'є всередину контейнера. Наприклад, інформацію про namespace, в якому запущено pod, ми можемо отримати таку конструкцію:

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 Server. Реалізуємо це так:

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', {})

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

Основна логіка готова! Тепер потрібно запакувати все це в один Python package. Оформлюємо файл setup.py, пишемо туди метаінформацію про проект:

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='[email protected]',
    include_package_data=True,
    install_requires=[
        'kubernetes==9.0.0',
    ],
    entry_points={
        'console_scripts': [
            '{0} = {0}.cli:main'.format(_NAME),
        ]
    }
)

NB: Клієнт kubernetes для Python має своє версіонування Докладніше про сумісність версій клієнта та версій Kubernetes можна дізнатися з матриці сумісностей.

Нині наш проект виглядає так:

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"]

Deployment для оператора теж дуже проста:

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 }}

Підсумок

Ось так, без страху, докору та вивчення Go ми змогли зібрати свого власного оператора для Kubernetes на Python. Звичайно, йому ще є куди зростати: у майбутньому він зможе обробляти кілька правил, працювати в кілька потоків, самостійно моніторити зміни своїх CRD.

Щоб можна було ближче познайомитися з кодом, ми склали його в публічний репозиторій. Якщо хочеться прикладів серйозніших операторів, реалізованих за допомогою Python, можете звернути свою увагу на два оператори для розгортання mongodb (перший и друга).

PS А якщо вам ліньки розбиратися з подіями Kubernetes або вам просто звичніше використовувати Bash — наші колеги приготували готове рішення у вигляді shell-operator (ми анонсували його у квітні).

PPS

Читайте також у нашому блозі:

Джерело: habr.com

Додати коментар або відгук