Kubernetes Operator w Pythonie bez frameworków i SDK

Kubernetes Operator w Pythonie bez frameworków i SDK

Go jest obecnie monopolistą wśród języków programowania, które ludzie wybierają do pisania operatorów dla Kubernetes. Istnieją obiektywne powody, by tak myśleć, jak:

  1. Istnieje potężny framework do tworzenia operatorów w Go — Operator SDK.
  2. Aplikacje, które zmieniły zasady gry, takie jak Docker i Kubernetes, są napisane w Go. Pisanie własnego operatora w Go oznacza rozmowę z ekosystemem w tym samym języku.
  3. Wysoka wydajność aplikacji w Go i proste narzędzia do pracy z concurrency "z pudełka".

NB: Przy okazji, jak napisać własnego operatora w Go, już opisywaliśmy w jednym z naszych tłumaczeń zagranicznych autorów.

Ale co jeśli przeszkadza Ci w nauce Go brak czasu lub, po prostu, motywacji? W artykule przedstawiony jest przykład, jak można napisać dobrze zbudowanego operatora, używając jednego z najpopularniejszych języków, który zna praktycznie każdy inżynier DevOps — Python.

Przedstawiamy: Koper – operator kopiujący!

Na przykład rozważmy rozwój prostego operatora, który ma na celu kopiowanie ConfigMap, zarówno przy pojawieniu się nowego namespace, jak i przy zmianie jednej z dwóch encji: ConfigMap i Secret. Z punktu widzenia praktycznego zastosowania, operator może być przydatny do masowej aktualizacji konfiguracji aplikacji (poprzez aktualizację ConfigMap) lub do aktualizacji danych poufnych — na przykład kluczy do pracy z Docker Registry (przy dodaniu Secret'a do namespace).

Otóż co powinno mieć dobry operator:

  1. Interakcja z operatorem odbywa się za pomocą Custom Resource Definitions (dalej — CRD).
  2. Operator może być konfigurowany. W tym celu użyjemy flag linii poleceń i zmiennych środowiskowych.
  3. Kompilacja kontenera Docker i wykresu Helm jest tak zaplanowana, aby użytkownicy mogli łatwo (dosłownie jednym poleceniem) zainstalować operatora w swoim klastrze Kubernetes.

CRD

Aby operator wiedział, jakie zasoby i gdzie ma szukać, musimy nadać mu zasady. Każda zasada będzie reprezentowana jako jeden obiekt CRD. Jakie pola powinny mieć te CRD?

  1. Typ zasobu, którego będziemy szukać (ConfigMap lub Secret).
  2. Lista namespace'ów, w których powinny znajdować się zasoby.
  3. Selector, według którego będziemy szukać zasobów w namespace.

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 stworzymy proste reguły — do wyszukiwania w przestrzeni nazw o nazwie default wszystkich ConfigMap z etykietami w formacie copyrator: "true":

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

Gotowe! Teraz musimy jakoś uzyskać informacje o naszej regule. Od razu zaznaczam, że nie będziemy sami pisać zapytań do API Server klastra. Zamiast tego skorzystamy z gotowej biblioteki 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')}

W wyniku działania tego kodu uzyskamy następujące:

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

Świetnie: udało nam się uzyskać regułę dla operatora. A co najważniejsze — zrobiliśmy to, co nazywamy 'Kubernetes way'.

Zmienne środowiskowe czy flagi? Bierzemy wszystko!

Przechodzimy do głównej konfiguracji operatora. Istnieją dwa podstawowe podejścia do konfigurowania aplikacji:

  1. używać parametrów linii poleceń;
  2. używać zmiennych środowiskowych.

Parametry linii poleceń pozwalają na bardziej elastyczne odczytywanie ustawień, z obsługą i walidacją typów danych. W standardowej bibliotece Pythona znajduje się moduł argparser, z którego skorzystamy. Szczegóły i przykłady jego możliwości są dostępne w oficjalnej dokumentacji.

Oto jak będzie wyglądał przykład konfiguracji odczytu flag linii poleceń dla naszego przypadku:

   parser = ArgumentParser(
        description='Copyrator - operator kopiujący.',
        prog='copyrator'
    )
    parser.add_argument(
        '--namespace',
        type=str,
        default=getenv('NAMESPACE', 'default'),
        help='Przestrzeń nazw operatora'
    )
    parser.add_argument(
        '--rule-name',
        type=str,
        default=getenv('RULE_NAME', 'main-rule'),
        help='Nazwa CRD'
    )
    args = parser.parse_args()

Z drugiej strony, korzystając z zmiennych środowiskowych w Kubernetes, można łatwo przenieść informacje o pod’zie do wnętrza kontenera. Na przykład, informacje o namespace, w którym uruchomiono pod, możemy uzyskać następującą konstrukcją:

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

Logika działania operatora

Aby zrozumieć, jak rozdzielić metody pracy z ConfigMap i Secret, posłużymy się specjalnymi mapami. Dzięki temu zrozumiemy, jakie metody są potrzebne 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 musimy odbierać zdarzenia z serwera API. Realizujemy to w następujący sposób:

def handle(specs):
    kubernetes.config.load_incluster_config()
    v1 = kubernetes.client.CoreV1Api()

    # Odbieramy metodę do śledzenia obiektów
    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 odebraniu 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. Tworzymy plik setup.py, wpisujemy tam metainformacje o projekcie:

from sys import version_info

from setuptools import find_packages, setup

if version_info[:2] < (3, 5):
    raise RuntimeError(
        'Nieobsługiwana wersja Pythona %s.' % '.'.join(version_info)
    )


_NAME = 'copyrator'
setup(
    name=_NAME,
    version='0.0.1',
    packages=find_packages(),
    classifiers=[
        'Status rozwoju :: 3 - Alpha',
        'Język programowania :: Python',
        'Język programowania :: Python :: 3',
        'Język programowania :: Python :: 3.5',
        'Język programowania :: Python :: 3.6',
        'Język programowania :: 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),
        ]
    }
)

NB: Klient kubernetes dla Pythona ma swoje własne wersjonowanie. Szczegóły dotyczące kompatybilności wersji klienta i wersji Kubernetes można znaleźć w macierzy zgodności.

Teraz nasz projekt wygląda tak:

copyrator
├── copyrator
│   ├── cli.py # Logika obsługi wiersza poleceń
│   ├── constant.py # Stałe, które przytoczyliśmy wcześniej
│   ├── load_crd.py # Logika ładowania CRD
│   └── operator.py # Główna logika działania operatora
└── setup.py # Przygotowanie pakietu

Docker i Helm

Dockerfile będzie niezwykle prosty: weźmiemy podstawowy obraz python-alpine i zainstalujemy nasz pakiet. Jego optymalizację odłożymy na lepsze czasy:

FROM python:3.7.3-alpine3.9

ADD . /app

RUN pip3 install /app

ENTRYPOINT ["copyrator"]

Wdrażanie 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 wymaganymi 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 }}

Podsumowanie

Tak, bez strachu, pretensji i nauki Go, stworzyliśmy własnego operatora dla Kubernetes w Pythonie. Oczywiście, ma jeszcze wiele do zrobienia: w przyszłości będzie mógł obsługiwać wiele reguł, pracować w kilku wątkach, samodzielnie monitorować zmiany swoich CRD...

Aby można było lepiej zapoznać się z kodem, zgromadziliśmy go w publicznym repozytorium. Jeśli szukasz bardziej zaawansowanych przykładów operatorów zrealizowanych w Pythonie, możesz zwrócić uwagę na dwa operatory do wdrażania mongodb (pierwszy i drugi).

P.S. A jeśli nie chce Ci się zgłębiać wydarzeń Kubernetes lub po prostu wolisz używać Bash — nasi koledzy przygotowali gotowe rozwiązanie w postaci shell-operator (zrobiliśmy to w kwietniu). ogłosili Prezentujemy shell-operator: tworzenie operatorów dla Kubernetes stało się jeszcze prostsze

P.P.S.

Przeczytaj także na naszym blogu:

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster