CRDのない超簡単Kubernetes Operator（シェルスクリプト編）

# はじめに
Kubernetes Operatorは、ご存知のようにKubernetesの拡張手段として提供されるデザインパターンです。カスタムリソースとコントローラによりKubernetes APIを拡張し、その他のリソースと同じようにKubernetes APIとkubectlコマンドを使用して制御できます。

## 本稿を書くに至った動機
Operatorの利便性は非常に高く、Kubernetesへシームレスに適合するため今では多数のOperatorが開発されています。一方で、CRDの定義やgo言語による開発 [^1] が必要である点など、ある種の高いハードルを感じさせるのもまた事実です。

[^1]: go言語以外のoperator sdkは多数提供されています。

operator-SDKのチュートリアルであるmemcached-operatorを手順通りに動かしてみても、コードの修正を行なってみても、なかなか理解に至らなかった人は多いのではないでしょうか。私もその一人です。

いろいろ勉強するうちに、memcached-operator機能も、operator-SDKの痒い所に手が届く仕組みも理解が進みましたが、知識ゼロのところからmemcachd-operatorチュートリアルに橋渡しする何かが必要だと感じ、いくつか記事を書くことにしました。これはその1つです。

私はOperatorの最大の特徴がCRDであることを認める一方で、Operatorの使用や理解を妨げているのもCRDであると考えています。そこで、ここではカスタムリソースについては極力触れず、コントローラの機能に注目して説明を行います。

:::note
**Kubernetes Operatorについてのちょっとした不満**
Kubernetes APIとしてシームレスに組み込むために、カスタムリソースは cluster resourceになります。クラスター管理者がCRDをインストールし、開発ユーザがそれをinstance化する役割分担なわけですが、開発ユーザによる自由なOperatorインストールを阻害していると思います。

また、Operatorを作成する際に必要となるCRDの定義もハードルが高い要素の1つです。operator-SDKでは、go言語の構造体からCRDを生成するツールを提供していますが、「やっぱりgo言語使わないといけない」点にイラッとしてしまいます。
:::

# 目標

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

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

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

# 必要なもの

## 前提とする知識
知識ゼロとは言え、「kubernetesって何？」からだと大変なので、kubernetesの操作はある程度できる、とさせてください。例題として扱う問題の性質上、クラスターを運用管理する駆け出しエンジニア程度の知識があれば、なお良いです。

## 必要な環境
ここで作成するものは、「まず見てわかる」を目指したので、コードを動かす環境は必要としていません（動かすことを想定していない）。

実際には、自由に使えるKubernetesクラスターがあると良いです。取り上げる問題（ノード障害時の運用）の性質上、minikubeやOpenShift Localは不十分です。


# 再考：Operatorとは

OperatorとはKubernetesドキュメント「オペレーターパターン[^2]」の冒頭にあるように、運用の知識をコード化するためのものです。

> オペレーターパターンはサービス、またはサービス群を管理している運用担当者の主な目的をキャプチャすることが目標です。

そのための仕組みをカスタムリソースとコントローラで実装するのがKubernetes Operatorです。ここではカスタムリソースやコントローラから説明するアプローチではなく、「運用知識のコード化」を出発点に説明していきます。

[^2]:https://kubernetes.io/ja/docs/concepts/extend-kubernetes/operator/

そんなに難しい話ではありません。インフラエンジニアなら普通に行なっていることを、Operatorっぽく説明するだけです。

# StatefulSet Podの強制削除Operator
## 解決すべき問題

StatefulSetはPodの順序と一意性を保証するKubernetesリソースです。Podがユニークであることを保証するために、ノード障害が発生した場合、Deploymentのように別のノードでPodが自動的に起動することはありません。ノードがクラスターから見えなくなった原因が単なるネットワークの切断であり、どこかでPodが稼働し続けているのか、それともノードが停止してPodも消滅したのかが判断できないからです。Podはノードが復帰するまで、もしくは強制的に削除されるまでTerminatingのままとなります。このTerminating Podが存在し続ける限り、新たなStatefulSet Podは起動しません。

ノードが障害で停止した場合、DeploymentのようにStatefulSet Podも自動で別のノードに移動させたいとします。問題を簡単にするために、次の2点を前提とします。

- Podはいつでも問題なくdeleteできる
- ノードにNoExecuteテイントがついていたらノード障害と判断できる

:::note warn
一般的にStatefulSet Podは外部リソースの使用など難しい制約を持つ場合が少なくなく、停止には慎重な判断が必要です。これは放置しておいても修復されない問題の一例として取り上げたものであり、プロダクション環境でこのような自動化を行う際には十分な配慮が必要です。
:::

StatefulPodの強制削除は、[Force Delete StatefulSet Pods](https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/)に書かれているように、`--force`オプション付きの`kubectl delete`コマンドを実行します。


