Kubernetes Operator in Python senza framework e SDK

Kubernetes Operator in Python senza framework e SDK

Go è attualmente il linguaggio di programmazione dominante scelto dalle persone per scrivere operatori per Kubernetes. Ci sono ragioni oggettive per questo, come:

  1. Esiste un potente framework per lo sviluppo di operatori in Go — Operator SDK.
  2. Applicazioni rivoluzionarie come Docker e Kubernetes sono scritte in Go. Scrivere il proprio operatore in Go significa comunicare con l'ecosistema nella stessa lingua.
  3. Le prestazioni elevate delle applicazioni in Go e gli strumenti semplici per gestire la concorrenza "out of the box".

NB: A proposito, come scrivere il proprio operatore in Go, abbiamo già descritto in una delle nostre traduzioni di autori stranieri.

Ma cosa fare se la mancanza di tempo o, banalmente, di motivazione ti impedisce di studiare Go? Nell'articolo è fornito un esempio su come scrivere un operatore solido utilizzando uno dei linguaggi più popolari, conosciuto quasi da ogni ingegnere DevOps — Python.

Ecco a voi: CopiRator — un operatore per la copia!

Prendiamo ad esempio lo sviluppo di un operatore semplice, progettato per copiare un ConfigMap sia all'aggiunta di un nuovo namespace, sia alla modifica di una delle due entità: ConfigMap e Secret. Dal punto di vista pratico, l'operatore può essere utile per l'aggiornamento massivo delle configurazioni dell'applicazione (attraverso l'aggiornamento del ConfigMap) o per l'aggiornamento dei dati segreti — ad esempio, delle chiavi per il lavoro con il Docker Registry (quando viene aggiunto un Secret nel namespace).

Quindi, cosa deve avere un buon operatore:

  1. L'interazione con l'operatore avviene tramite Custom Resource Definitions (di seguito — CRD).
  2. L'operatore è configurabile. A tale scopo utilizzeremo i flag della riga di comando e le variabili d'ambiente.
  3. La costruzione del contenitore Docker e del chart Helm è progettata in modo che gli utenti possano facilmente (letteralmente con un comando) installare l'operatore nel proprio cluster Kubernetes.

CRD

Perché l'operatore sappia quali risorse cercare e dove, dobbiamo fornirgli una regola. Ogni regola sarà rappresentata da un singolo oggetto CRD. Quali campi devono essere presenti in questo CRD?

  1. Tipo di risorsa, che stiamo cercando (ConfigMap o Secret).
  2. Elenco dei namespace, in cui devono trovarsi le risorse.
  3. Selettore, in base al quale cercheremo le risorse nello spazio dei nomi.

Descriviamo il 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

E subito creiamo una regola semplice — per cercare nello spazio dei nomi con il nome default di tutti i ConfigMap con etichette di tipo copyrator: "true":

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

Fatto! Ora dobbiamo in qualche modo ottenere informazioni sulla nostra regola. Specifico subito che non scriveremo richieste all'API Server del cluster. Per questo utilizzeremo una libreria Python già pronta 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')}

Il risultato di questo codice sarà il seguente:

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

Ottimo: siamo riusciti a ottenere la regola per l'operatore. E la cosa più importante è che l'abbiamo fatto, come si suol dire, alla maniera di Kubernetes.

Variabili d'ambiente o flag? Prendiamo tutto!

Passiamo alla configurazione principale dell'operatore. Ci sono due approcci fondamentali per configurare le applicazioni:

  1. utilizzare i parametri della riga di comando;
  2. utilizzare le variabili d'ambiente.

I parametri della riga di comando consentono di leggere le impostazioni in modo più flessibile, con supporto e convalida dei tipi di dati. Nella libreria standard di Python c'è il modulo argparser, che useremo. Ulteriori dettagli e esempi delle sue funzionalità sono disponibili in documentazione ufficiale.

Ecco come per il nostro caso apparirà un esempio di configurazione della lettura dei flag della riga di comando:

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

    # Otteniamo il metodo per il monitoraggio degli oggetti
    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)

Dopo aver ricevuto l'evento, passiamo alla logica principale di elaborazione:

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

La logica principale è pronta! Ora dobbiamo confezionare tutto in un unico pacchetto Python. Creiamo il file setup.py, scriviamo le informazioni di meta del progetto:

from sys import version_info

from setuptools import find_packages, setup

if version_info[:2] < (3, 5):
    raise RuntimeError(
        'Versione python non supportata %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),
        ]
    }
)

NB: Il client kubernetes per Python ha una propria versione. Maggiori informazioni sulla compatibilità delle versioni del client e delle versioni di Kubernetes possono essere trovate nella matrice di compatibilità.

Ora il nostro progetto appare così:

copyrator
├── copyrator
│   ├── cli.py # Logica di funzionalità della riga di comando
│   ├── constant.py # Costanti che abbiamo menzionato sopra
│   ├── load_crd.py # Logica di caricamento CRD
│   └── operator.py # Logica principale del funzionamento dell'operatore
└── setup.py # Impostazione del pacchetto

Docker e Helm

Il Dockerfile sarà estremamente semplice: prenderemo l'immagine di base python-alpine e installeremo il nostro pacchetto. L'ottimizzazione sarà rimandata a tempi migliori:

FROM python:3.7.3-alpine3.9

ADD . /app

RUN pip3 install /app

ENTRYPOINT ["copyrator"]

Il Deployment per l'operatore è anche molto semplice:

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

Infine, è necessario creare il ruolo corrispondente per l'operatore con i diritti necessari:

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

Risultato

In questo modo, senza paura, rimproveri o studiando Go, siamo riusciti a costruire il nostro operatore per Kubernetes in Python. Certo, ha ancora molto da crescere: in futuro potrà gestire più regole, lavorare in più thread, monitorare autonomamente i cambiamenti dei suoi CRD…

Per familiarizzare meglio con il codice, lo abbiamo raccolto in un repository pubblico. Se desideri esempi di operatori più complessi realizzati in Python, puoi prestare attenzione a due operatori per il deployment di mongodb (il primo e il secondo).

P.S. E se hai poca voglia di affrontare eventi Kubernetes o semplicemente preferisci usare Bash, i nostri colleghi hanno preparato una soluzione pronta sotto forma di shell-operator (lo abbiamo annunciato ad aprile).

P.P.S.

Leggete anche nel nostro blog:

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster