KubernetesOperator に入門してみた

## はじめに

本記事は [ラクスパートナーズアドベントカレンダー 2023 2日目](https://qiita.com/advent-calendar/2023/rakus-partners) の投稿です。

数年前から話題だったようですが、私は最近プロジェクトで触れる機会があり、遅ればせながら KubernetesOperator に入門したので個人的な備忘もかねて投稿します。
私はバリバリインフラ系のため、Go については完全素人でしたが後述の operator-sdk があったおかげで何とか作り上げることができました。ありがたや。。

## そもそも KubernetesOperator とは
ざっくり簡単に言うと
「Kubernetes リソースの作成を自動化してくれるプログラム」です。

CustomResource をアプライすることで Cluster 内に起動した KubernetesOperator がそれを検知し
KubernetesOperator 内で定義してある Deployment や NameSpace 等のリソースを自動でクラスターに起動してくれます。

[Operatorhub](https://operatorhub.io/) で様々なプロダクトが開発している KubernetesOperator があるので使用したことがある人も多いと思います。（PrometheusOperator 等。。）

↓ かなりざっくりとした動作イメージ。。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549173/786a3b51-cbdd-ad77-b613-ce48846e6c6d.png)

1. ユーザーが CustomResource をアプライする。
1. KubernetesOperator が CustomResource のアプライを検知する。
1. KubernetesOperator が各リソースをアプライする。

詳しくは [RedHat 様の記事](https://www.redhat.com/ja/topics/containers/what-is-a-kubernetes-operator) を読んでいただくとより理解していただけると思います。

KubernetesOperator の作成方法はいろいろあるようですが、今回は上記 Redhat 様の記事で紹介されている 「operator-sdk」を使用して KubernetesOperator を作成していきます。

## operator-sdk とは

KubernetesOperator の開発、管理を容易にする SDK で、
Go, Ansible, Helm のいずれかを使用した開発に対応しています。

今回はプロジェクトで触れた Go での開発を記載いたします。

※ 筆者は KubernetesOperator で初めて Go に触れましたがそれでも operator-sdk を使うことでかなり簡単に開発をすることができました。

## 実際に作成してみる

ここから実際に簡単な内容で KubernetesOperator を作成していきます。
淡々と作成していくので都度 [operator-sdk のドキュメント](https://sdk.operatorframework.io/docs/installation/#install-from-github-release) を読んで補完していただくとより分かりやすいかもしれません。
(ただの手順書になってしまった。。)

### 〇 環境情報

プラットフォーム：GCP GKE
KubernetesVersion：1.27.5-gke.200
KubectlVersion：v1.27.5
Go Version：1.19.13
(operator-sdk の前提条件で Go の 1.19.~ が必要です。)

### 1 operator-sdk のインストール

[operator-sdk のドキュメント](https://sdk.operatorframework.io/docs/installation/#install-from-github-release) を確認して環境に合ったインストール方法を選択します。
今回は Git リポジトリからクローンしてインストールします。

* リポジトリをクローン
  ```bash
  $ git clone https://github.com/operator-framework/operator-sdk
  ```

* クローンしたリポジトリに移動する
  ```bash
  $ cd operator-sdk
  ```

* make コマンドを使用してインストールする  
  少し時間かかります。。
  ```bash
  $ make install
  ```

### 2 operator プロジェクトの作成

operator プロジェクトを作成して各種必要な資材を作成します。

* プロジェクト作成
  `--domain` や `--repo` は実在するものでなくても問題ありません。
  ```bash
  $ operator-sdk init --domain demo.com --repo github.com/example/demo-operator
  ```

* Operator Api の作成
  `--kind` で指定した文字列が CustomResource の kind になります。
  ```bash
  $ operator-sdk create api --group demo --version v1alpha1 --kind Demo --resource --controller
  ```

### 3 CustomResourceDefinition の作成

前手順で作成した資材の中から、CustomResourceDefinition の設定にかかわる部分から修正していきます。

* ~types.go の修正
  `<kind>Spec` の構造体に必要な値を追加する
  
  ```bash
  $ vim api/v1alpha1/demo_types.go
  ```
  (修正イメージ)
  `DemoSpec` 等の文字列は前段の `create api` で指定した `--kind` によって変わりますので適宜読み替えてください。
  ※ デフォルトのコメントアウトは消しています。
  ```diff
  type DemoSpec struct {
  -   Foo string `json:"foo,omitempty"`

  // CustomResource の manifest の値を読み取るために、json:"キー名" を紐づける。
  // omitempty を付けないと必須の値となり、マニフェストで指定していなければアプライ時にエラーが出力される。
  +   Namespace string `json:"namespace"`
  +   Name string `json:"name"`
  +   Image string `json:"image"`
  +   Size int32 `json:"size"`
  }
  ```

* CustomResourceDefinition マニフェストの作成

  `~types.go` 修正後に以下コマンドを実行すると
  `~types.go` の内容に合った CustomResource のアプライに必要なファイルを作成/修正してくれるため修正後は必ず実行します。
  ```bash
  $ make generate
  ```
  ```bash
  $ make manifests
  ```

### 4 Reconcile に処理を追加

KubernetesOperator のメイン処理 (deployment, namespace を作ったり) を記載する部分なので、開発作業の大半はこのファイルをいじることになります。

* ~controller.go の修正
  ```bash
  $ vi controllers/demo_controller.go
  ```
  ↓ の Reconcile メソッドにメインの処理を追記します。(もちろん別の関数にしたり、別の pkg から呼び出したりもできますが今回は簡単に Reconcile メソッドのみ修正します。。)
  ```go
  func (r *DemoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, 
  error) {
          _ = log.FromContext(ctx)

          // TODO(user): your logic here

          return ctrl.Result{}, nil
  }
  ```
  (修正イメージ)
  ```go
  func (r *DemoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
          // 要所要所でログを出力したいので logger を定義
          logger := log.FromContext(ctx)

          // CustomResourceDefinition がなければ処理を行わない 
          cr := &demov1alpha1.Demo{}
          err := r.Get(ctx, req.NamespacedName, cr)
          if errors.IsNotFound(err) {
                  logger.Info("Demo Resource Not Found")
                  return ctrl.Result{}, nil
          } else if err != nil {
          // 取得自体が失敗したらエラーを出力
                  logger.Error(err, "Failed to Get Demo Resource")
          }
          // それっぽいログを出力
          logger.Info("Create Namespace")
          // Namespace 作成用の構造体を定義
          // 他のリソースを作成したい場合は Go のドキュメントを参照ください。
          ns := &corev1.Namespace{
                  ObjectMeta: metav1.ObjectMeta{
                          Name: cr.Spec.Namespace,
                  },
          }
          // すでに対象リソースがあれば処理を行わない
          err = r.Get(ctx, client.ObjectKey{Name: cr.Spec.Namespace}, ns)
          if errors.IsNotFound(err) {
          // 対象リソースがなければここで作成
                  if err := r.Client.Create(ctx, ns); err != nil {
                          logger.Error(err, "Failed to Create Namespace")
                  }
          } else if err != nil {
                  logger.Error(err, "Failed to Get Namespace")
          }

          logger.Info("Namespace creation successfully")
          logger.Info("Create Deployment")
          // Deployment 作成用の構造体を定義
          deploy := &appsv1.Deployment{
                  ObjectMeta: metav1.ObjectMeta{
                          Name:      cr.Spec.Name,
                          Namespace: cr.Spec.Namespace,
                  },
                  Spec: appsv1.DeploymentSpec{
                          Replicas: &cr.Spec.Size,
                          Selector: &metav1.LabelSelector{
                                  MatchLabels: map[string]string{"demo": "true"},
                          },
                          Template: corev1.PodTemplateSpec{
                                  ObjectMeta: metav1.ObjectMeta{
                                          Name:   cr.Spec.Name + "-pod",
                                          Labels: map[string]string{"demo": "true"},
                                  },
                                  Spec: corev1.PodSpec{
                                          Containers: []corev1.Container{
                                                  {
                                                          Name:  cr.Spec.Name + "-container",
                                                          Image: cr.Spec.Image,
                                                  },
                                          },
                                  },
                          },
                  },
          }
          // Namespace と同じく対象リソースがすでにあれば作成しない
          err = r.Get(ctx, client.ObjectKey{Name: cr.Spec.Name, Namespace: cr.Spec.Namespace}, deploy)
          if errors.IsNotFound(err) {
                  if err := r.Client.Create(ctx, deploy); err != nil {
                          logger.Error(err, "Failed to Create Deployment")
                  }
          } else if err != nil {
                  logger.Error(err, "Failed to Get Deployment")
          }
          // 最後にまたそれっぽいログを出力して処理完了
          logger.Info("Deployment creation successfully")

          return ctrl.Result{}, nil
  }
  ```
  

* 静的チェック
  (go vet でも同様のチェックができるのでお好きなほうで。。)
　何も出力されなければ OK
  ```bash
  $ make vet
  ```
  
### 5 動作確認

#### ローカルでオペレーターを起動

* CustomResourceDefinition のアプライ
  ```bash
  $ make install
  ```

* KubernetesOperator のアプライ
  `Starting workers` と出力されていれば問題なく起動できている。
  ```bash
  $ make run
  ```
  (出力例)
  ```bash
  ~~ snip ~~
  2023-12-01T13:51:56Z    INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
  2023-12-01T13:51:56Z    INFO    setup   starting manager
  2023-12-01T13:51:56Z    INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
  2023-12-01T13:51:56Z    INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
  2023-12-01T13:51:56Z    INFO    Starting EventSource    {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo", "source": "kind source: *v1alpha1.Demo"}
  2023-12-01T13:51:56Z    INFO    Starting Controller     {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo"}
  2023-12-01T13:51:56Z    INFO    Starting workers        {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo", "worker count": 1}  
  ~~ snip ~~
  ```

* CustomResource のアプライ
  operator-sdk で用意してくれているサンプルマニフェストを少し修正し Operator の CustomResource をアプライする。
  `make run` を実行しているターミナルとは別のターミナルを開いて行う。
  ```bash
  $ vim config/samples/demo_v1alpha1_demo.yaml
  ```
  (修正イメージ)
  ~types.go で指定したキーを入れていく。
  ```diff
  apiVersion: demo.demo.com/v1alpha1
  kind: Demo
  metadata:
    labels:
      app.kubernetes.io/name: demo
      app.kubernetes.io/instance: demo-sample
      app.kubernetes.io/part-of: demo-operator
      app.kubernetes.io/managed-by: kustomize
      app.kubernetes.io/created-by: demo-operator
    name: demo-sample
  spec:
    # TODO(user): Add fields here
  +   namespace: demo-namespace
  +   name: demo-deployment
  +   image: nginx
  +   size: 3
  ```
  ```bash
  $ kubectl apply -f config/samples/demo_v1alpha1_demo.yaml
  ```
  CustomResource をアプライすると Reconcile メソッドに追加したそれっぽいログが出力される。
  ```bash
  2023-12-01T14:23:49Z    INFO    Create Namespace        {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo", "Demo": {"name":"demo-sample","namespace":"default"}, "namespace": "default", "name": "demo-sample", "reconcileID": "9ae44549-3b16-4087-bd42-e1d5feaae1e2"}
  2023-12-01T14:23:50Z    INFO    Namespace creation successfully {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo", "Demo": {"name":"demo-sample","namespace":"default"}, "namespace": "default", "name": "demo-sample", "reconcileID": "9ae44549-3b16-4087-bd42-e1d5feaae1e2"}
  2023-12-01T14:23:50Z    INFO    Create Deployment       {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo", "Demo": {"name":"demo-sample","namespace":"default"}, "namespace": "default", "name": "demo-sample", "reconcileID": "9ae44549-3b16-4087-bd42-e1d5feaae1e2"}
  2023-12-01T14:23:50Z    INFO    Deployment creation successfully        {"controller": "demo", "controllerGroup": "demo.demo.com", "controllerKind": "Demo", "Demo": {"name":"demo-sample","namespace":"default"}, "namespace": "default", "name": "demo-sample", "reconcileID": "9ae44549-3b16-4087-bd42-e1d5feaae1e2"}
  ```
  さらに定義した Namespace と Deployment も問題なくアプライされている。
  ```bash
  $ kubectl get ns
  NAME              STATUS   AGE
  default           Active   2d10h
  demo              Active   24h
  demo-namespace    Active   2m5s
  gmp-public        Active   2d10h
  gmp-system        Active   2d10h
  kube-node-lease   Active   2d10h
  kube-public       Active   2d10h
  kube-system       Active   2d10h

  $ kubectl get all -n demo
  NAME                       READY   STATUS    RESTARTS   AGE
  pod/demo-c9fd6db65-kxg64   1/1     Running   0          23h
  pod/demo-c9fd6db65-vlhl5   1/1     Running   0          23h
  pod/demo-c9fd6db65-z6scj   1/1     Running   0          23h

  NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
  deployment.apps/demo   3/3     3            3           24h

  NAME                             DESIRED   CURRENT   READY   AGE
  replicaset.apps/demo-c9fd6db65   3         3         3       24h
  ```

* 動作確認まで完了したので最後にイメージを作成してクラスター内で起動する。

  イメージをビルドする前にもうひと手間加えます。
  Reconciler 構造体の下にあるこの部分。
  ```go
  //+kubebuilder:rbac:groups=demo.demo.com,resources=demoes,verbs=get;list;watch;create;update;patch;delete 
  //+kubebuilder:rbac:groups=demo.demo.com,resources=demoes/status,verbs=get;update;patch
  //+kubebuilder:rbac:groups=demo.demo.com,resources=demoes/finalizers,verbs=update
  ```
  初見時はぱっと見コメントだと思いましたが、ここに rbac の設定を記載しておけばビルド時に rbac のリソースをよしなに作ってくれます。便利！
  今回作成したいのは Namespace と Deployment なのでこんな感じで追記。
  ```go
   //+kubebuilder:rbac:groups=demo.demo.com,resources=demoes,verbs=get;list;watch;create;update;patch;delete
  //+kubebuilder:rbac:groups=demo.demo.com,resources=demoes/status,verbs=get;update;patch
  //+kubebuilder:rbac:groups=demo.demo.com,resources=demoes/finalizers,verbs=update
  //+kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch;create;update;patch
  //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch
  ```
  
  追記したら `make` コマンドを使ってイメージをビルドする。
  ```bash
  $ make docker-build
  ```
  ビルドしたら適当なレジストリにプッシュする。
  その後以下のコマンドを実行して、オペレーターをクラスター内に起動する。
  ```bash
  $ make deploy IMG=<IMAGEPATH>:<TAG>
  ```
  
  ```bash
  $ kubectl get ns
  NAME                   STATUS   AGE
  default                Active   2d12h
  ↓ ここにオペレーターのリソースが入っている。
  demo-operator-system   Active   22m
  gmp-public             Active   2d12h
  gmp-system             Active   2d12h
  kube-node-lease        Active   2d12h
  kube-public            Active   2d12h
  kube-system            Active   2d12h
  $ kubectl get all -n demo-operator-system
  NAME                                                    READY   STATUS    RESTARTS   AGE
  pod/demo-operator-controller-manager-68fc6f6cc8-v6znr   2/2     Running   0          23m

  NAME                                                       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
  service/demo-operator-controller-manager-metrics-service   ClusterIP   10.60.4.79   <none>        8443/TCP   23m

  NAME                                               READY   UP-TO-DATE   AVAILABLE   AGE
  deployment.apps/demo-operator-controller-manager   1/1     1            1           23m

  NAME                                                          DESIRED   CURRENT   READY   AGE
  replicaset.apps/demo-operator-controller-manager-68fc6f6cc8   1         1         1       23m
  ```
  再度 CustomResource をアプライする。
  ```bash
  $ kubectl apply -f config/samples/demo_v1alpha1_demo.yaml
  ```
  ```bash
  $ kubectl get ns
  NAME                   STATUS   AGE
  default                Active   2d13h
  demo-namespace         Active   51s
  demo-operator-system   Active   60s
  gmp-public             Active   2d13h
  gmp-system             Active   2d13h
  kube-node-lease        Active   2d13h
  kube-public            Active   2d13h
  kube-system            Active   2d13h
  $ kubectl get all -n demo-namespace
  NAME                                   READY   STATUS    RESTARTS   AGE
  pod/demo-deployment-6db7ddf7d9-7px8s   1/1     Running   0          62s
  pod/demo-deployment-6db7ddf7d9-7v8kx   1/1     Running   0          62s
  pod/demo-deployment-6db7ddf7d9-xrd2s   1/1     Running   0          62s

  NAME                              READY   UP-TO-DATE   AVAILABLE   AGE
  deployment.apps/demo-deployment   3/3     3            3           62s

  NAME                                         DESIRED   CURRENT   READY   AGE
  replicaset.apps/demo-deployment-6db7ddf7d9   3         3         3       62s
  ```

  問題なく作成できているのでヨシ！！

## 以上！！

簡潔な内容ですが以上です。

正直 KubernetesOperator が刺さる要件ってそこまで多くない気はしていますが
私がまだまだ Go と仲良くなれていないため、KubernetesOperator の良さを引き出せていない。というのもあるので今後も修行し続ければもっと良い使い方が見つかるかも。。

とりあえず Kubernetes を頻繁に触っている方や、"自動化" が大好物な方はかなり楽しいので触ってみることをお勧めします。

本当は CustomResource の削除時に自動で削除とか、エコシステムのクライアント使ってリソース作成とかいろいろ書こうと思っていましたが長くなってしまうのでまたそのうち書きます。。

## 参考文献

https://sdk.operatorframework.io/docs/building-operators/golang/tutorial/

https://access.redhat.com/documentation/ja-jp/openshift_container_platform/4.6/html/operators/osdk-golang