## 一般的な解法
普通、次のようなシェルスクリプトを書くのではないでしょうか。まず分かりやすくするために疑似コードで書いてみます。

```
無限ループ {
  if Terminating StateのPodがある {
    if そのPodが稼働しているノードが障害で停止している {
      そのPodを強制削除する
    }
  }
}
```

実際のシェルスクリプトではこんな感じになるでしょうか。


```forcedelete.sh
#!/bin/sh
NAMESPACE=application

while True
do
  kubectl get pod -o wide -n $NAMESPACE | grep Terminating | awk '{print $1, $7'} | while read terminating
  do
    appl=$(echo $terminating | awk '{print $1}')
    node=$(echo $terminating | awk '{print $2}')

    kubectl get node $node -n $NAMESPACE -o jsonpath="{.spec.taints[*].effect}" | grep NoExecute > /dev/null
    if [ $? -eq 0 ]; then
      echo $node is NoExecute, $appl must be deleted
      kubectl delete pod $appl --force -n $NAMESPACE
    fi
    
  done

  sleep 10
done
```

特殊なことは何もありません。運用作業として行われるであろう

- Terminatingが継続しているPodがあるぞ
- なんでTerminatingなんだ？
- ノード障害が原因か。Podを停止して別のノードに移動させるか

という手順をストレートにコード化しただけです。実際にはノード障害の判定をもう少し慎重にするだとか、いきなりdeleteするのではなくて、この状況がしばらく継続していたらとか、さまざまな配慮を行うでしょうが本質は変わりません。


## Kubernetes Operator化

基本的にはOperator化するにあたり必要なことはありません。このシェルスクリプトがそのままOperatorです（暴論）。

:::note
もちろん、このシェルスクリプトをKubernetes Operatorとして稼働させるには、少なくとも
- コンテナ化して、Deployment Podにする
- Podのdeleteを行う権限を持つserviceaccountと、それを使用するkubeconfigを作成して、シェルスクリプトに同梱する（もしくはSecretとして供給する）

など、やるべきことは多々あるのですが、本稿はOperatorの理解に焦点を当てているので、そうした周辺作業は取り上げない（サボる）ことにします。

実際のところ、このシェルスクリプトを起動するには、`kubectl login`した状態でターミナルから実行しています。
:::

このシェルスクリプトを抽象化すると、次のような形になります。

```
無限ループ {
    状態を確認する
    if 不都合があり、希望する状態から乖離している {
        不都合を修正して、希望の状態にする
    }
}
```

この中で、不都合を修正する作業をreconcileと呼び、これを繰り返し行うループをreconcile-loopと呼びます。例題のシェルスクリプトでは、Podの削除を行いました。コントローラが繰り返し行うループなので、control-loopとも呼ばれます。

状態はカスタムリソースで管理されることが多いのですが、カスタムリソースで表現されている必要は必ずしもありません。ここでは、「障害中のノード上にTerminating STATUSのPodが存在しないこと」が希望する状態であり、既存リソースであるPodのSTATUSとNodeのtaintを利用しています。

同様に、例えばDeplymentリソースが存在した場合、そこに定義されたPodが稼働していなければPodを起動するのがDeplymentコントローラーです。そこではDeplymentが（カスタムではないけれども）リソースであり、DeploymentリソースとDeploymentコントローラがDeployment Operatorを構成することで、Kubernetesの一般的な処理をこのデザインパターンで説明できます。

# おわりに

StatefulSet Podがノード障害時にTerminatingのままになる現象に対して、運用で行うであろう処理をシェルスクリプトのOperatorとして表現することで、Operatorの基本的な構造とその役割を説明しました。

CRDとか、reconcile-loopとか、desired-stateとか、小難しい説明がいろいろ出てきて初学者は面食らうKubernetes Operatorですが、基本的にはそれほど難しいものではありません。一般的にインフラ運用で（シェルスクリプトなどとして）作成するツールと考え方としては同じことを、本稿では示せたと思います。

operator-SDKを使用して作成するOperatorもこの構造を踏襲します。ツールで生成した雛形に対して、状態の確認と状態の修正を行うコードを追加するわけです。memcached-operatorチュートリアルに挫折した方は、この構造を理解した上で再度チャレンジすることをお勧めします[^3]。

[^3]:CRDの生成という理解の難しいセクションがあるが、少なくともgo言語のコード修正が何を目的としているかは理解できるはず。

実際のプロダクション環境で使用するOperatorは、ここで例示したようなkubectlコマンドの出力をテキスト処理するような杜撰な作り方では、対応できないようなものもあるはずです。この続きとして、PythonとPython kubernetes client[^4]を使用してKubernetes APIを介してクラスターを操作するOperatorについて説明したいと思います。

[^4]:PythonのOperator SDKである[kopf](https://github.com/nolar/kopf)ではありません。それはまたいずれ。