はじめに
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))
}
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)
}
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オブジェクトを操作するために送られます。
Owns(&appsv1.Deployment{})
- Deploymentをセカンダリなリソースとして管理することを定義しています。Deployment
Add/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{}).
WithOptions(controller.Options{MaxConcurrentReconciles: 2}).
WithEventFilter(ignoreDeletionPredicate()).
Complete(r)
}
func ignoreDeletionPredicate() predicate.Predicate {
return predicate.Funcs{
UpdateFunc: func(e event.UpdateEvent) bool {
return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration()
},
DeleteFunc: func(e event.DeleteEvent) bool {
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
いい感じに動いてくれてるみたいですね。
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
いい感じで動いてくれてそうですね!!