先日のKubernetes Operator SDKチュートリアルやってみたの続き。
中身の理解に挑戦したい。
チュートリアルはおおむね淡々とコマンドを実行していくだけのものだったが、各所で何をやっているか、選択肢は何かと言ったことの解説がOperator SDK User Guideにある。
https://github.com/operator-framework/operator-sdk/blob/master/doc/user-guide.md
そも、KubenetesのOperatorとは何ぞやと言う解説にはKubernetesのドキュメント「オペレーターパターン」を参照するのが良いだろう。「どのようにコードを書くかをキャプチャします。」なんて表現ここでしか見かけないが、一応日本語である。
https://kubernetes.io/ja/docs/concepts/extend-kubernetes/operator/
要はKubernetesを機能拡張したい人向けのガイドラインであり、Kubernetes標準のDeploymentが俺は気に入らねーな!という向きには、CRDを使って似たようなものを自分で実装すれば良いじゃない、と言う逃げ道を与える。
Kubernetesのカスタムリソースを読んだ方が分かりやすいかもしれない。曰く「オペレーターパターンは、カスタムリソースとカスタムコントローラーの組み合わせです」である。
https://kubernetes.io/ja/docs/concepts/extend-kubernetes/api-extension/custom-resources/
Operatorの何が重要かと言うとRed Hat OpenShift 4が大々的に採用してなんか有用なんじゃねーかと色んな人がこぞって勉強し始めたから、という事なのだけど、少なくともKubernetesディストリビューションにベンダーごとの差別化ポイントを作りこむには都合の良い仕組みである。と思っている人が多い。多分。
Kubernetesのドキュメントが示す通りOperatorはソフトウェア開発のデザイン・パターンなのであって。。ああ脳みそが痒いが、Operatorという製品なりプロジェクトが存在するわけでは無い。The Operator Frameworkというプロジェクトは存在し、Operator SDKはその主要な一部である。
https://github.com/operator-framework
カスタムリソースを作る
早速先日の続き、「pkg/apis/app/v1alpha1/appservice_types.go」の理解から。
Opeartor SDKの「operator-sdk add api」を実行して、KubernetesのカスタムリソースまたはCRDを作ることが出来る。
コマンド実行すると作られる「pkg/apis/.../..._types.go」(一般に「_types.go」と呼ばれる)に自分好みのパラメーターを追加する。
いじる箇所は以下の2つ、「...Spec」と「...Status」。
// AppServiceSpec defines the desired state of AppService
type AppServiceSpec 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
}
// AppServiceStatus defines the observed state of AppService
type AppServiceStatus 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
}
例えばユーザーガイドに倣えば、int32値の「Size」とstring配列の「Nodes」をパラメーターとして追加することが出来る。
type MemcachedSpec struct {
// Size is the size of the memcached deployment
Size int32 `json:"size"`
}
type MemcachedStatus struct {
// Nodes are the names of the memcached pods
Nodes []string `json:"nodes"`
}
「json:~
」と言うのはGo言語特有で、JSON tagと呼ばれる。「omitempty」を追加すると省略可能値になる。
https://golang.org/pkg/encoding/json/#Marshal
各typeの説明には、Specには「望ましき状態」を記述し、Statusには「観測される値」を記述する。
Specに記述した内容が、CR作成時にユーザーが記述しなきゃいかんものとなるので、一般に少なければ少ないほどいいだろう。パラメーターによるカスタマイズ性がエンタープライズ対応の肝であり多ければ多いほどいいとか、そういう工数肥大策もあるだろうが。
typeの入れ子や、最大文字数等のバリデーション用の情報を付加することも出来る。詳細は以下。
https://book.kubebuilder.io/reference/generating-crd.html
「_types.go」ファイルを修正したら、以下のコマンドを実行してCRDの定義ファイル等を再生成する必要がある。
# operator-sdk generate k8s
# operator-sdk generate crds
(実行例)
[root@ip-172-26-4-124 app-operator]# operator-sdk generate k8s
INFO[0000] Running deepcopy code-generation for Custom Resource group versions: [app:[v1alpha1], ]
INFO[0009] Code-generation complete.
[root@ip-172-26-4-124 app-operator]# operator-sdk generate crds
INFO[0000] Running CRD generator.
INFO[0000] CRD generation complete.
カスタムコントローラーを作る
難しいのはこの部分か。コントローラーのビジネス・ロジックを実装する。
修正するファイルは、チュートリアルでは「operator-sdk add controller」で生成された「pkg/controller/appservice/appservice_controller.go」である。
とりあえず、生成されたコードは長いので「直せ」と明示されているところだけ抜粋すると。。
Watchの追加
「Watch for changes to secondary resource(s)」の所、CRをWatchするのは当然で、SDKもそれをWatchするコードは自動で追加するのだけど、CRの内容を基にして派生するPodとかもWatchを追加する。
...
func add(mgr manager.Manager, r reconcile.Reconciler) error {
...
// TODO(user): Modify this to be the types you create that are owned by the primary resource
// Watch for changes to secondary resource Pods and requeue the owner AppService
err = c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForOwner{
IsController: true,
OwnerType: &appv1alpha1.AppService{},
})
if err != nil {
return err
}
...
}
Watch()とは何ぞや?→KubernetesのControllerインターフェースに含まれるメソッド。
https://godoc.org/github.com/kubernetes-sigs/controller-runtime/pkg/controller
// Watch takes events provided by a Source and uses the EventHandler to
// enqueue reconcile.Requests in response to the events.
//
// Watch may be provided one or more Predicates to filter events before
// they are given to the EventHandler. Events will be passed to the
// EventHandler if all provided Predicates evaluate to true.
Watch(src source.Source, eventhandler handler.EventHandler, predicates ...predicate.Predicate) error
srcで指定されるオブジェクトのイベントをeventhandlerに渡せと、設定している。先の例なら種類がPodのイベントをhandler.EnqueueRequestForOwnerに渡せと、そう指定しているのだ。ろう、きっと。
EnqueueRequestForOwnerは以下で説明されている。
https://godoc.org/sigs.k8s.io/controller-runtime/pkg/handler
エーと、全部のPodイベントを渡されても困るので、OwnerTypeがappv1alpha1.AppServiceであるPod、つまりこのOperatorが生成したPodのみとフィルターしている様だ。
「Type: &corev1.Pod{}」として指定されているTypeは、Podの他には。。たぶん以下にあるものは指定できるんだろうな?
https://godoc.org/k8s.io/client-go/kubernetes/typed
まあいいや、とりあえず今はPodがあれば。
ここまでまとめると、add関数の中で、Operatorが監視する対象のKubernetesオブジェクトと、イベント発生時のハンドラ(後のReconcile)を指定している事が分かった。
Reconcile処理
あるいはイベントハンドラ。
...
// Reconcile reads that state of the cluster for a AppService object and makes changes based on the state read
// and what is in the AppService.Spec
// TODO(user): Modify this Reconcile function to implement your Controller logic. This example creates
// a Pod as an example
// Note:
// The Controller will requeue the Request to be processed again if the returned error is non-nil or
// Result.Requeue is true, otherwise upon completion it will remove the work from the queue.
func (r *ReconcileAppService) Reconcile(request reconcile.Request) (reconcile.Result, error) {
...
入力としてreconcile.Requestが与えられる。どういうオブジェクトかと言うと以下。
https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/reconcile/reconcile.go
簡単なもので、「Namespace」と「Name」の2つのフィールドを持つtype。
出力で返すのはコメントにある通り。errorがnon-nilかreconcile.Result.Requeueがtrueの間このイベントハンドラは何度でも呼び出される。
なお「return reconcile.Result{RequeueAfter: time.Second*5}, nil」という様な終わり方も有り、この例なら5秒後にReconcileの再実行を指定できる。
Operatorのユースケースとしてバックアップを取得するというのがあるが、多分Watchではなくこちらの仕組みを利用して定期的にReconcileを実行しているのだろう。
で、肝心のビジネス・ロジックである。
reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name)
reqLogger.Info("Reconciling AppService")
// Fetch the AppService instance
instance := &appv1alpha1.AppService{}
err := r.client.Get(context.TODO(), request.NamespacedName, instance)
if err != nil {
if errors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
// Owned objects are automatically garbage collected. For additional cleanup logic use finalizers.
// Return and don't requeue
return reconcile.Result{}, nil
}
// Error reading the object - requeue the request.
return reconcile.Result{}, err
}
// Define a new Pod object
pod := newPodForCR(instance)
// Set AppService instance as the owner and controller
if err := controllerutil.SetControllerReference(instance, pod, r.scheme); err != nil {
return reconcile.Result{}, err
}
// Check if this Pod already exists
found := &corev1.Pod{}
err = r.client.Get(context.TODO(), types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace}, found)
if err != nil && errors.IsNotFound(err) {
reqLogger.Info("Creating a new Pod", "Pod.Namespace", pod.Namespace, "Pod.Name", pod.Name)
err = r.client.Create(context.TODO(), pod)
if err != nil {
return reconcile.Result{}, err
}
// Pod created successfully - don't requeue
return reconcile.Result{}, nil
} else if err != nil {
return reconcile.Result{}, err
}
// Pod already exists - don't requeue
reqLogger.Info("Skip reconcile: Pod already exists", "Pod.Namespace", found.Namespace, "Pod.Name", found.Name)
return reconcile.Result{}, nil
}
まず分からないのは、「err := r.client.Get(context.TODO(), request.NamespacedName, instance)」か?
「context.TODO()」は「何もしない」と同義らしい。
で取得される何かはerrに入ってくる訳でもないらしいしドコに来んねんという事だが、まずclientはKubernetesのController Runtime Clientで、Operator SDKのドキュメントに使い方が書いてある。
https://github.com/operator-framework/operator-sdk/blob/master/doc/user/client.md#get
それによればinstanceに結果のオブジェクトが入ってくるという事だが、わっかりづら。Create、Update、Deleteのアクションと形式を揃えたかったというモチベーションの様だが。
次に「pod := newPodForCR(instance)」だが、これはファイルの後ろで定義されている関数で、このOperatorが生成するPodのひな型、Dockerイメージ名とかを指定している。
んで、SetControllerReferenceで自分(Operator)がオブジェクトの親だと指定して、「r.client.Get(context.TODO(), types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace}, found)」で既存のPodが存在していなかったらそのPodをクラスターに作って終了という流れか。
Statusの更新
Operator SDKが自動生成するコードにはStatusを更新する処理が含まれないが、それもReconcileが行うべき処理だろう。
Operator SDKの以下のドキュメントにStatus更新の例は記載されているが、SDK生成コードにStatus更新処理が含まれていないのはバグと呼んで差し支えない。いや、API側が生成時点では空だからしょうがないのか。
https://github.com/operator-framework/operator-sdk/blob/master/doc/user/client.md#updating-status-subresource
Podの削除
そう言えば生成したPodを削除するという処理は追加していないが、サンプルではKubernetesクラスターからCRオブジェクトを削除すると、同じくControllerが生成した子Podも削除される。
これは、Pod生成時に親オブジェクトとしてCRのインスタンスを設定しているからだろう。
真面目には、以下のFinalizer?の実装を検討するしないといけないんだろうなあ?またぞろ良くわからないサンプルは以下にある。
https://github.com/operator-framework/operator-sdk/blob/master/doc/user-guide.md#handle-cleanup-on-deletion
Leader選出問題
例えば、Operator自身のDeploymentをアップデートするときに新旧2つのOperatorインスタンスが同時に実行される場面が生じる。
2つ同時に実行されていても問題ないようにReconciler作るのがベストだが、子Pod作るにしても既存のPodが存在するかチェックした後に作るところからしてAtomicじゃないのでそうもいかない。
リーダー選定どうするかの議論がSDKのUser Guideにはある。
https://github.com/operator-framework/operator-sdk/blob/master/doc/user-guide.md#leader-election
これも見た感じSDKが生成するサンプルには含まれない処理の様だ。
おわりに
なんとなくOperatorの実装解った気分になった。
出来ないことも見えてきたような。例えばWatchで監視できるのはKubernetesオブジェクトに紐づく生成等のイベントのみであるため、それ以外の監視対象(コンテナでexecした実行結果とか)を追加するというのはOperatorのこの仕組みに乗ることは出来ない。
実装するとしたらReconcileのResultにRequeueAfterを設定するなりして、数秒か1分周期で定期的にReconcileを実行する方式にせざるを得ないようだ(?)
実用的なOperatorを作りたかったら、定期的にコンテナにexecするとか、http get送るとか、そういうところの作り込みが必要になるのだろうが、多分Operator SDKにはそういうところのユーティリティが無い?
https://github.com/operator-framework/operator-sdk/blob/master/doc/user-guide.md#adding-3rd-party-resources-to-your-operator
3rdパーティリソースを追加する?いやいやそんなバカらしい。。
Operatorが生成したPodに対してexecをかけるというのは、Kubernetes APIレベルでは「ExecAction v1 core」にアクセスするという事だが。。
https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/
また日を改めて、気が向いたらそのへんチャレンジしてみたい。