CRDのない超簡単Kubernetes Operator（Python Operator Framework編）

# はじめに
本稿は、KubernetesのOperator初学者のために、Operatorのコントローラの部分だけを簡単に書いてみて、Operatorが何をしているかを明らかにします。

ここではPythonとkubernetesモジュール（kubernetes Python SDK）に加えて、Python用のOperator Framework（Kopf: Kubernetes Operators Framework）を用いて、Operatorとして動作する簡単なプログラムを書きます。これは「CRDのない超簡単Kubernetes Operator」の第3弾であり、第2弾の続編です。まだ読んでいない方は第1弾のシェルスクリプト編と第2弾のPython編から読むことをお勧めします。

シェルスクリプト編では、この一連の記事の目的なども説明しています。

https://qiita.com/shimenawa_design/items/85681d5ce8787937f52f

https://qiita.com/shimenawa_design/items/c7ba1ef51702531ad01c

このPython Operator Framework編では、Python編で作成したOperatorを、Operator Framework（Kopf）を使用して再度作成します。Operator Frameworkが、Operatorの作成者に対してどのような利便性を提供するのか、その導入部となることを意図しています。

# 目標

本稿と、関連する一連の記事では以下を目標とします。取り消し線の項目は、本稿ではなく別記事で説明します。

- ~~Kubernetes Operatorをざっくり理解する~~
- ~~Python SDKを使用してOperatorのロジックを作成する~~
- Python Operator SDK (kopf）を使用してOperatorを作成する

繰り返しになりますがここではOperatorのコントローラが実現するロジックやコーディングを中心に説明し、CRDについては触れません。また作成したロジックをOperatorとして動作させるための周辺作業については必要最小限の説明とさせてください。


# 必要なもの
## 前提とする知識

Kubernetesの一般的なオブジェクト（Deployment, Pod）と内容とその関係について理解している必要があります。

また、プログラムを読んでわかる程度のPythonの知識を必要とします。

少なくとも[Python編](https://qiita.com/shimenawa_design/items/c7ba1ef51702531ad01c)の内容に目を通しておくことをお勧めします。コードの半分以上を再利用することになりますが、重複した内容は本稿では説明しません。

## 用意する環境

シェルスクリプト編、Python編と同様に、見てわかることを目標にしているため、必要な環境は特にありません。

もちろん、実際に動かした方が理解が進むはずなので、何らかのKubernetesクラスターを用意していただくのが良いです。本稿の例を作成する際にOpenShift Localを使用しました。動かすのであれば、Pythonの実行環境も必要なります。

PythonでKubernetes APIにアクセスするために、Kubernetes Python clientを使用します。これはGitHubでも提供されていますが、kubernetesパッケージとしてPyPIで提供されているので、pipコマンドでインストールできます。

```
$ pip install -y kubernetes
```

APIのドキュメントは、同じくGitHubで提供されています。

https://github.com/kubernetes-client/python/blob/master/kubernetes/README.md

それに加えて、Kopfも必要となります。これもPyPIで提供されているため、pipコマンドでインストールできます。

```
$ pip install -y kopf
```

ドキュメントはRead the Docsで公開されています。
https://kopf.readthedocs.io/en/stable/


# 今回のお題

ここは、Python編と全く同じです。

この記事の基本方針はCRDを用意しないことであるため、既存のKubernetesリソースであるDeploymentを利用する単純なDeploymentコントローラを作成します。Kubernetesには組み込みでDeploymentコントローラが動作しており、Deploymentの記述に従い、Podが作成されます。ここではその動作と並行で別のPodを起動するOperatorを作成します。

1. 指定したnamespace（ここではsimple-deploymentとする）に存在するDeploymentリソースに対して、同じ名前のPodを作成する。
1. コンテナは何でも構わない（今回は、適当に無限ループするシェルスクリプトとする）。
1. Deploymentリソースが削除されたら、Podも削除される。

標準的なDeploymentコントローラのサブセットであり、これをsimple-deployment Operatorと名付けます。

そのため、1つのDeploymentに対して、標準的なDeploymentが起動するPodと、simple-deployment operatorが起動するPodの2種類のPodが起動することになります。

# simple-deployment operator

## control loop

Python編のコントロールループを振り返ってみましょう。こんな感じでした。

```Python
def control_loop() -> None:
    while True:
        deployment_list = get_deployment_list('simple-deployment')

        for item in deployment_list:
            deployment_name = item.metadata.name
            namespace       = item.metadata.namespace

            # Reconciliation
            pod_name = deployment_name
            if (find_pod(pod_name, namespace) == None):
                start_pod(make_pod(pod_name, deployment_name), namespace)
        sleep(10)
```

Kopfを使用すると、これを次のように書けます。

```Python
import kopf

@kopf.daemon('deployment')
def monitor_deployment(stopped, logger, body, **kwargs):

    deployment_name = body.metadata.name
    pod_name        = body.metadata.name
    namespace       = body.metadata.namespace

    while not stopped:
        if (find_pod(pod_name, namespace) == None):
            start_pod(make_pod(pod_name, deployment_name), namespace)
        stopped.wait(10)
```

Deploymentと同じ名前を持つPodを``find_pod``で探し、存在しなければ``make_pod``で新しく作成し``start_pod``で起動するのは、Python編のコードと同じです。それぞれの関数もほぼPython編と同じです。

違いは、``@kopf.daemon``でデコレートされた関数``monitor_deployment``の定義が中心となります。この後は、Python編との違いとして次の4点を説明します。

- Daemonデコレータ
- Daemonの関数とその動き
- Daemonの停止方法
- ownerReferenceの設定

## Daemonデコレータ
Kopfでは、一つのリソースを監視し、それぞれのリソースに対して長時間処理を継続するものをDaemonと呼びます。

```Python
@kopf.daemon('deployment')
```
``@kopf.daemon('deployment')``デコレータは、``deployment``に対するDaemonを作成します。Daemonの実態はデコレート対象の関数です。ここでは``monitor_deployment``が相当します。Daemonはリソースそれぞれに対して生成されます。つまりこの例ではクラスターにDeploymentが100個あると、DaemonはそれぞれのDeployment毎に100個作られます。

Python編のコードでは無限ループの中にDeploymentを探すコードが入っていましたが、ここでは監視対象のリソースはFrameworkが探してきて、それぞれのリソースに対してDaemonを起動する点が異なります。

実はこのままだと、全namespaceのDeploymentが対象となります。お題である指定したnamespace、つまり``simple-deployment`` namespaceに限定するにはどうすれば良いのでしょうか。これは、デコレータの[field filter](https://kopf.readthedocs.io/en/stable/filters/#field-filters)を使用します。

```Python
@kopf.daemon('deployment', field='metadata.namespace', value='simple-deployment')
```

これで、このDaemonの処理対象は``metadata.namespace``の値が``simple-deployment``であるDeploymentリソースに限定されます。

デコレート対象の関数は、通常のPython関数を使用したSync Daemonと、async関数を使用したAsync Daemonの2種類がありますが、本稿では、Syncの方のみを扱います。

## Daemonの関数とその動き
``kopf.deamon``がデコレートする関数のシグニチャーを見てみましょう。

```Python
def monitor_deployment(stopped, logger, body, **kwargs):
```
``kwargs``と``logger``は今回使用しないので説明を省略します。

### ``body``パラメータ
``body``は処理対象のリソースを表します。このコードではDeploymentのnameとnamespaceの値を次のように取得しています。

```Python
deployment_name = body.metadata.name
namespace       = body.metadata.namespace
```

### ``stopped``パラメータ
Deamonの動作は``stopped``パラメータで制御され、この値がFalseの間だけwaitしながら動作するようコードを作成します。つまり、Deamonの骨格は次のようになります。

```Python
    while not stopped:
        stopped.wait(10)
```
Python編のコントローラは無限ループを持っていましたが、Operator Frameworkではこのコードがその無限ループに相当します。この中でPodの存在確認と、存在しない場合の起動を行います。

``time.sleep``関数ではなく、``stopped``の``wait``メソッドを使用する理由は、``wait``メソッドの場合は、``stopped``の値が変更したことを検知すると指定した時間待つことなく、メソッドから抜けるからです。パラメータの10は10秒を表します。

## Deamonの停止方法
Daemonの動作を停止するには、関数からリターンします。通常は、監視対象のリソースが削除されると、Frameworkがそれを検知しして``stopped``の値を反転させるため、上記のwhileループから抜けてDaemonが終了します。

なお、Daemonが監視しているリソースを削除すると、削除が完了するまで約60秒ほどかかります。これはDaemonが完全に停止するまでは監視対象のリソースに対して削除防止のために以下の``finalizer``が設定されるためです。

```yaml
finalizers:
- kopf.zalando.org/KopfFinalizerMarker
```

これを制御するためには、``@kopf.daemon``デコレータに、``cancellation_timeout``を設定します。
```Python
@kopf.daemon('deployment', field='metadata.namespace', value='simple-deployment', cancellation_timeout=10)
```

## ownerReferenceの設定

Python編のコードでは、リソースのガベージコレクションのために、``make_pod``関数で作成するPodリソースの``ownerReference``に、Deploymentのuidをはじめとする情報を設定したのを覚えているでしょうか。Kopfを使用すると、その設定は``adopt``関数を呼び出すだけでframeworkが設定を行ってくれます。

```Python
def make_pod(name:str, deployment_name:str) -> V1Pod:
    """
    Create a pod controled by the deployment
    """
    pod = make_prototype_pod()

...途中省略...

    # set the deployment as its owner for garbage collection of the deployment
    kopf.adopt(pod)

    return pod
```

これにより、Daemonの管理対象のリソース（ここではDeployment）の情報がPodのownerReferenceに設定されます。

# Operator Frameworkの利点
Python編の最後に書いたように、Operator Frameworkを使用するとOperatorが行う処理を簡単に記述するためのサポートが得られます。ガベージコレクションのための``ownerReference``の設定もその一つです。

KopfはDaemonだけでなくEventを持ち、そのHandlerを設定できます。詳細は省略しますが、例えばリソースの作成（create）、削除（delete）や変更（update）を検知し、定義されているHandlerを呼び出します。詳しくは、[マニュアル](https://kopf.readthedocs.io/en/stable/handlers/)を参照してください。これを使えば、リソースの一部が変更された際に行うアクションを定義できます。

# コード

最後に、コード全体を示します。

```python
import kopf
import logging

from kubernetes import client, config, utils
from kubernetes.client.rest import ApiException
from kubernetes.client import V1Pod, V1ObjectMeta, V1PodSpec, V1Deployment

config.load_kube_config("./kubeconfig")

@kopf.daemon('deployment', field='metadata.namespace', value='simple-deployment',
             cancellation_timeout=5)
def monitor_deployment(stopped, logger, body, **kwargs):
    logger.info(f"A handler is called with body: {body}")

    logger.info(f"Deploymnet name = {body.metadata.name}, {body.metadata.namespace}")

    deployment_name = body.metadata.name
    pod_name = body.metadata.name
    namespace = body.metadata.namespace

    while not stopped:
        if (find_pod(pod_name, namespace) == None):
            logging.info(f"pod {pod_name} is not found, create a new one")
            start_pod(make_pod(pod_name, deployment_name), namespace)
        stopped.wait(10)

    logging.info(f"Deamon for {deployment_name} is terminated.")


def find_pod(name:str, namespace:str) -> V1Pod:
    """     
    get specified pod
    """
    api = client.CoreV1Api()
            
    try:
        response = api.read_namespaced_pod(name, namespace)
    except ApiException as e:
        logging.error(f"Exception when calling CoreV1Api->read_namespaced_pod: {e}")
        return None
        
    return response


def make_prototype_pod() -> V1Pod:
    """
    Create a generic V1Pod data
    """
    metadata = V1ObjectMeta()
    spec = V1PodSpec(containers=[])

    # pod construction
    pod = V1Pod(metadata=metadata, spec=spec)

    return pod


def make_pod(name:str, deployment_name:str) -> V1Pod:
    """
    Create a pod controled by the deployment
    """
    pod = make_prototype_pod()
    pod.metadata.name = name
    pod.spec.containers = [{
                        "command": [
                            "bash", "-c", f"while true ; do echo {name} ; sleep 1 ; done"
                        ],
                        "image": "ubi8",
                        "name": "echo",
                    }]

    pod.metadata.labels = { "app": name }

    # set the deployment as its owner for garbage collection of the deployment
    kopf.adopt(pod)

    #logging.info(f"create pod is {pod}")
    return pod


def start_pod(pod:V1Pod, namespace:str) -> None:
    """
    instanceate the pod
    """
    api = client.CoreV1Api()

    try:
        response = api.create_namespaced_pod(namespace, pod)
    except ApiException as e:
        logging.error(f"Exception when calling CoreV1Api->create_namespaced_pod: {e}")
```

# Operatorの実行
Kopfで作成したコードは、Pythonインタープリタで直接実行するのではなく、``kopf run``コマンドで起動します。例えば、コードのファイルが``simple-deployment.py``ファイルであった場合、このOperatorを起動するには、次のコマンドを実行します。``kopf``コマンドは、``pip install kopf``によりインストールされているはずです。

```bash
$ kopf run simple-deployment.py
```

Kubernetes上でこのコードをOperatorとして起動するには、これを行うコンテナや、必要な権限を持たせたserviceaccountなどが必要となりますが、前述したようにそこは省略します。


# まとめ
Python Operator Frameworkを使用して、Deploymentリソースを参照する``simple-deployment`` Operatorを作成しました。

この``simple-deployment``は簡単なOperatorであるため、Pythonを使用してロジックを書き下しても（Python編）、Frameworkを使用しても（Python Operator Framework編）でも大きな違いは感じられないかもしれません。恐らくOperatorの処理が複雑であれば複雑であるほど、こうしたFrameworkの利点は大きくなるはずです。実際、下記の記事で紹介した、MySQL Operator for Kubernetesは、MySQLのInnoDBコンテナのクラスターを構成し、その障害時の回復処理まで実装している複雑なOperatorですが、Kopfを使用して開発されています。

https://qiita.com/shimenawa_design/items/f196e1341fc228d66994