Kubernetes Custom Resource for GCS on GKE

# はじめに

[Kubebuilder](https://book.kubebuilder.io/)では、リソースを独自定義する事ができます。

GCSの論理名を定義すれば、それをリソースとして宣言し、勝手に作成したり、削除したりしてくれないかな？という事を検証すること、またKubebuilderを学ぶために作ってみる事を目的としています。

GKEのリソースとして、 論理的な`example`というGCSのリソースを定義すれば、勝手にその物理的なバケット(`example-xxxxxx`)も作成したりしてくれる・・・これがあると、k8sのnamaepsace毎、プロジェクト毎に同一のアプリケーションを構築するのがとても楽になるなと思った次第です。開発環境、ITb環境、ステージング環境・・・これが全て宣言的なコンポーンネントの管理が行えるようになり、CDがとてもしやすくなります。

なぜ、論理名と物理名を分けているか？と言えば、バケットは全GCPプロジェクトでユニークなバケット名の必要があり、「どの環境もこの名前で作成する」というのができないからです。ユニークにするのは、誰かが勝手に制御して欲しいわけです。

後、GCPでは [Service Broker](https://cloud.google.com/kubernetes-engine/docs/concepts/google-cloud-platform-service-broker)があります。通常の人はこちらを使った方が幸せになれます。

# 最初に学んだこと

これを、読むだけ。
https://book.kubebuilder.io/

# 出来上がったもの

初めてgolangで色々ソースを書きましたが本当に勉強になりました。。。

https://github.com/h-r-k-matsumoto/gcs-crd
https://cloud.docker.com/u/hirokimatsumoto/repository/docker/hirokimatsumoto/gcs-crd

## 説明

### リポジトリの初期化

[book.kubebuilder.io/quick-start.html](https://book.kubebuilder.io/quick-start.html) にある通り、下記コマンドを実行しました。 
`{my.domain}` は、自身の管理化にあるドメインを指定した方が良いのでしょう。

```shell:init
$ kubebuilder init --domain {my.domain}
↓ #実際に私が入力したコマンド
$ kubebuilder init --domain matsumo.dev
```

次にControllerを作成するための雛形を作成します。

```shell:create
$ kubebuilder create api --group {group} --version {version} --kind {kind}
↓ #実際に私が入力したコマンド
$ kubebuilder create api --group storage --version v1 --kind gcs

```

ここらへんの詳細は、
　[KubebuilderでKubernetesのカスタムコントローラを作ってみる](https://qiita.com/TakanariKo/items/379aff2467bb8d50728d)
をご確認した方が良いです。

### ソースの修正

Kubebuilderで作る上では、ほとんどのケースでは、CRD (Custom Resource Definitions)の仕様となる　`xxxx_types.go` と、そのリソースのイベントを受信し、制御する `xxxx_controller.go` を修正するだけで事足ります。
今回私が入力した情報だと、 [gcs_types.go](https://github.com/h-r-k-matsumoto/gcs-crd/blob/master/pkg/apis/storage/v1alpha1/gcs_types.go)と[gcs_controller.go](https://github.com/h-r-k-matsumoto/gcs-crd/blob/master/pkg/controller/gcs/gcs_controller.go)が対象となります。

#### gcs_types.go

GCSのバケットを扱うために、下記情報が必要となります。

- 論理的なバケット名
- 物理的なバケット名
- バケットのプロジェクトID

人が宣言する情報は、`Spec` として仕様を記載します。論理的なバケット名を`BucketName`、プロジェクトIDを`ProjectID`として定義しました。

```golang:gcs_types.go
type GcsSpec struct {
	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
	// Important: Run "make" to regenerate code after modifying this file
	ProjectID  string `json:"projectID"`
	BucketName string `json:"bucketName"`
}
```

人が原則管理しない、Controller側で管理すべき情報を、 `Status`側に記載しています。
こちらにのみ、物理名を表す `BucketFullName`を追加で定義しています。

```golang:gcs_types.go
type GcsStatus struct {
	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
	// Important: Run "make" to regenerate code after modifying this file
	ProjectID      string `json:"projectID"`
	BucketName     string `json:"bucketName"`
	BucketFullName string `json:"bucketFullName"`
}
```

#### gcs_controller.go

雛形だと、余計なdeploymentsとの連携が入っているのでまずそれを全て削除します。
また、それに付随し、アノテーションも色々書かれているので削除します。
アノテーションは、

```golang:gcs_controller.go
// +kubebuilder:rbac:groups=storage.matsumo.dev,resources=gcs,verbs=get;list;watch;create;update;patch;delete
```

のように書かれているものです。主にRBAC周りが多いです。

そして実際のビジネスロジックを記載していくわけですが、変更するのは、Reconcileメソッドが中心となります。

下記の部分で、まずは、gcsオブジェクトの情報を取得しに行きます。取れなかったエラーです。

```golang:gcs_controller.go
	// Fetch the Gcs instance
	instance := &storagev1alpha1.Gcs{}

	err = r.Get(context.TODO(), request.NamespacedName, instance)
	if err != nil {
		if errors.IsNotFound(err) {
			// Object not found, return.  Created objects are automatically garbage collected.
			// For additional cleanup logic use finalizers.
			return reconcile.Result{}, nil
		}
		// Error reading the object - requeue the request.
		return reconcile.Result{}, err
	}
```

次に、リソースが削除されており、且つ後述するバケットを削除するためのFinalizerが存在しており、バケットが問題なく削除された場合は、Finalizerをクリアして更新します。
リソースが削除されている場合は、このif文のブロックで処理は終了します。

```golang:gcs_controller.go
	myFinalizerName := "bucket.finalizer.gcs.storage.matsumo.dev"

	if !instance.ObjectMeta.DeletionTimestamp.IsZero() {
		log.Info("delete object.")
		// The object is being deleted
		if containsString(instance.ObjectMeta.Finalizers, myFinalizerName) {
			// our finalizer is present, so lets handle our external dependency
			if err := r.deleteExternalDependency(instance); err != nil {
				return reconcile.Result{}, err
			}

			// remove our finalizer from the list and update it.
			instance.ObjectMeta.Finalizers = removeString(instance.ObjectMeta.Finalizers, myFinalizerName)
			if err := r.Update(context.Background(), instance); err != nil {
				return reconcile.Result{}, err
			}
		}
		// if deleted, logic finished.
		return reconcile.Result{}, nil
	}
```

リソースが、まだ削除されていないのであれば、削除された時にバケットを削除するためのFinalizerを登録します。
また、実際にバケットが作成されていない場合は、バケットの物理的名称を生成してセットします。

```golang:gcs_controller.go
	// update finalizer,and bucket name.
	modified := false
	if !containsString(instance.ObjectMeta.Finalizers, myFinalizerName) {
		instance.ObjectMeta.Finalizers = append(instance.ObjectMeta.Finalizers, myFinalizerName)
		modified = true
	}
	if instance.Spec.BucketName != instance.Status.BucketName {
		instance.Status.BucketName = instance.Spec.BucketName
		instance.Status.BucketFullName = GenerateBacketFullName(instance.Spec.BucketName)
		modified = true
	}
```

manifest情報が更新されていたら、実際に更新を行います。

```golang:gcs_controller.go
	if modified {
		if err := r.Update(context.Background(), instance); err != nil {
			return reconcile.Result{}, err
		}
	}
```

最後に、GCSのバケットが作成されていなかったら、作成を行います。
実際のGCSの操作は、[gcs_operater.go](https://github.com/h-r-k-matsumoto/gcs-crd/blob/master/pkg/controller/gcs/gcs_operater.go)ファイルとして独自に作成しています。

```golang:gcs_controller.go
	client, err := NewGcsClient(context.Background())
	if err != nil {
		return reconcile.Result{}, err
	}

	//bucket operation.
	exists := IfExistsBucket(context.Background(), client, instance.Spec.ProjectID, instance.Status.BucketFullName)
	if !exists {
		log.Info(fmt.Sprintf("create bucket[ %s ]", instance.Status.BucketFullName))
		err := CreateBucket(context.Background(), client, instance.Spec.ProjectID, instance.Status.BucketFullName)
		if err != nil {
			return reconcile.Result{}, err
		}
	}
```

#### 動作確認

[README.md](https://github.com/h-r-k-matsumoto/gcs-crd/blob/master/README.md)のように動かせば動くと思います。

# ハマったところ

## make deployでエラー

この問題はもうすぐ対応されるでしょう。とりあえずディレクトリの配置を変えれば動きます。

https://github.com/kubernetes-sigs/kubebuilder/issues/654

```
$ make deploy
go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all
CRD manifests generated under '/Users/hiroki-matsumoto/Dev/go/src/github.com/h-r-k-matsumoto/gcs-crd/config/crds' 
RBAC manifests generated under '/Users/hiroki-matsumoto/Dev/go/src/github.com/h-r-k-matsumoto/gcs-crd/config/rbac' 
kubectl apply -f config/crds
customresourcedefinition.apiextensions.k8s.io/gcs.storage.matsumo.dev created
kustomize build config/default | kubectl apply -f -
Error: rawResources failed to read Resources: Load from path ../rbac/rbac_role.yaml failed: security; file '../rbac/rbac_role.yaml' is not in or below '/Users/hiroki-matsumoto/Dev/go/src/github.com/h-r-k-matsumoto/gcs-crd/config/default'

error: no objects passed to apply
make: *** [deploy] Error 1
$ 
```

## GKEで動かすとエラーになる。

`x509: failed to load system roots and no roots provided`とエラーになります。

```
{"level":"error","ts":1557927559.5866375,"logger":"kubebuilder.controller","msg":"Reconciler error","controller":"gcs-controller","request":"default/gcs-sample","error":"Post https://www.googleapis.com/storage/v1/b?alt=json&prettyPrint=false&project=xxxxxx: x509: failed to load system roots and no roots provided","stacktrace":"github.com/h-r-k-matsumoto/gcs-crd/vendor/github.com/go-logr/zapr.(*zapLogger).Error\n\t/go/src/github.com/h-r-k-matsumoto/gcs-crd/vendor/github.com/go-logr/zapr/zapr.go:128\ngithub.com/h-r-k-matsumoto/gcs-crd/vendor/sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).processNextWorkItem\n\t/go/src/github.com/h-r-k-matsumoto/gcs-crd/vendor/sigs.k8s.io/controller-runtime/pkg/internal/controller/controller.go:217\ngithub.com/h-r-k-matsumoto/gcs-crd/vendor/sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Start.func1\n\t/go/src/github.com/h-r-k-matsumoto/gcs-crd/vendor/sigs.k8s.io/controller-runtime/pkg/internal/controller/controller.go:158\ngithub.com/h-r-k-matsumoto/gcs-crd/vendor/k8s.io/apimachinery/pkg/util/wait.JitterUntil.func1\n\t/go/src/github.com/h-r-k-matsumoto/gcs-crd/vendor/k8s.io/apimachinery/pkg/util/wait/wait.go:133\ngithub.com/h-r-k-matsumoto/gcs-crd/vendor/k8s.io/apimachinery/pkg/util/wait.JitterUntil\n\t/go/src/github.com/h-r-k-matsumoto/gcs-crd/vendor/k8s.io/apimachinery/pkg/util/wait/wait.go:134\ngithub.com/h-r-k-matsumoto/gcs-crd/vendor/k8s.io/apimachinery/pkg/util/wait.Until\n\t/go/src/github.com/h-r-k-matsumoto/gcs-crd/vendor/k8s.io/apimachinery/pkg/util/wait/wait.go:88"}
```

動いているノードから証明書情報を持って来ましょう。[manager.yaml](https://github.com/h-r-k-matsumoto/gcs-crd/blob/master/config/default/manager/manager.yaml)にマウント情報を追加しました。

```yaml:manager.yaml
        volumeMounts:
 <<省略>>
        - mountPath: /etc/ssl/certs
          name: ssl-certs
 <<省略>>
      volumes:
 <<省略>>
      - name: ssl-certs
        hostPath:
          path: /etc/ssl/certs
```

## 独自のサービスアカウントでbucket操作したい

ServiceAccountをGOOGLE_APPLICATION_CREDENTIALSの環境変数を指定して使うように[manager.yaml](https://github.com/h-r-k-matsumoto/gcs-crd/blob/master/config/default/manager/manager.yaml)を変更しました。

`/opt/google/certs/secret.json`から読み込むようにしました。

```yaml:manager.yaml
        env:
          - name: GOOGLE_APPLICATION_CREDENTIALS
            value: /opt/google/certs/secret.json
```

次にvolumeMounts、volumesを指定します。secret`gcs-service-account`を指定しました。

```yaml
        volumeMounts:
        - mountPath: /opt/google/certs
          name: google-apps-credential
      volumes:
      - name: google-apps-credential
        secret:
          secretName: gcs-service-account
```

最後に、secret`gcs-service-account`を定義します

```
apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: gcs-service-account
data:
  secret.json: <<change your service account credential file>>
```

# その他 (TODO )

## テストに関して

頑張ります。まだあまり理解してません・・・。

## アプリケーション側から、どう扱うか？

やはりアプリケーション側からは、実際のBucketの名前でアクセスしなくてはなりません。

1. Serviceのようにproxyを挟む
2. podのinitで環境変数、設定を埋め込む
3. 設定は・・・アプリケーション側のmanifestで定義して手動なりなんなりで書き換える。

という形での実現になりそうです。1、もしくは2.じゃないと管理がめんどくさいですね。
もっと簡単にできればいいんですけど・・・。

## まだ想定外の処理がいまいち

- ~~削除処理にてすでにバケットが存在しなかったら無限ループするなぁ・・・とか思ってます。
そのうち直そうと思います。~~ [修正完了 commit messageが・・・](https://github.com/h-r-k-matsumoto/gcs-crd/commit/1b372437eb551553c2f9e764d36192d5c3c7ef3c)。
- ~~作成処理もいまいち。バケット名がconflictした場合、 `Status`の情報をクリアしてループさせるか何かしないとこれも無限ループするのかな？と思ってます。(試して直せよ・・・という話ですけど )~~ [対応完了](https://github.com/h-r-k-matsumoto/gcs-crd/commit/e9135b889bfcde7e75b570d83bcf4bc7badbc325)


# 参考

- https://book.kubebuilder.io/
- [KubebuilderでKubernetesのカスタムコントローラを作ってみる](https://qiita.com/TakanariKo/items/379aff2467bb8d50728d)
- https://github.com/kubernetes-sigs/kubebuilder/issues/654
- https://github.com/h-r-k-matsumoto/gcs-crd