OperatorSDKでCustom ResourceとCustom Controllerを作る
はじめに
k8sではいくつかの拡張ポイントが用意されています。その中でも、APIをカスタムするためのCustom Resourceや調整ループによってリソースオブジェクトの状態管理を行い、宣言的なAPIを可能にするCustom Controllerがあります。
まえまえからこの2つには興味がありつつも触れることができてなかったのですが、先日k8sのブログに「Writing a Controller for Pod Labels」というのを見かけて、OperatorSDKを使えばいい感じにできそうであるということに気がついたので試してみたいと思います。
OperatorSDKとは
そもそもOperatorとはなんぞやという部分なのですが、 Custom Resourceを使ってアプリケーションとそのコンポーネントを管理するソフトウェアの拡張で、OperatorそのものはControllerパターンに則ってリソースオブジェクトを管理します。
OperatorSDKはその名の通りでOperatorを作る際のSDKです。
Operatorを作る際の高次元なAPIを提供していたり、テスト、ビルド、パッケージングのサポートをしていたりします。
また、Operatorやカスタムリソースを作成する際のベースとなるテンプレートプロジェクトの作成などもしてくれるようです。
OperatorSDKでは以下の方法でOperatorの作成を行えるようです。
今回はGoでOperator(Memcached Operator)を作る方法を試してみます。
基本的にはここをたどって気になったところを深堀りする感じでやっていこうと思います。
使ってみる
環境
動作環境は以下の通り
$ uname -srvmpio Linux 5.4.0-77-generic #86-Ubuntu SMP Thu Jun 17 02:35:03 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a LSB Version: core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch Distributor ID: Ubuntu Description: Ubuntu 20.04.2 LTS Release: 20.04 Codename: focal $ go version go version go1.16 linux/amd64 # インストールに必要っぽい $ gpg --version gpg (GnuPG) 2.2.19 libgcrypt 1.8.5 Copyright (C) 2019 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. (省略) $ minikube version minikube version: v1.21.0 commit: 76d74191d82c47883dc7e1319ef7cebd3e00ee11 $ kubectl version -o yaml clientVersion: buildDate: "2021-03-18T01:10:43Z" compiler: gc gitCommit: 6b1d87acf3c8253c123756b9e61dac642678305f gitTreeState: clean gitVersion: v1.20.5 goVersion: go1.15.8 major: "1" minor: "20" platform: linux/amd64 serverVersion: buildDate: "2021-01-13T13:20:00Z" compiler: gc gitCommit: faecb196815e248d3ecfb03c680a4507229c2a56 gitTreeState: clean gitVersion: v1.20.2 goVersion: go1.15.5 major: "1" minor: "20" platform: linux/amd64
OperatorSDKのインストール
インストトールは以下の3つの方法があります。
今回はInstall from GitHub releaseでインストールしてみます。
インストールにはcurlとgpgのversion 2.0以上が必要みたいです。
バイナリをダウンロードします。
$ echo $ARCH amd64 $ export OS=$(uname | awk '{print tolower($0)}') $ echo $OS linux $ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.9.0/operator-sdk_${OS}_${ARCH}
次にバイナリの検証を行います。
$ gpg --keyserver keyserver.ubuntu.com --recv-keys 052996E2A20B5C7E $ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.9.0/checksums.txt $ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.9.0/checksums.txt.asc $ gpg -u "Operator SDK (release) <cncf-operator-sdk@cncf.io>" --verify checksums.txt.asc gpg: 署名されたデータが'checksums.txt'にあると想定します gpg: 2021年06月18日 08時21分40秒 JSTに施された署名 gpg: RSA鍵8613DB87A5BA825EF3FD0EBE2A859D08BF9886DBを使用 gpg: "Operator SDK (release) <cncf-operator-sdk@cncf.io>"からの正しい署名 [不明の] gpg: *警告*: この鍵は信用できる署名で証明されていません! gpg: この署名が所有者のものかどうかの検証手段がありません。 主鍵フィンガープリント: 3B2F 1481 D146 2380 80B3 46BB 0529 96E2 A20B 5C7E 副鍵フィンガープリント: 8613 DB87 A5BA 825E F3FD 0EBE 2A85 9D08 BF98 86DB
公開鍵自体が信用できるかわからないという警告が出ていますね。 ただ、checksum.txtの検証自体はうまく言ってるみたいなのでここでは先に進もうと思います。
$ grep operator-sdk_${OS}_${ARCH} checksums.txt | sha256sum -c - operator-sdk_linux_amd64: OK
チェックサムもOKみたいです。
実行権限を付与して、Pathがとおっているところに配置します。
$ chmod +x operator-sdk_${OS}_${ARCH} && sudo mv operator-sdk_${OS}_${ARCH} /usr/local/bin/operator-sdk
これでインストールは完了です。
$ operator-sdk version operator-sdk version: "v1.9.0", commit: "205e0a0c2df0715d133fbe2741db382c9c75a341", kubernetes version: "1.20.2", go version: "go1.16.5", GOOS: "linux", GOARCH: "amd64"
プロジェクトを作成する
まずは、プロジェクトを作成します。
$ mkdir operatorsdk-sample $ cd operatorsdk-sample/ $ operator-sdk init --domain hirooka.dev --repo github.com/samuraiball/settings
operator-sdk init
コマンドではGo modulesベースのプロジェクトを作成します。
$GOPATH/src
以外のところでプロジェクトをInitする場合は--repo
フラグでリポジトリを指定する必要があるみたいです。
--domain
フラグではDockerレジストリもしくは、Docker Hubのnamespace(ユーザ名)を指定します。
今回この最初の部分で色々しくってレジストリでもユーザ名でもないものを指定してしまったのですが、Makefileの記述を変えればうまく行くので一旦ここでは先に進みます。
さておきコマンドを実行すると諸々の設定がすんだプロジェクトが出来上がります。
ディレクトリ構造は以下のような感じ。
$ tree . ├── Dockerfile ├── Makefile ├── PROJECT ├── config │ ├── default │ │ ├── kustomization.yaml │ │ ├── manager_auth_proxy_patch.yaml │ │ └── manager_config_patch.yaml │ ├── manager │ │ ├── controller_manager_config.yaml │ │ ├── kustomization.yaml │ │ └── manager.yaml │ ├── manifests │ │ └── kustomization.yaml │ ├── prometheus │ │ ├── kustomization.yaml │ │ └── monitor.yaml │ ├── rbac │ │ ├── auth_proxy_client_clusterrole.yaml │ │ ├── auth_proxy_role.yaml │ │ ├── auth_proxy_role_binding.yaml │ │ ├── auth_proxy_service.yaml │ │ ├── kustomization.yaml │ │ ├── leader_election_role.yaml │ │ ├── leader_election_role_binding.yaml │ │ ├── role_binding.yaml │ │ └── service_account.yaml │ └── scorecard │ ├── bases │ │ └── config.yaml │ ├── kustomization.yaml │ └── patches │ ├── basic.config.yaml │ └── olm.config.yaml ├── go.mod ├── go.sum ├── hack │ └── boilerplate.go.txt └── main.go
config/
ディレクトリには起動時の設定が諸々用意されて、Kustomizeのyamlファイルが入っています。
config/manager
:コントローラーをPodとして起動する設定config/rbac
:作成するコントローラーを操作する際のパーミッションの設定
その他にもCRDやWebhookの設定も入っているようです。
また、main.go
がOperatorのエントリーポイントとなるみたいです。
func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, MetricsBindAddress: metricsAddr, Port: 9443, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "6a59cba3.hirooka.dev", }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } }
ここではControllerのセットアップ、実行のトラッキング、キャッシュの制御、CRDのスキーマ登録等を行ってくれるManagerを初期化する処理が書かれています。
ManagerではControllerがResorceを監視するネームスペースを制限することができます。
mgr, err := ctrl.NewManager(cfg, manager.Options{Namespace: namespace})
デフォルトではネームスペースはOperatorが動いているネームスペースのものを監視するようになります。
もしすべてのネームスペースを監視するようにしたい場合はNamespace: ""
と空の文字列を入れる必要があるようです。
ここではなにも触らずに、デフォルトのままで先に進みます。
新しいCRDとControllerを追加する
足場となるコードを自動生成する
次に新しいCRDとControllerを追加します。
以下のコマンドを実行します。
$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
これで、groupがcache
、versionがv1alpha1
、KindがMemcached
の足場が完成します。
今回深くはおいませんが、groupなどのそれぞれの意味はこちらをご確認ください。
実行してGitでどんな感じの変更が入っているかをみてみます。
$ git status ブランチ master Your branch is up to date with 'origin/master'. コミット予定の変更点: (use "git restore --staged <file>..." to unstage) new file: ../../docker/Dockerfile new file: ../opentracing/deployment.yaml new file: ../opentracing/gateway.yaml new file: ../opentracing/service.yaml new file: ../opentracing/tracing.yaml new file: ../opentracing/virtual-service.yaml modified: PROJECT new file: api/v1alpha1/groupversion_info.go new file: api/v1alpha1/memcached_types.go new file: api/v1alpha1/zz_generated.deepcopy.go new file: config/crd/kustomization.yaml new file: config/crd/kustomizeconfig.yaml new file: config/crd/patches/cainjection_in_memcacheds.yaml new file: config/crd/patches/webhook_in_memcacheds.yaml new file: config/rbac/memcached_editor_role.yaml new file: config/rbac/memcached_viewer_role.yaml new file: config/samples/cache_v1alpha1_memcached.yaml new file: config/samples/kustomization.yaml new file: controllers/memcached_controller.go new file: controllers/suite_test.go modified: go.mod modified: main.go
新しいファイルがいくつかできているのとmain.goなどが書き換わってますね。
Custom Resourceを編集する
ここで、注目すべきはapi/v1alpha1/memcached_types.go
とcontrollers/memcached_controller.go
でこれがCustom ResourceとCustom Controllerのベースのコードとなります。
まずはmemcached_types.go
の方から修正していきます。
Memcached
とそれに関係する構造体が定義されているのがわかります。
type Memcached struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec MemcachedSpec `json:"spec,omitempty"` Status MemcachedStatus `json:"status,omitempty"` }
Memached
構造体が持つMemcachedSpec.Size
という、デプロイされるCustom Resourceの数を設定するフィールドと、MemcachedStatus.Nodes
ではCustom Resourceで作られるPodの名前を保存するフィールドを追加します。
// MemcachedSpec defines the desired state of Memcached type MemcachedSpec struct { //+kubebuilder:validation:Minimum=0 // デプロイされるMemcachedの数 Size int32 `json:"size"` } // MemcachedStatus defines the observed state of Memcached type MemcachedStatus struct { // MemcachedのPodの名前を保存する Nodes []string `json:"nodes"` }
次にMemached
構造体に+kubebuilder:subresource:status
マーカーを追加します。
Status Subresourceを追加することによりControllerが他のCustom Resourceオブジェクトに変更を加えること無くCustom Resourceのステータスを更新できるようになるみたいです。
//+kubebuilder:subresource:status type Memcached struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec MemcachedSpec `json:"spec,omitempty"` Status MemcachedStatus `json:"status,omitempty"` }
*_types.go
は必ず以下のコマンドを実行して、Resource Typeの自動生成されるコードを更新する必要があります。
$ make generate
Makefileで定義されたこのコマンドはcontroller-genを実行して、api/v1alpha1/zz_generated.deepcopy.go
をアップデートします。
また、 SpecやStatusフィースドにCRD validationマーカーが付いている場合、以下のコマンドでそのCRDのマニフェストを生成することができます。
$ make manifests
config/crd/bases/cache.example.com_memcacheds.yaml
に以下のようなCRDのマニフェストが生成されます。
apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.4.1 creationTimestamp: null name: memcacheds.cache.hirooka.dev spec: group: cache.hirooka.dev names: kind: Memcached listKind: MemcachedList plural: memcacheds singular: memcached scope: Namespaced versions: - name: v1alpha1 schema: openAPIV3Schema: description: Memcached is the Schema for the memcacheds API properties: apiVersion: description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' type: string kind: description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' type: string metadata: type: object spec: description: MemcachedSpec defines the desired state of Memcached properties: size: description: デプロイされるMemcachedの数 format: int32 minimum: 0 type: integer required: - size type: object status: description: MemcachedStatus defines the observed state of Memcached properties: nodes: description: MemcachedのPodの名前を保存する items: type: string type: array required: - nodes type: object type: object served: true storage: true subresources: status: {} status: acceptedNames: kind: "" plural: "" conditions: [] storedVersions: []
コントローラーの実装
コントローラーの実装はここのものを一旦そのまま使います。
まず、SetUpWithManager
メソッドではManager
がセットアップされ、ControllerがどのようにCustom Resourceを管理するかを設定します。
func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&cachev1alpha1.Memcached{}). Owns(&appsv1.Deployment{}). Complete(r) }
NewControllerManagedBy
はコントローラーのビルダーを提供しており、様々なコントローラーの設定を行なうことができます。
例えば上記の例では以下のような設定を行ってます。
For(&cachev1alpha1.Memcached{}
- MecahedをプライマリーResourceとして指定しています。
Add/Update/Delete
のそれぞれのイベントが発火されたタイミングで調整ループの中でRequest
がMemcachedオブジェクトを操作するために送られます。
- MecahedをプライマリーResourceとして指定しています。
Owns(&appsv1.Deployment{})
Controllerを初期化する際の様々な設定が用意されています。詳細はこちらをご確認ください。
例えば以下のように設定すると、調整ループの最大の並行数を2に設定し特定条件のイベントを無視するようになります。
func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&cachev1alpha1.Memcached{}). Owns(&appsv1.Deployment{}). // WithOptionとWithEventFilerを追加 WithOptions(controller.Options{MaxConcurrentReconciles: 2}). WithEventFilter(ignoreDeletionPredicate()). Complete(r) } func ignoreDeletionPredicate() predicate.Predicate { return predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { // メタデータが変更されていない場合はUpdateを無視する return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() }, DeleteFunc: func(e event.DeleteEvent) bool { // オブジェクトがDeleteとなっている場合はfalseで評価される return !e.DeleteStateUnknown }, } }
WithEventFilter
にPredicateを渡すことでイベントをフィルターすることができるようです。
次に、 Reconcile
で調整ループの実装の部分をみていきます。
func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { memcached := &cachev1alpha1.Memcached{} err := r.Get(ctx, req.NamespacedName, memcached) // 省略 }
Reconcile関数はCustom Resourceを実際のシステムで理想な状態にする役割を持ちます。
イベントが発火されるたびにReconcile関数が呼ばれ、調整が行われます。
Reconcile関数はRequest
を引数として受け取り、Request
はNamespace/Name
の鍵を持っており、リクエストに対応するオブジェクトをLookUpするのに利用されます。
上記の例では、調整リクエストに対応するMemcached
をLookUpしてます。
また、Reconcile関数は以下の戻り値を返すことが可能です。
return ctrl.Result{}, err
:エラーreturn ctrl.Result{Requeue: true}, nil
:エラーなしreturn ctrl.Result{}, nil
:調整の停止return ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())}, nil
:調整をX時間後に再度行なう
最後にContorollerはRBACのパーミンションを持つ必要があります。
以下のようにRBACマーカーを付与します。
//+kubebuilder:rbac:groups=cache.hirooka.dev,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=cache.hirooka.dev,resources=memcacheds/status,verbs=get;update;patch //+kubebuilder:rbac:groups=cache.hirooka.dev,resources=memcacheds/finalizers,verbs=update //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { //省略 }
以下のコマンドを叩くとconfig/rbac/role.yaml
に上記のマーカーからマニフェストを自動生成してくれます。
$ make manifests
OperatorのイメージをDocker HubにPushする
作成したOperatorのイメージをDockerレジストリにPushしておく必要があります。
Makefileの以下の部分を書き換えます。
-IMG ?= controller:latest +IMG ?= $(IMAGE_TAG_BASE):$(VERSION)
IMAGE_TAG_BASE
でInitのところで指定したDockerレジストリを取得できるみたいです。
しかし、前述の通り私はちょっとinitのところでしくってしまったのでこのブログでは以下のように書き換えます。
IMG ?= hirohiroyuya/sample-controller:latest
次のコマンドでイメージをビルドしてPushします。
$ make docker-build docker-push
クラスターにデプロイする
作ったCDRとControllerをクラスターにデプロイします。
デプロイにはいくつかの方法があるようですが今回はこの中で「Run as a Deployment inside the cluster」の方法を試してみます。
具体的には以下のコマンドを実行します。
$ make deploy
この際クラスターはカレントContextで指定されるものが利用されるみたいです(このブログではminikube
にしてます)。
コマンドの実行が成功するとデフォルトでは<project-name>-system
のネームスペースでOperatorが実行されています。
$ kubectl get deployment -n operatorsdk-sample-system NAME READY UP-TO-DATE AVAILABLE AGE operatorsdk-sample-controller-manager 1/1 1 1 16m
いい感じに動いてくれてるみたいですね。
Memcached Recourceを作成する
Operatorのデプロイまでできたので、いよいよMemcached Resourceをクラスタにデプロイしてみます。
config/samples/cache_v1alpha1_memcached.yaml
を以下のように書き換えます。’
apiVersion: cache.hirooka.dev/v1alpha1 kind: Memcached metadata: name: memcached-sample spec: size: 3
applyしてリソースが作成されていることを確認します。
$ kubectl apply -f config/samples/cache_v1alpha1_memcached.yaml memcached.cache.hirooka.dev/memcached-sample created $ kubectl get deployment NAME READY UP-TO-DATE AVAILABLE AGE memcached-sample 3/3 3 3 91s $ kubectl get pods NAME READY STATUS RESTARTS AGE memcached-sample-6c765df685-6mzjj 1/1 Running 0 2m12s memcached-sample-6c765df685-l9jpr 1/1 Running 0 2m12s memcached-sample-6c765df685-t2wkw 1/1 Running 0 2m12s
いい感じで動いてくれてそうですね!!