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でインストールしてみます。
インストールにはcurlgpgの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/ディレクトリには起動時の設定が諸々用意されて、Kustomizeyamlファイルが入っています。

  • 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.gocontrollers/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のそれぞれのイベントが発火されたタイミングで調整ループの中でRequestMemcachedオブジェクトを操作するために送られます。
  • Owns(&appsv1.Deployment{})
    • Deploymentをセカンダリなリソースとして管理することを定義しています。DeploymentAdd/Update/Deleteイベントのタイミングで調整のRequestがDeploymemtのオーナーの調整ループ(Reconcileメソッド)にマップされます。今回の場合はMemcachedオブジェクトになります。

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
        },
    }
}

WithEventFilterPredicateを渡すことでイベントをフィルターすることができるようです。

次に、 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を引数として受け取り、RequestNamespace/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

いい感じで動いてくれてそうですね!!