Kubernetes Operator em Python sem frameworks e SDK

Kubernetes Operator em Python sem frameworks e SDK

Go é atualmente o único monopolista entre as linguagens de programação que as pessoas escolhem para escrever operadores para o Kubernetes. Existem razões objetivas para isso, como:

  1. Há um poderoso framework para desenvolvimento de operadores em Go — Operator SDK.
  2. Usando Go, foram criados aplicativos que mudaram o jogo, como Docker e Kubernetes. Escrever seu próprio operador em Go é comunicar-se com o ecossistema em uma única linguagem.
  3. Alto desempenho dos aplicativos em Go e ferramentas simples para trabalhar com concorrência "prontas para uso".

NB: A propósito, como escrever seu próprio operador em Go, já descrevemos em uma de nossas traduções de autores estrangeiros.

Mas e se a falta de tempo ou, simplesmente, a falta de motivação, estiver atrapalhando seu aprendizado de Go? O artigo apresenta um exemplo de como escrever um operador de qualidade, usando uma das linguagens mais populares, que praticamente todo engenheiro DevOps conhece — Python.

Conheçam: Copiador — o operador de cópia!

Para este exemplo, vamos considerar o desenvolvimento de um operador simples, projetado para copiar um ConfigMap sempre que um novo namespace aparecer ou sempre que uma das duas entidades — ConfigMap e Secret — for alterada. Em termos práticos, o operador pode ser útil para atualização em massa de configurações de aplicativos (através da atualização do ConfigMap) ou para atualização de dados secretos — por exemplo, chaves para trabalhar com o Docker Registry (ao adicionar um Secret ao namespace).

Então, o que um bom operador deve ter:

  1. A interação com o operador é feita por meio de Custom Resource Definitions (a seguir — CRD).
  2. O operador pode ser configurado. Para isso, usaremos flags de linha de comando e variáveis de ambiente.
  3. A construção do contêiner Docker e do Helm chart é elaborada de forma que os usuários possam facilmente (literalmente com um único comando) instalar o operador em seu cluster Kubernetes.

CRD

Para que o operador saiba quais recursos e onde procurá-los, precisamos definir uma regra para ele. Cada regra será representada como um único objeto CRD. Quais campos devem existir neste CRD?

  1. Tipo de recurso, que vamos procurar (ConfigMap ou Secret).
  2. Lista de namespaces, onde os recursos devem estar.
  3. Selector, pelo qual vamos buscar recursos no namespace.

Descreveremos o 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 logo iremos criar uma regra simples — para busca no namespace com o nome padrão de todos os ConfigMaps com rótulos do tipo copyrator: "true":

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

Pronto! Agora precisamos obter informações sobre nossa regra. Devo esclarecer que não vamos escrever consultas ao API Server do cluster por conta própria. Para isso, usaremos uma biblioteca Python já 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')}

Como resultado da execução deste código, obteremos o seguinte:

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

Ótimo: conseguimos recuperar a regra para o operador. E o mais importante — fizemos isso, por assim dizer, da maneira Kubernetes.

Variáveis de ambiente ou flags? Vamos pegar tudo!

Vamos para a configuração principal do operador. Existem duas abordagens básicas para configurar aplicativos:

  1. usar parâmetros da linha de comando;
  2. usar variáveis de ambiente.

Os parâmetros da linha de comando permitem ler configurações de forma mais flexível, com suporte e validação de tipos de dados. Na biblioteca padrão do Python, existe o módulo argparser, que é o que vamos usar. Detalhes e exemplos de suas funcionalidades estão disponíveis em documentação oficial.

Aqui está como um exemplo de configuração para leitura de flags da linha de comando ficaria no nosso caso:

   parser = ArgumentParser(
        description='Copyrator - operador de cópia.',
        prog='copyrator'
    )
    parser.add_argument(
        '--namespace',
        type=str,
        default=getenv('NAMESPACE', 'default'),
        help='Namespace do Operador'
    )
    parser.add_argument(
        '--rule-name',
        type=str,
        default=getenv('RULE_NAME', 'main-rule'),
        help='Nome do CRD'
    )
    args = parser.parse_args()

Por outro lado, com o uso de variáveis de ambiente no Kubernetes, é fácil transferir informações de serviço sobre o pod para dentro do contêiner. Por exemplo, as informações sobre o namespace em que o pod está em execução podem ser obtidas com a seguinte construção:

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

A lógica de funcionamento do operador

Para entender como dividir os métodos para trabalhar com ConfigMap e Secret, utilizaremos mapas especiais. Assim, poderemos entender quais métodos precisamos para monitorar e criar o objeto:

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

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

Em seguida, precisamos receber eventos do servidor API. Vamos implementar isso da seguinte forma:

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

    # Obtemos o método para monitorar objetos
    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)

Após receber o evento, passamos para a lógica principal de processamento dele:

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

A lógica principal está pronta! Agora precisamos empacotar tudo isso em um pacote Python. Vamos editar o arquivo setup.py, escrevendo as metainformações sobre o projeto:

from sys import version_info

from setuptools import find_packages, setup

if version_info[:2] < (3, 5):
    raise RuntimeError(
        'Versão de python não suportada %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: O cliente kubernetes para Python tem sua própria versão. Mais detalhes sobre a compatibilidade entre versões do cliente e versões do Kubernetes podem ser encontrados na matriz de compatibilidade.

Atualmente, nosso projeto se parece com isto:

copyrator
├── copyrator
│   ├── cli.py # Lógica de trabalho com a linha de comando
│   ├── constant.py # Constantes que mencionamos anteriormente
│   ├── load_crd.py # Lógica de carregamento do CRD
│   └── operator.py # Lógica principal de funcionamento do operador
└── setup.py # Formatação do pacote

Docker e Helm

O Dockerfile será extremamente simples: vamos pegar a imagem base python-alpine e instalar nosso pacote. Vamos adiar a sua otimização para um futuro melhor:

FROM python:3.7.3-alpine3.9

ADD . /app

RUN pip3 install /app

ENTRYPOINT ["copyrator"]

A implantação para o operador também é muito simples:

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

Finalmente, é necessário criar a função correspondente para o operador com os direitos necessários:

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

Resultado

Assim, sem medo, reprovação ou necessidade de aprender Go, conseguimos criar nosso próprio operador para Kubernetes em Python. Claro, ainda há espaço para crescimento: no futuro, ele poderá lidar com várias regras, trabalhar em múltiplas threads e monitorar automaticamente as mudanças em seus CRD…

Para que você possa conhecer melhor o código, o colocamos em um repositório público. Se você está em busca de exemplos de operadores mais complexos implementados com Python, pode prestar atenção em dois operadores para o deploy do mongodb (o primeiro e o segundo).

P.S. E se você está com preguiça de entender os eventos do Kubernetes ou simplesmente prefere usar Bash — nossos colegas prepararam uma solução pronta na forma de shell-operator (nós anunciaram o lançamos em abril).

P.P.S.

Confira também em nosso blog:

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster