Operator Kubernetes w Pythonie bez frameworków i SDK

Operator Kubernetes w Pythonie bez frameworków i SDK

Go ma obecnie monopol na języki programowania, w których ludzie piszą oświadczenia dla Kubernetes. Istnieją ku temu obiektywne przyczyny, takie jak:

  1. Istnieje potężna platforma do rozwijania operatorów w Go - Pakiet SDK operatora.
  2. Aplikacje zmieniające zasady gry, takie jak Docker i Kubernetes, są pisane w Go. Napisanie operatora w Go oznacza mówienie tym samym językiem z ekosystemem.
  3. Wysoka wydajność aplikacji Go i proste narzędzia do pracy ze współbieżnością od razu po wyjęciu z pudełka.

NB: Swoją drogą, jak napisać własną wypowiedź w Go, my już opisane w jednym z naszych tłumaczeń autorstwa zagranicznych autorów.

Co jednak, jeśli naukę Go uniemożliwia Ci brak czasu lub, mówiąc prościej, motywacji? W artykule podany jest przykład jak napisać dobre oświadczenie korzystając z jednego z najpopularniejszych języków, które zna niemal każdy inżynier DevOps - Python.

Poznaj: Kopiarka - operator kopiarki!

Jako przykład rozważ opracowanie prostej instrukcji zaprojektowanej do kopiowania ConfigMap, gdy pojawi się nowa przestrzeń nazw lub gdy zmieni się jedna z dwóch jednostek: ConfigMap i Secret. Z praktycznego punktu widzenia operator może przydać się do zbiorczej aktualizacji konfiguracji aplikacji (poprzez aktualizację ConfigMap) lub do aktualizacji tajnych danych - na przykład kluczy do pracy z Docker Registry (przy dodawaniu Secret do przestrzeni nazw).

W ten sposób co powinien posiadać dobry operator:

  1. Interakcja z operatorem odbywa się za pomocą Niestandardowe definicje zasobów (dalej jako CRD).
  2. Możliwość konfiguracji operatora. W tym celu użyjemy flag wiersza poleceń i zmiennych środowiskowych.
  3. Kompilacja kontenera Docker i wykresu Helm została zaprojektowana tak, aby użytkownicy mogli łatwo (dosłownie jednym poleceniem) zainstalować operatora w swoim klastrze Kubernetes.

CRD

Aby operator wiedział jakich zasobów szukać i gdzie szukać musimy ustawić dla niego regułę. Każda reguła będzie reprezentowana jako pojedynczy obiekt CRD. Jakie pola powinien zawierać ten CRD?

  1. Typ zasobu, którego będziemy szukać (ConfigMap lub Secret).
  2. Lista przestrzeni nazw, w którym powinny być zlokalizowane zasoby.
  3. Selektor, według którego będziemy szukać zasobów w przestrzeni nazw.

Opiszmy 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

I od razu go stworzymy prosta zasada — aby przeszukać przestrzeń nazw według nazwy default wszystkie ConfigMap z etykietami takimi jak copyrator: "true":

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

Gotowy! Teraz musimy w jakiś sposób zdobyć informacje o naszej regule. Od razu zastrzegam, że sami nie będziemy pisać żądań do serwera API klastra. W tym celu skorzystamy z gotowej biblioteki Pythona klient kubernetes:

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

W wyniku uruchomienia tego kodu otrzymamy następujący komunikat:

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

Świetnie: udało nam się uzyskać regułę dla operatora. A co najważniejsze, zrobiliśmy tak zwaną drogę Kubernetesa.

Zmienne środowiskowe lub flagi? Bierzemy wszystko!

Przejdźmy do konfiguracji głównego operatora. Istnieją dwa podstawowe podejścia do konfigurowania aplikacji:

  1. użyj opcji wiersza poleceń;
  2. użyj zmiennych środowiskowych.

Opcje wiersza poleceń umożliwiają bardziej elastyczne odczytywanie ustawień, z obsługą typów danych i sprawdzaniem poprawności. Standardowa biblioteka Pythona zawiera moduł argparser, z którego skorzystamy. Szczegóły i przykłady jego możliwości dostępne są w oficjalna dokumentacja.

W naszym przypadku tak mógłby wyglądać przykład ustawienia flag wiersza poleceń do odczytu:

   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()

Z drugiej strony, używając zmiennych środowiskowych w Kubernetesie, możesz łatwo przenieść informacje o usłudze o podie do wnętrza kontenera. Przykładowo informację o przestrzeni nazw, w której działa pod, możemy uzyskać następującą konstrukcją:

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

Logika operatora

Aby zrozumieć, jak oddzielić metody pracy z ConfigMap i Secret, użyjemy specjalnych map. Wtedy możemy zrozumieć, jakich metod potrzebujemy do śledzenia i tworzenia obiektu:

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

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

Następnie musisz odbierać zdarzenia z serwera API. Zaimplementujmy to w następujący sposób:

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)

Po otrzymaniu zdarzenia przechodzimy do głównej logiki jego przetwarzania:

# Типы событий, на которые будем реагировать
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)

Główna logika jest gotowa! Teraz musimy spakować to wszystko w jeden pakiet Pythona. Przygotowujemy plik setup.py, wpisz tam metainformacje o projekcie:

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: Klient kubernetes dla języka Python ma własną wersję. Więcej informacji na temat kompatybilności pomiędzy wersjami klienckimi i wersjami Kubernetesa znajdziesz w macierze zgodności.

Teraz nasz projekt wygląda tak:

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

Docker i Helm

Plik Dockerfile będzie niezwykle prosty: weź podstawowy obraz Pythona-alpine i zainstaluj nasz pakiet. Odłóżmy jego optymalizację na lepsze czasy:

FROM python:3.7.3-alpine3.9

ADD . /app

RUN pip3 install /app

ENTRYPOINT ["copyrator"]

Wdrożenie dla operatora jest również bardzo proste:

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

Na koniec należy utworzyć odpowiednią rolę dla operatora z niezbędnymi uprawnieniami:

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

Łączny

W ten sposób bez strachu, wyrzutów i nauki Go udało nam się zbudować własnego operatora dla Kubernetesa w Pythonie. Oczywiście ma jeszcze przestrzeń do rozwoju: w przyszłości będzie mógł przetwarzać wiele reguł, pracować w wielu wątkach, samodzielnie monitorować zmiany w swoich CRD...

Aby dać Ci bliższe spojrzenie na kod, umieściliśmy go repozytorium publiczne. Jeśli chcesz przykładów poważniejszych operatorów zaimplementowanych przy użyciu Pythona, możesz zwrócić uwagę na dwóch operatorów do wdrożenia mongodb (первый и drugi).

PS A jeśli jesteś zbyt leniwy, aby zajmować się zdarzeniami Kubernetesa lub po prostu jesteś bardziej przyzwyczajony do korzystania z Basha, nasi koledzy przygotowali gotowe rozwiązanie w postaci operator powłoki (My ogłosił to w kwietniu).

PPS

Przeczytaj także na naszym blogu:

Źródło: www.habr.com

Dodaj komentarz