PipeCD Operator を実装してみた

commits15 min read読了の目安(約13500字

この記事は Qiita: Kubernetes Advent Calendar 2020 その3 及び NTTコミュニケーションズ Advent Calendar 2020 の 19 日目の記事です。

会社の Advent Calendar での参加ですが仕事でやっていることを話さなければいけないというレギュレーションは特に無かったため、Qiita: Kubernetes Advent Calendar 2020 の 11 日目に出した GitOps を実現する CD ツール、PipeCD が良さそうという話 の記事関連で趣味開発の話をします。

今回の話は実装してみた系 & 実装してみてつまずいたところの備忘録的な記事になります。

導入

先日、 PipeCD について GitOps を実現する CD ツール、PipeCD が良さそうという話 という記事を書きました。

PipeCD は現状最初の stable release を目指すフェーズにおり比較的新しいソフトウェアです。そのため自身の勉強と PipeCD の界隈を盛り上げられればという意味を兼ねて、PipeCD を Kubernetes API から操作できるような CRD + Controller を Kubebuilder を用いて実装してみました。

(前の記事で K8s と密結合でないことを PipeCD の利点に上げときながらなんで Operator で Kubernetes API を噛ませるようにしたんやねんという感じですが自学用途ということで、、)

ソースコードは以下になります。 あくまで自学用途なので動作は保証できません。

デモ

動作確認時のバージョンはそれぞれ以下になります。

  • PipeCD v0.9.0
  • Kubernetes v1.19.1 (kind)

CRD, Controller のインストール

以下のコマンドを実行すると CRD, Namespace (pipecd-system), カスタムコントローラ, カスタムコントローラが Kubernetes の各種リソースにアクセスするための RBAC がそれぞれクラスタに適用されます。

kubectl apply -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/deploy/deploy.yaml

また、以降の作業のために作業用の Namespace を作成します。

kubectl create ns pipecd-demo

Control Plane の構築

まずは PipeCD Control Plane を構築するためのマニフェストを適用します。

kubectl apply -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/secret.yaml
kubectl apply -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_minio.yaml
kubectl apply -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_mongo.yaml
kubectl apply -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_controlplane.yaml

Minio , Mongo リソースはそれぞれ Minio , MongoDB の StatefulSet と Service をデプロイするだけのものです。
FileStore, Data Store に Minio, MongoDB 以外を利用する場合を考慮して、これらのリソースは ControlPlane リソースの生成物に含めず別途カスタムリソースして定義しました。

ControlPlane リソースを適用するとコントローラにより以下が行われます。

  • PipeCD Control Plane の各種コンポーネント [1] 用の Deployment, Service リソースをデプロイ
  • ControlPlane.spec.config 以下の宣言を Server コンポーネント起動時引数として渡される設定ファイルに整形し ConfigMap リソースとして保存
  • JWT の署名アルゴリズム HS256 の共有シークレットを Secret リソースとして保存

Pod を確認すると PipeCD Control Plane の各種コンポーネントがデプロイされていることが確認できます。

$ kubectl get pod -n pipecd-demo
NAME                                           READY   STATUS    RESTARTS   AGE
controlplane-sample-cache-6fbf867d56-wvdpv     1/1     Running   0          28s
controlplane-sample-gateway-75655d6fc4-s67df   1/1     Running   0          28s
controlplane-sample-ops-cf5f5649d-6txk2        1/1     Running   0          28s
controlplane-sample-server-89dc5bf8-xsb29      1/1     Running   2          28s
minio-sample-minio-0                           1/1     Running   0          31s
mongo-sample-mongo-0                           1/1     Running   0          30s

ポートフォワードすることで WebUI へのアクセスも可能です。

$ kubectl port-forward -n pipecd-demo svc/pipecd 8080

Environment の作成

続いて Environment リソースを適用します。

kubectl apply -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_environment.yaml

Environment リソースを適用した後 PipeCD の WebUI を確認すると、Environment が作成されていることが確認できます。

Piped の作成・構築

続いて Piped リソースを適用します。

kubectl apply -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_piped.yaml

Piped リソースを適用すると、コントローラにより以下が行われます。

  • PipeCD の該当 Project, 該当 Environment に紐づく Piped Secret の払い出しリクエスト
  • 上記で払い出した Piped Secret Key を Secert リソースとして保存
  • Piped.spec.config 以下の宣言を Piped コンポーネント起動時引数として渡される設定ファイルに整形し ConfigMap リソースとして保存
  • Piped 用の各種リソースをデプロイ
    • 上記で払い出した Piped Secret をここで生成する Pod に渡すことで、 Piped Pod が該当の Project, Environment に紐づく Piped として登録される

Pod を確認すると PipeCD の Piped コンポーネントがデプロイされていることが確認できます。

$ kubectl get pod -n pipecd-demo
NAME                                           READY   STATUS    RESTARTS   AGE
controlplane-sample-cache-6fbf867d56-wvdpv     1/1     Running   0          39m
controlplane-sample-gateway-75655d6fc4-s67df   1/1     Running   0          39m
controlplane-sample-ops-cf5f5649d-6txk2        1/1     Running   0          39m
controlplane-sample-server-89dc5bf8-xsb29      1/1     Running   2          39m
minio-sample-minio-0                           1/1     Running   0          39m
mongo-sample-mongo-0                           1/1     Running   0          39m
piped-sample-68ff77dcb7-9tz6b                  1/1     Running   0          5m49s

また WebUI では Piped Secret が払い出され、名前 (test-piped) の横にバージョン (v0.9.0) と書かれていることから Pod としてデプロイされた Piped が紐付いていることが確認できます。

TODO

ここまで作成した Environment, Piped を用いて、 WebUI より PipeCD Application を作成することで PipeCD を用いた Continuous Delivery が可能です!

本当は Application もカスタムリソース化したかったのですが時間が足りなくなったので当記事での紹介は断念しました、、誠意実装中です。

お掃除

Kubernetes に適用されているオブジェクトを 順番に 削除します。
順番であることの必要性については 備忘録:オブジェクトの削除処理の順番を制御したい にて後述します。

  • Piped の削除
kubectl delete -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_piped.yaml
  • Environment の削除
kubectl delete -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_environment.yaml
  • ControlPlane の削除
kubectl delete -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_controlplane.yaml
kubectl delete -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_mongo.yaml
kubectl delete -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/pipecd_v1alpha1_minio.yaml
kubectl delete -n pipecd-demo -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/config/samples/secret.yaml
  • CRD, Namespace の削除
kubectl delete -f https://raw.githubusercontent.com/ShotaKitazawa/pipecd-operator/master/deploy/deploy.yaml
kubectl delete ns pipecd-demo

備忘録

以降は pipecd-operator を実装する上でつまずいた点や未解決な点を書き連ねます。

PipeCD の一部パッケージが import error になる

ビルドを実行すると https://github.com/pipe-cd/pipe の一部パッケージに未定義エラーが発生します。

$ go build main.go
# github.com/pipe-cd/pipe/pkg/model
../../../../go/pkg/mod/github.com/pipe-cd/pipe@v0.9.0/pkg/model/application.go:24:9: undefined: ApplicationGitPath
../../../../go/pkg/mod/github.com/pipe-cd/pipe@v0.9.0/pkg/model/application.go:34:9: undefined: ApplicationSyncState
../../../../go/pkg/mod/github.com/pipe-cd/pipe@v0.9.0/pkg/model/application_live_state.go:17:9: undefined: ApplicationLiveStateVersion
... (省略)

これが発生する理由は、 https://github.com/pipe-cd/pipe の上記関数が protobuf ファイルから自動生成される関数であるためです。
PipeCD をビルドする場合は Bazel によるビルドプロセスの一環として protobuf から Golang 用のソースコードが自動生成されます。しかしながら今回のように外部リポジトリから https://github.com/pipe-cd/pipe を参照する場合は上記のような未定義エラーが発生してしまいます。

これの対処として、 protoc を手動実行し Golang のソースコードを生成しました。

  • github.com/pipe-cd/pipe (v0.9.0) のクローンと protoc の実行
cd $HOME
go get -u github.com/pipe-cd/pipe
go get -u github.com/envoyproxy/protoc-gen-validate
cd $GOPATH/src/github.com/pipe-cd/pipe
git switch -d v0.9.0
for i in $(ls ./pkg/model | grep "proto$"); do protoc -I ./ -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate --go_out=plugins=grpc:. ./pkg/model/$i; done
protoc -I ./ -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate --go_out=plugins=grpc:.  pkg/app/api/service/webservice/service.proto
  • 生成した Golang のソースコードを Go Module の管理するディレクトリ以下に移動
sudo mv github.com/pipe-cd/pipe/pkg/model/* $GOPATH/pkg/mod/github.com/pipe-cd/pipe@v0.9.0/pkg/model/
sudo mv github.com/pipe-cd/pipe/pkg/app/api/service/webservice/service.pb.go $GOPATH/pkg/mod/github.com/pipe-cd/pipe@v0.9.0/pkg/app/api/service/webservice/

なおこのビルドエラーに関して、以下の Tweet のスレッドにて開発者の方からコメントを頂いており、将来的に自動生成された Golang のソースコードも commit に含めることを検討してくださっています。

PipeCD に外部サービスが叩くための API がない (PipeCD v0.9.0 以前)

PipeCD v0.9.0 以前において、 PipeCD は外部サービスが叩くための API を提供していません。
pipecd-operator では、gRPC-Web から叩かれる gRPC インタフェース (以下、gRPC WebService インタフェース) を叩くように実装しました。

gRPC WebService インタフェースでは認可に JWT (JSON Web Token) を利用しており、署名アルゴリズムに HS256 を用いています。
pipecd-oprator では、 HS256 の共有シークレットを PipeCD の Server コンポーネントと pipecd-operator の各種コントローラ間で共有することにより強引に認証認可を突破する方法を取りました。クライアントに当たる pipecd-operator にて JWT を生成している部分のソースコードは このあたり です。

なお、PipeCD v0.9.1 以降にて外部サービスが叩くための API が提供され、 API Key により認証認可が行われるそうです。
今回は実験的に gRPC WebService インタフェースを叩くよう実装しましたが、 PipeCD が Stable になるタイミングで pipecd-operator から PipeCD への接続方法も見直すつもりです。

control-plane-config.yaml, piped-config.yaml のスキーマをそのまま CRD にマッピング出来ない

pipecd-operator では control-plane-config.yaml, piped-config.yaml (以下、実設定ファイル) の内容をそれぞれ ControlPlane.spec.config, Piped.spec.config へ記述できるようにしました。

ここで、実設定ファイルには例えば以下の文法があります[2]。この文法は type の値により config のスキーマが異なるため、実設定ファイルのスキーマを単純に Kubernetes CRD とすることが出来ません。

apiVersion: pipecd.dev/v1beta1
kind: ControlPlane
spec:
  datastore:
    type: MONGODB
    config:
      url: mongodb://pipecd-mongodb:27017/quickstart
      database: quickstart

そのため、 pipecd-operator では type ごとにフィールドを分けるようにしました。
例えば上記の設定を pipecd-operator の ControlPlane リソースで宣言する場合は以下のようになります。

apiVersion: pipecd.kanatakita.com/v1alpha1
kind: ControlPlane
spec:
  config:
    datastore:
      mongoDBConfig:
        url: mongodb://pipecd-mongodb:27017/quickstart
        database: quickstart

また上記の ControlPlane.spec.config.datastore の部分の構造体に 当リンク先 のように MarshalJSON() ([]byte, error) メソッドを実装することで、ControlPlane.spec.config を yaml.Marshal するだけで実設定ファイルに変換できるようにしました。

ただ実装してから気付いたこととして、 CRD のスキーマを表す構造体に直接 MarshalJSON() ([]byte, error) メソッドを実装すると以下のようになってしまうためおそらくアンチパターンであると思っています。

  1. ユーザがリソースを適用する
  2. コントローラの Reconciliation Loop が走る。このとき Reconciliation Loop 内で該当オブジェクトの Update を行うと、更新対象のオブジェクトを表す構造体に対し json.Marshal が実行される
  3. 結果 Kubernetes へ以下ようなオブジェクトの適用が走る。当スキーマは CRD のスキーマ定義と異なるため、オブジェクトの Update はエラーする。
apiVersion: pipecd.kanatakita.com/v1alpha1
kind: ControlPlane
spec:
  config:
    datastore:
      ### この部分が invalid
      type: MONGODB
      config:
        url: mongodb://pipecd-mongodb:27017/quickstart
        database: quickstart
      ###

上記問題を回避するために CRD のスキーマを表す構造体に MarshalJSON() ([]byte, error) を直接生やさずに、別途構造体を用意する必要があると思っています。

オブジェクトの削除処理の順番を制御したい

pipecd-operator で作成するオブジェクトには以下のような依存関係があります。

  • ControlPlane オブジェクトを適用し PipeCD Control Plane がデプロイされないと、 Environment, Piped オブジェクトに紐づく外部リソースが作成されない
  • Environment オブジェクトを適用し PipeCD に Environment が作成されないと、Piped オブジェクトに紐づく外部リソースが作成されない

上記より、依存先オブジェクトが存在する限り依存元オブジェクトも削除されないようにしたいです。

これを Finalizers を用い実現しようとすると以下の図のようになります。

pipecd-operator では現状、各オブジェクトの Finalizers の操作に Patch を用いています。
ここで、上記のケースにて Patch で操作しようとすると以下のような問題があります。

  • Merge Patch の場合、更新対象のフィールドが slice なため要素の追加はできるが削除ができない
  • Server-side Apply Patch の場合、同時に複数コントローラが更新対象 Object を Get した後に Patch しようとすると Conflict する (Error from server (Conflict): Apply failed with 1 conflict: ...)

そのため現状の実装は、コントローラは自身の管理するオブジェクト以外の更新 (上図の例だと controller A から object B の更新) をしないようにしています。

削除処理の順番を制御するために、以下のいずれかの解決策を考えています。

  • Server-side Apply Patch を使う。その際、更新対象 Object を Get してから Patch するまで他コントローラが Object を Get 出来ないよう Redis 等でセマフォを用意する。
  • Patch ではなく Update を用い metadata.finalizers フィールドを書き換える

当問題に関して、そもそもコントローラが自身の管理するオブジェクト以外を更新することがお作法的によろしくないのではないかという懸念があるため、もし削除処理の順番を制御できる他の方法を知っている方が居られたら教えていただきたいです。

感想

PipeCD を Kubernetes API から操作できるよう Kubebuilder で Operator を実装しました。

Kuberenetes Operator はこれまでもいくつか利用したことが有りましたが、自分で実際に実装してみて初めて得られる知見が色々ありました。
また Operator から PipeCD へ繋ぎこむ部分は PipeCD の実装を読み仕様を理解しながら実装したため、実際に動作したときの喜びがありとても楽しかったです。

上記で述べたとおりまだ実装途中な部分 (例. PipeCD Application を操作する Controller) や PipeCD の最新版に追従しきれていない部分 (例. API Key を用いた PipeCD との通信) 、この Advent Calendar の締切駆動開発をしてしまったせいで不十分な部分 (パッケージ構成が雑なことやテストが無いこと等) が色々あるので、これからも継続的に実装を進めたい思います。

脚注
  1. https://pipecd.dev/docs/operator-manual/control-plane/architecture-overview/ ↩︎

  2. https://pipecd.dev/docs/operator-manual/control-plane/configuration-reference/#datastorefirestoreconfig ↩︎