Go言語になるべく踏み込まずに、operator-sdkでサンプル以外のOperatorを作りたい

[operator SDKのQuickstart](https://sdk.operatorframework.io/docs/golang/quickstart/)の手順をなぞると、memcached-operatorという名前のシンプルなOperatorを作ることができます。このOperatorはbusyboxが稼働するPodをひとつデプロイしてくれます。
しかしそれ以上のことをしようとすると、memcached-controller.goというファイルのソースコードを修正する必要が出てきて、Go言語に馴染みがない者にとってはハードルが一気に上がってしまいます。
ソースコードを修正する必要があると言っても、デプロイのパターンを固定してしまえばほとんどのコードは使い回せるはずです。そこで、ここではデプロイしたいものをDeploymentとService（type=NodePort）に固定して、任意のコンテナイメージで再利用が可能なテンプレートを用意し、ほぼ機械的にOperatorを組み立てる手順を考えてみました。

紹介する手順は次のとおりです。基本的な流れは[operator SDKのQuickstart](https://sdk.operatorframework.io/docs/golang/quickstart/)の手順とおおよそ同じです。

1. カスタムリソースの仕様を決めて、キーとなる値を環境変数にセットする
2. テンプレートファイルを用意して、operator-sdkの操作を開始する
3. CRD、CRのマニフェスト、およびOperatorのロジックの主要部分の雛形を生成する
4. controllerのソースコードにテンプレートを組み込む
5. controllerのソースコードをカスタマイズする
6. Operatorをビルドして、K8sクラスタにデプロイする
7. カスタムリソースをK8sクラスタにデプロイする
8. カスタムリソースを動的変更する
9. アプリケーションの疎通を確認する

テンプレートの作成には[operator-sdk-samplesのmemcached-operator](https://github.com/operator-framework/operator-sdk-samples/tree/master/go/memcached-operator)を参考にしました。作成したテンプレートは[GitHub](https://github.com/takeyan/tk-operator-sdk)に置いておきました。

手元の検証環境はoperator-sdk v0.18.1, ubuntu 18.04.1LTS, go 1.14.4, kubernetes v1.18.3 (microk8s)を使用しました。

環境変数の与え方によってはうまくいかないケースもあるかもしれませんが、その場合は少しGo言語に踏み込んでみて下さい。この記事が少しでもOperatorの敷居を下げる一助になれば幸いです。

------------

### 1. カスタムリソースの仕様を決めて、キーとなる値を環境変数にセットする

まずOperatorを使ってデプロイするアプリのコンテナイメージを決めます。ここではtakeyan/flask:0.0.3とします。
次に、Operatorのカウンターパートとなるカスタムリソースのマニフェストを次のように定義します。定義するといってもoperator-sdkのサンプルの構造はそのままで、キーワードを置き換えただけです。

```yaml
apiVersion: swallowlab.com/v1alpha1
kind: EchoFlask
metadata:
  name: example-echoflask
spec:
  # Add fields here
  size: 3
```

上記マニフェストのキーワードと、Operatorを使ってデプロイしたいアプリのコンテナイメージ名を、環境変数にセットしておきます。これ以降の手順を進めていくと、operator-sdkが生成する雛形ファイル群の名前やソースコードに、これらの環境変数の値が整合性を保ちながらセットされていくという算段です。


```shell
export IMAGENAME=takeyan/flask:0.0.3
export CLUSTERPORT=5000
export APPNAME=echoflask
export VERSION=v1alpha1
export GROUPNAME=swallowlab
export CRKIND=EchoFlask
```

GROUPNAMEにはswallowlab.comのswallowlabの部分だけ指定しています。controllerのソースコードの中でこの部分だけ使用している箇所がいくつかあったのでそれに合わせました。

### 2. テンプレートファイルを用意して、operator-sdkの操作を開始する

Operator開発プロジェクトの親になるディレクトリを作成し、そこに以下のテンプレートファイルを配置しておきます。

```bash
IMPORT_BLOCK.template 
RECONCILE_BLOCK.template  
WATCH_BLOCK.template
```

テンプレートファイルの配置は、この記事の[GitHubリポジトリ](https://github.com/takeyan/tk-operator-sdk)をgit cloneしてそのまま使っていただくのが簡単です。あるいはgit clone後にテンプレートファイルを任意のディレクトリにコピーして下さい。ディレクトリはGOPATH配下でなくても大丈夫です。

```bash
git clone https://github.com/takeyan/tk-operator-sdk.git
cd tk-operator-sdk
```

テンプレートファイルを配置したら、以下の順にコマンドを実行します。operator-sdk newコマンドは、テンプレートファイルを配置したディレクトリがカレントディレクトリになっている状態で実行して下さい。後の手順では、その前提でテンプレートファイルを相対パスで参照しています。

```bash
export GO111MODULE=on
operator-sdk new $APPNAME --repo=$APPNAME
cd $APPNAME
go mod tidy
```



### 3. CRD、CRのマニフェスト、およびOperatorのロジックの主要部分の雛形を生成する
引き続きoperator-sdkを使って、OperatorのソースコードやCRD、CRのマニフェストファイルを生成していきます。

まずoperator-sdk add apiコマンドを実行し、このとき生成された${APPNAME}_types.goファイルを編集します。ここでは2箇所に1行ずつ追加するだけなので、手修正で済ませました。

```bash
operator-sdk add api --api-version=${GROUPNAME}.com/${VERSION} --kind=$CRKIND
vi pkg/apis/${GROUPNAME}/${VERSION}/${APPNAME}_types.go
```

修正箇所は次の2つの構造体EchoFlascSpecとEchoFlaskStatusです（EchoFlaskの部分は環境変数CRKINDの値がoperator-sdkによってセットされています）。雛形は空でできているので、``` Size int32 `json:"size"` ``` および ``` Nodes []string `json:"nodes"` ``` をそれぞれ挿入します。


```go:${APPNAME}_types.go
// EchoFlaskSpec defines the desired state of EchoFlask
type EchoFlaskSpec struct {
        // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
        // Important: Run "operator-sdk generate k8s" to regenerate code after modifying this file
        // Add custom validation using kubebuilder tags: https://book-v1.book.kubebuilder.io/beyond_basics/generating_crd.html
            Size int32 `json:"size"`       // Deploymentマニフェストのreplicasに反映
}

// EchoFlaskStatus defines the observed state of EchoFlask
type EchoFlaskStatus struct {
        // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
        // Important: Run "operator-sdk generate k8s" to regenerate code after modifying this file
        // Add custom validation using kubebuilder tags: https://book-v1.book.kubebuilder.io/beyond_basics/generating_crd.html
            Nodes []string `json:"nodes"`  // デプロイされたPod名一覧を保持
}

```


${APPNAME}_types.goを編集したら次のコマンドを順次実行していきます。

```bash
operator-sdk generate k8s
operator-sdk generate crds
operator-sdk add controller --api-version=${GROUPNAME}.com/${VERSION} --kind=${CRKIND}
```

operator-sdkによる雛形ファイル生成作業はここで一段落です。



### 4. controllerのソースコードにテンプレートを組み込む

いよいよcontrollerのソースコードを編集します。念の為ファイルのバックアップを取っておいてから先に進めます。エディターはこの手順ではviを使いました。

```bash
cp pkg/controller/${APPNAME}/${APPNAME}_controller.go pkg/controller/${APPNAME}/${APPNAME}_controller.go.bk`date '+%Y-%m-%d_%H:%M:%S'`
vi pkg/controller/${APPNAME}/${APPNAME}_controller.go
```

まずimportの下の"context"（4行目）の下にカーソルを置いて、IMPORT_BLOCK.templateファイルの中身をコピーします。viの操作例を以下に示します。4行目にカーソルを置いて、`:r ../IMPORT_BLOCK.template`を実行しています。
![コメント 2020-06-21 212556.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32083/d580ddc3-e9c0-c546-0034-a3ec4aaa93b2.png)


次にPodをwatchしている部分（以下の59行目～67行目）を置き換えます。
![コメント 2020-06-21 213608.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32083/12d20009-807c-68d6-dbec-5b3ab0b0b8e3.png)


まず59行目～67行目を削除します。`// TODO(user): Modify this to be the types you create ...`が目印です。
次に削除した場所にWATCH_BLOCK.templateファイルの中身をコピーします。viでは59行目にカーソルを置いて`:r ../WATCH_BLOCK.template`を実行するとコピーできます。

最後にReconcile処理のPodを対象にしている部分以降（以下の121行目以降すべて）を置き換えます。
![コメント 2020-06-21 214023.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32083/464b058e-0091-a5b8-c963-a22b4bf42285.png)

まず121行目以降をすべて削除します。`// Define a new Pod object`が目印です。
次に削除した場所にRECONCILE_BLOCK.templateファイルの中身をコピーします。viでは120行目にカーソルを置いて`:r ../RECONCILE_BLOCK.template`を実行するとコピーできます。

編集は以上です。編集内容を保存してエディターを終了します。


### 5. controllerのソースコードをカスタマイズする

controllerに組み込んだテンプレートに含まれている置換前文字列（`__XXXXX__`の部分）を、環境変数の値で置き換えていきます。以下のコマンドを実行したらcontrollerのソースコードは完成です。

```bash
sed -i "s|__GROUPNAME__|${GROUPNAME}|g" pkg/controller/${APPNAME}/${APPNAME}_controller.go
sed -i "s|__VERSION__|${VERSION}|g" pkg/controller/${APPNAME}/${APPNAME}_controller.go
sed -i "s|__CRKIND__|${CRKIND}|g" pkg/controller/${APPNAME}/${APPNAME}_controller.go
sed -i "s|__APPNAME__|${APPNAME}|g" pkg/controller/${APPNAME}/${APPNAME}_controller.go
sed -i "s|__CLUSTERPORT__|${CLUSTERPORT}|g" pkg/controller/${APPNAME}/${APPNAME}_controller.go
```


### 6. Operatorをビルドして、K8sクラスタにデプロイする

Quickstartの手順に従って、まずCRDをKubernetesクラスタにデプロイしておきます。

```bash
kubectl create -f deploy/crds/${GROUPNAME}.com_${APPNAME}s_crd.yaml
```

次にOperatorのコンテナイメージをビルドします。Operatorのコンテナイメージ名はdockerhubのレジストリに登録する前提で、レジストリのURLは省略して命名してあります。

```bash
export OPERATOR_IMAGE=takeyan/${APPNAME}-operator:0.0.1
operator-sdk build $OPERATOR_IMAGE
```

deploy/operator.yamlを修正します。operator-sdkが生成したマニフェストファイルのコンテナイメージ名のところにREPLACE_IMAGEという文字列が入っているので、これを環境変数OPERATOR_IMAGEの値で置き換えます。

```bash
sed -i "s|REPLACE_IMAGE|${OPERATOR_IMAGE}|g" deploy/operator.yaml
```

Operatorのコンテナイメージをレジストリに登録しておきます。dockerhubにログインし、docker pushで登録します。

```bash
docker login
docker push $OPERATOR_IMAGE
```

deployディレクトリ直下の4つのマニフェストファイルをデプロイします。default以外のネームスペースをターゲットとしたい場合は、この時点からネームスペースを指定しておきます。

```bash
kubectl create -f deploy/service_account.yaml [-n namespace名]
kubectl create -f deploy/role.yaml [-n namespace名]
kubectl create -f deploy/role_binding.yaml [-n namespace名]
kubectl create -f deploy/operator.yaml [-n namespace名]
```

### 7. カスタムリソースをK8sクラスタにデプロイする

次のコマンドでカスタムリソースをデプロイします。カスタムリソースがデプロイされると、それをきっかけにアプリのDeploymentとServiceもデプロイされます。

```bash
kubectl apply -f deploy/crds/${GROUPNAME}.com_${VERSION}_${APPNAME}_cr.yaml [-n namespace名]
```

アプリのDeployment, Pod, Serviceがデプロイされていることを確認します。example-\$APPNAME-deploymentという名前のDeploymentとそのDeploymentに従属するPodが3個、example-$APPNAME-svcという名前のSerivce（タイプはNodePort）ができていれば成功です。

```bash
kubectl get all -o wide [-n namespace名]

root@tk-ub18-microk8s:~/echoflask-operator/echoflask# kubectl get all -o wide
NAME                                                READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
pod/echoflask-68b874ccd-996jx                       1/1     Running   0          42s   10.1.94.91   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-fjd5x   1/1     Running   0          25s   10.1.94.92   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-grlp4   1/1     Running   0          24s   10.1.94.93   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-jtc7v   1/1     Running   0          24s   10.1.94.94   tk-ub18-microk8s   <none>           <none>

NAME                            TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE   SELECTOR
service/echoflask-metrics       ClusterIP   10.152.183.120   <none>        8383/TCP,8686/TCP   35s   name=echoflask
service/example-echoflask-svc   NodePort    10.152.183.226   <none>        5000:30523/TCP      24s   app=echoflask,echoflask_cr=example-echoflask
service/kubernetes              ClusterIP   10.152.183.1     <none>        443/TCP             13d   <none>

NAME                                           READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES
 SELECTOR
deployment.apps/echoflask                      1/1     1            1           42s   echoflask    takeyan/echoflask-operator:0.0.1   name=echoflask
deployment.apps/example-echoflask-deployment   3/3     3            3           25s   echoflask    takeyan/flask:0.0.3  app=echoflask,echoflask_cr=example-echoflask

NAME                                                      DESIRED   CURRENT   READY   AGE   CONTAINERS   IMAGES
       SELECTOR
replicaset.apps/echoflask-68b874ccd                       1         1         1       42s   echoflask    takeyan/echoflask-operator:0.0.1   name=echoflask,pod-template-hash=68b874ccd
replicaset.apps/example-echoflask-deployment-65bb9c49c6   3         3         3       25s   echoflask    takeyan/flask:0.0.3        app=echoflask,echoflask_cr=example-echoflask,pod-template-hash=65bb9c49c6
```

カスタムリソースをdescribeしてみます。StatusにPodの一覧が表示されるはずです。


```bash
kubectl get EchoFlask [-n namespace名]
kubectl describe EchoFlask example-echoflask [-n namespace名]

root@tk-ub18-microk8s:~/echoflask-operator/echoflask# kubectl get EchoFlask
NAME                AGE
example-echoflask   4m15s

root@tk-ub18-microk8s:~/echoflask-operator/echoflask# kubectl describe EchoFlask example-echoflask
Name:         example-echoflask
Namespace:    default
Labels:       <none>
Annotations:  API Version:  swallowlab.com/v1alpha1
Kind:         EchoFlask
Metadata:
  Creation Timestamp:  2020-06-21T13:42:45Z
  Generation:          1
  Managed Fields:
  （中略）
Spec:
  Size:  3
Status:
  Nodes:
    example-echoflask-deployment-65bb9c49c6-fjd5x
    example-echoflask-deployment-65bb9c49c6-grlp4
    example-echoflask-deployment-65bb9c49c6-jtc7v
Events:  <none>
```

### 8. カスタムリソースを動的に変更する

Operator echoflask-operatorが作成するDeploymentのReplicasの値（=Pod数）は、カスタムリソース定義のsizeの値を反映しています。この値を動的に変更して、それがDeploymentに動的に反映されることを確認します。

カスタムリソースexample-echoflaskを編集します。

```bash
kubectl edit EchoFlask example-echoflask [-n namespace名]
```

![コメント 2020-06-21 225805.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32083/d472c695-4f65-9c39-8a60-652b7b5559ad.png)

変更を保存したら、アプリのDeploymentやPodに反映されたことを確認します。sizeの値のとおりにPodの数が変わっていれば成功です。

```bash
kubectl get all -o wide [-n namespace名]
kubectl describe EchoFlask example-echoflask [-n namespace名]


root@tk-ub18-microk8s:~/echoflask-operator/echoflask# kubectl get all -o wide
NAME                                                READY   STATUS    RESTARTS   AGE    IP           NODE               NOMINATED NODE   READINESS GATES
pod/echoflask-68b874ccd-996jx                       1/1     Running   0          18m    10.1.94.91   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-fjd5x   1/1     Running   0          18m    10.1.94.92   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-fx6ns   1/1     Running   0          115s   10.1.94.95   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-grlp4   1/1     Running   0          18m    10.1.94.93   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-jtc7v   1/1     Running   0          18m    10.1.94.94   tk-ub18-microk8s   <none>           <none>
pod/example-echoflask-deployment-65bb9c49c6-lzd8m   1/1     Running   0          115s   10.1.94.96   tk-ub18-microk8s   <none>           <none>

NAME                            TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE   SELECTOR
service/echoflask-metrics       ClusterIP   10.152.183.120   <none>        8383/TCP,8686/TCP   18m   name=echoflask
service/example-echoflask-svc   NodePort    10.152.183.226   <none>        5000:30523/TCP      18m   app=echoflask,echoflask_cr=example-echoflask
service/kubernetes              ClusterIP   10.152.183.1     <none>        443/TCP             13d   <none>

NAME                                           READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES                             SELECTOR
deployment.apps/echoflask                      1/1     1            1           18m   echoflask    takeyan/echoflask-operator:0.0.1   name=echoflask
deployment.apps/example-echoflask-deployment   5/5     5            5           18m   echoflask    takeyan/flask:0.0.3                app=echoflask,echoflask_cr=example-echoflask

NAME                                                      DESIRED   CURRENT   READY   AGE   CONTAINERS   IMAGES                             SELECTOR
replicaset.apps/echoflask-68b874ccd                       1         1         1       18m   echoflask    takeyan/echoflask-operator:0.0.1   name=echoflask,pod-template-hash=68b874ccd
replicaset.apps/example-echoflask-deployment-65bb9c49c6   5         5         5       18m   echoflask    takeyan/flask:0.0.3                app=echoflask,echoflask_cr=example-echoflask,pod-template-hash=65bb9c49c6


root@tk-ub18-microk8s:~/echoflask-operator/echoflask# kubectl describe EchoFlask example-echoflask
Name:         example-echoflask
Namespace:    default
Labels:       <none>
Annotations:  API Version:  swallowlab.com/v1alpha1
Kind:         EchoFlask
Metadata:
  Creation Timestamp:  2020-06-21T13:42:45Z
  Generation:          2
  Managed Fields:
（中略）
Spec:
  Size:  5
Status:
  Nodes:
    example-echoflask-deployment-65bb9c49c6-fjd5x
    example-echoflask-deployment-65bb9c49c6-grlp4
    example-echoflask-deployment-65bb9c49c6-jtc7v
    example-echoflask-deployment-65bb9c49c6-fx6ns
    example-echoflask-deployment-65bb9c49c6-lzd8m
Events:  <none>
```


### 9. アプリケーションの疎通を確認する

最後にアプリの疎通も確認しておきます。[takeyan/flask:0.0.3](https://github.com/takeyan/echo_flask)は、/api/echoをhttp GETするとアプリ実行環境のノード名、Pod名、Pod IPアドレス等を含むJSONフォーマットのメッセージを返すように作ってあります。繰り返し実行すると負荷分散される様子が観察できます。

```bash
curl localhost:{NodePortのポート番号}/api/echo?Hello=World!

root@tk-ub18-microk8s:~/echoflask-operator/echoflask# curl localhost:30523/api/echo?Hello=World!
{"api":"echo","nodename":"tk-ub18-microk8s","pod_ip":"10.1.94.93","podname":"example-echoflask-deployment-65bb9c49c6-grlp4","query_string":["Hello=World!"],"timestamp":"2020-06-21T14:03:35.389553"}
root@tk-ub18-microk8s:~/echoflask-operator/echoflask# curl localhost:30523/api/echo?Hello=World!
{"api":"echo","nodename":"tk-ub18-microk8s","pod_ip":"10.1.94.94","podname":"example-echoflask-deployment-65bb9c49c6-jtc7v","query_string":["Hello=World!"],"timestamp":"2020-06-21T14:03:37.354148"}
root@tk-ub18-microk8s:~/echoflask-operator/echoflask# curl localhost:30523/api/echo?Hello=World!
{"api":"echo","nodename":"tk-ub18-microk8s","pod_ip":"10.1.94.92","podname":"example-echoflask-deployment-65bb9c49c6-fjd5x","query_string":["Hello=World!"],"timestamp":"2020-06-21T14:03:38.517778"}
root@tk-ub18-microk8s:~/echoflask-operator/echoflask# curl localhost:30523/api/echo?Hello=World!
{"api":"echo","nodename":"tk-ub18-microk8s","pod_ip":"10.1.94.93","podname":"example-echoflask-deployment-65bb9c49c6-grlp4","query_string":["Hello=World!"],"timestamp":"2020-06-21T14:03:39.713882"}
root@tk-ub18-microk8s:~/echoflask-operator/echoflask# curl localhost:30523/api/echo?Hello=World!
{"api":"echo","nodename":"tk-ub18-microk8s","pod_ip":"10.1.94.93","podname":"example-echoflask-deployment-65bb9c49c6-grlp4","query_string":["Hello=World!"],"timestamp":"2020-06-21T14:03:41.128680"}
```

以上です。