CustomControllerでinformerを使ってリソースの追加や更新といったイベント毎に処理を行う

Hello Custom Controller!ではカスタムリソースの取得を素朴にループで実装している。しかし、code-generatorにはinformer-genというコマンドがあり、これを使うとリソースの追加や更新、削除といったイベントに対応して任意の処理を実行することが出来る。コードの見通しもよくなるので、informerを使った形に書き換えてみよう。

準備

kubernetes v1.12.3を使う。minikubeで環境を作成:

$ minikube start --memory=8192 --cpus=4 \
    --kubernetes-version=v1.12.3 \
    --vm-driver=hyperkit \
    --bootstrapper=kubeadm
  • go: v1.11.2

informer用のコードを生成する

code-generatorのinformer-genを使う。

$ bash ~/src/k8s.io/code-generator/generate-groups.sh client,deepcopy,informer
Generating deepcopy funcs
Generating clientset for foo:v1alpha at github.com/takaishi/hello2019/hello-custom-controller-with-informer/pkg/client/clientset
Generating informers for foo:v1alpha at github.com/takaishi/hello2019/hello-custom-controller-with-informer/pkg/client/informers

コントローラーの実装

informerを素朴に使った実装は以下の通り。リソースの追加、更新、削除に対応して任意の処理を実行できる。

package main

import (
	clientset "github.com/takaishi/hello2019/hello-custom-controller-with-informer/pkg/client/clientset/versioned"
	informers "github.com/takaishi/hello2019/hello-custom-controller-with-informer/pkg/client/informers/externalversions"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/cache"
	"os/signal"
	"syscall"

	"github.com/urfave/cli"
	"log"
	"os"
	"time"
)

func main() {
	log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime)

	app := cli.NewApp()
	app.Flags = []cli.Flag{}

	app.Action = func(c *cli.Context) error {
		return action(c)
	}

	err := app.Run(os.Args)
	if err != nil {
		log.Fatal(err)
	}
}

func action(c *cli.Context) error {
	cfg, err := rest.InClusterConfig()
	if err != nil {
		log.Printf(err.Error())
	}

	client, err := clientset.NewForConfig(cfg)
	if err != nil {
		log.Printf(err.Error())
	}

	informerFactory := informers.NewSharedInformerFactoryWithOptions(client, time.Second*30, informers.WithNamespace("default"))
	informer := informerFactory.Samplecontroller().V1alpha().Foos()
	informer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc:    addFunc,
		UpdateFunc: updateFunc,
		DeleteFunc: deleteFunc,
	})

	stopCh := SetupSignalHandler()
	go informerFactory.Start(stopCh)

	log.Printf("[DEBUG] start")
	<-stopCh
	log.Printf("[DEBUG] shutdown")
	return nil
}

var onlyOneSignalHandler = make(chan struct{})
var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM}

func SetupSignalHandler() (stopCh <-chan struct{}) {
	close(onlyOneSignalHandler)

	stop := make(chan struct{})
	c := make(chan os.Signal, 2)
	signal.Notify(c, shutdownSignals...)
	go func() {
		<-c
		close(stop)
		<-c
		os.Exit(1)
	}()

	return stop
}

func addFunc(obj interface{}) {
	log.Printf("[DEBUG] addFunc")
	log.Printf("[DEBUG] obj: %+v\n", obj)
}

func updateFunc(old, obj interface{}) {
	log.Printf("[DEBUG] updateFunc")
	log.Printf("[DEBUG] old: %+v\n", old)
	log.Printf("[DEBUG] obj: %+v\n", obj)
}

func deleteFunc(obj interface{}) {
	log.Printf("[DEBUG] deleteFunc")
	log.Printf("[DEBUG] obj: %+v\n", obj)
}

後はビルドしてデプロイするだけ。注意点として、使用するサービスアカウントがリソースをwatchできる必要があるのでRoleの更新が必要。

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: foo-reader
  namespace: default
rules:
  - apiGroups: ["samplecontroller.k8s.io"]
    verbs: ["get", "list", "watch"]
    resources: ["foos"]

カスタムリソースを追加するとaddFunc関数が呼び出されることが確認できる。

controller-main-6cbd85c8f7-8hg27 controller-main 2019/01/04 02:03:39 main.go:81: [DEBUG] addFunc
controller-main-6cbd85c8f7-8hg27 controller-main 2019/01/04 02:03:39 main.go:82: [DEBUG] obj: &{TypeMeta:{Kind: APIVersion:} ObjectMeta:{Name:foo-001 GenerateName: Namespace:default SelfLink:/apis/samplecontroller.k8s.io/v1alpha/namespaces/default/foos/foo-001 UID:f43b8abd-0fc4-11e9-95ac-263ada282756 ResourceVersion:204577 Generation:1 CreationTimestamp:2019-01-04 02:03:39 +0000 UTC DeletionTimestamp: DeletionGracePeriodSeconds: Labels:map[] Annotations:map[kubectl.kubernetes.io/last-applied-configuration:{"apiVersion":"samplecontroller.k8s.io/v1alpha","kind":"Foo","metadata":{"annotations":{},"name":"foo-001","namespace":"default"},"spec":{"deploymentName":"deploy-foo-001","replicas":1}}
controller-main-6cbd85c8f7-8hg27 controller-main ] OwnerReferences:[] Initializers:nil Finalizers:[] ClusterName:} Status:{Name:} Spec:{Name:}}

informerを使えばイベントに応じて任意の処理を書くことが出来る、ということを確認出来た。code-generatorでコードを生成できるので、基本的にはinformerを使っていくのがよさそうだなあ。

Hello Custom Controller!

2019年はカスタムコントローラーや!ということで2018年末頃から少しづつ調べている。まずは非常に簡単なものを動かしてみる。

Custom Controller is…

CRDについてのメモにも書いたが、CRD自体はただのデータなので、それだけでは何も起こらない。Kubernetesのdeclarative APIを活用するためにはコントローラーの作成が必要。なお、本記事は以下のページを参考にしています。

作っていく

カスタムコントローラーを作っていくわけだが、いきなり複雑なものを作るのは難しい。そこで、まずはカスタムリソースを読み込み、ログに出力するだけのコントローラーを作る。カスタムコントローラー用のSDKやBuilderがいろいろあるようだがこれも使わず、KubernetesのAPI用コードを出力するだめの code-generator を用いて素朴に作ってみる。code-generatorはいくつかの機能を持っている。

  • deepcopy-gen – 各型についてDeepCopy用のメソッドを生成
  • client-gen – カスタムリソースを扱うためのクライアントセットを生成
  • informer-gen – カスタムリソースの変更に対応するためのイベントベースインターフェースを生成
  • lister-gen – GetとListについて、読み取り用キャッシュレイヤを扱うコードを生成

本格的にカスタムリソースを扱うためにはinformer-genやlister-genが必要になるが、今回はdeepcopy-genとclient-genのみ。deepcopy-genも不要な気がするが、これなしでうまく動かす方法がちと分からなかった。

生成元のコードを用意する

まずは apis/foo/v1alpha/doc.go

// +k8s:deepcopy-gen=package

// Package v1alpha is the v1alpha version of the API.
// +groupName=samplecontroller.k8s.io
package v1alpha

次にapis/foo/v1alpha/register.go

package v1alpha

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
)

// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{Group: "samplecontroller.k8s.io", Version: "v1alpha"}

// Kind takes an unqualified kind and returns back a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
	return SchemeGroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
	return SchemeGroupVersion.WithResource(resource).GroupResource()
}

var (
	SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
	AddToScheme   = SchemeBuilder.AddToScheme
)

// Adds the list of known types to Scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
	scheme.AddKnownTypes(SchemeGroupVersion,
		&Foo{},
		&FooList{},
	)
	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
	return nil
}

最後にapis/foo/v1alpha/types.go

package v1alpha

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// Foo is a specification for a Foo resource
type Foo struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Status FooStatus `json:"status"`
	Spec   FooSpec   `json:"spec"`
}

type FooStatus struct {
	Name string `json:"name"`
}

// FooSpec is the spec for a Workflow resource
type FooSpec struct {
	Name string `json:"name"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// FooList is a list of Workflow resources
type FooList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata"`

	Items []Foo `json:"items"`
}

コード生成

これらのファイルを用意したら、code-generatorでコードを生成する。なお、code-generatorのバージョンは kubernetes-1.12.3を使用。

$ bash ~/src/k8s.io/code-generator/generate-groups.sh client,deepcopy github.com/takaishi/hello2019/hello-custom-controller/pkg/client github.com/takaishi/hello2019/hello-custom-controller/pkg/apis foo:v1alpha
Generating deepcopy funcs
Generating clientset for foo:v1alpha at github.com/takaishi/hello2019/hello-custom-controller/pkg/client/clientset

カスタムコントローラーを実装

これで、GoからFooリソースを扱うことが出来る。カスタムコントローラー用のコードを書いていく:

package main

import (
	clientset "github.com/takaishi/hello2019/hello-custom-controller/pkg/client/clientset/versioned"
	"k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/rest"

	"fmt"
	"github.com/urfave/cli"
	"log"
	"os"
	"time"
)

func main() {
	log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime)

	app := cli.NewApp()
	app.Flags = []cli.Flag{}

	app.Action = func(c *cli.Context) error {
		return action(c)
	}

	err := app.Run(os.Args)
	if err != nil {
		log.Fatal(err)
	}
}

func action(c *cli.Context) error {
	cfg, err := rest.InClusterConfig()
	if err != nil {
		log.Printf(err.Error())
	}
	client, err := clientset.NewForConfig(cfg)
	if err != nil {
		log.Printf(err.Error())
	}

	for {
		foos, err := client.SamplecontrollerV1alpha().Foos("default").List(v1.ListOptions{})
		if err != nil {
			log.Printf(err.Error())
			continue
		}

		fmt.Printf("%+v\n", foos)

		time.Sleep(10 * time.Second)
	}
	return nil
}

コントローラー用のマニフェスト。defaultアカウントがオブジェクトを取得できるようにRBACの設定も行っている:

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: foo-reader
  namespace: default
rules:
  - apiGroups: ["samplecontroller.k8s.io"]
    verbs: ["get", "list"]
    resources: ["foos"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: foo-reader-rolebinding
  namespace: default
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: foo-reader
subjects:
  - kind: ServiceAccount
    name: default
    namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: controller-main
spec:
  selector
    matchLabels:
      app: controller-main
  replicas: 1
  template:
    metadata:
      labels:
        app: controller-main
    spec:
      containers:
        - name: controller-main
          image: rtakaishi/sample-controller-main:latest
          imagePullPolicy: IfNotPresent

デプロイして動作確認する

カスタムコントローラーのビルドからKubernetesへのデプロイまで行うためのMakefile:

date := $(shell date +'%s')

default:
	env GOOS=linux GOARCH=amd64 go build -o controller-main main.go
	docker build . -t rtakaishi/sample-controller-main
	kubectl apply -f ./deploy-controller-main.yaml
	kubectl patch deploy controller-main -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"date\":\"$(date)\"}}}}}"

デプロイする:

$ eval (minikube docker-env)
$ make

カスタムコントローラーのログを見ると、Fooリソースを取得してログに出力していることが確認できた:

$ stern controller-main
+ controller-main-66f5cb49df-4bhq6 › controller-main
controller-main-66f5cb49df-4bhq6 controller-main &{TypeMeta:{Kind: APIVersion:} ListMeta:{SelfLink:/apis/samplecontroller.k8s.io/v1alpha/namespaces/default/foos ResourceVersion:98881 Continue:} Items:[{TypeMeta:{Kind:Foo APIVersion:samplecontroller.k8s.io/v1alpha} ObjectMeta:{Name:foo-001 GenerateName: Namespace:default SelfLink:/apis/samplecontroller.k8s.io/v1alpha/namespaces/default/foos/foo-001 UID:77506f4f-0e40-11e9-95ac-263ada282756 ResourceVersion:3649 Generation:1 CreationTimestamp:2019-01-02 03:42:45 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[] Annotations:map[kubectl.kubernetes.io/last-applied-configuration:{"apiVersion":"samplecontroller.k8s.io/v1alpha","kind":"Foo","metadata":{"annotations":{},"name":"foo-001","namespace":"default"},"spec":{"deploymentName":"deploy-foo-001","replicas":1}}
controller-main-66f5cb49df-4bhq6 controller-main ] OwnerReferences:[] Initializers:nil Finalizers:[] ClusterName:} Status:{Name:} Spec:{Name:}} {TypeMeta:{Kind:Foo APIVersion:samplecontroller.k8s.io/v1alpha} ObjectMeta:{Name:foo-002 GenerateName: Namespace:default SelfLink:/apis/samplecontroller.k8s.io/v1alpha/namespaces/default/foos/foo-002 UID:775194f8-0e40-11e9-95ac-263ada282756 ResourceVersion:93582 Generation:1 CreationTimestamp:2019-01-02 03:42:45 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[] Annotations:map[kubectl.kubernetes.io/last-applied-configuration:{"apiVersion":"samplecontroller.k8s.io/v1alpha","kind":"Foo","metadata":{"annotations":{},"name":"foo-002","namespace":"default"},"spec":{"deploymentName":"deploy-foo-002","hoge":"huga","replicas":1}}
controller-main-66f5cb49df-4bhq6 controller-main ] OwnerReferences:[] Initializers:nil Finalizers:[] ClusterName:} Status:{Name:} Spec:{Name:}}]}

非常にシンプル、というかとくに何もしていないが、カスタムコントローラーができたといっていいだろう。理屈ではログ出力の部分をロジックに置き換えればいいはず。実際にはinformerを用いてイベントベースで処理を行うようなコードになるのだと思う。次はログ出力だけではなく別リソースを扱うようにしていきたい。

ソースコードなどはtakaishi/hello2019にあります。

CRDについてのメモ

Kubernetesのカスタムリソースについてのメモ。基本的には公式ドキュメントの Custom Resources にいろいろと説明が書かれているのでここを読めばよい。カスタムリソース自体は構造化されたデータをKubernetes上に保存・取得できる機能といえる。これにカスタムコントローラを組み合わせることで、Deploymentのようなビルトインリソースと同じような宣言的APIを用いた状態管理を実現できるようになる。データ保存だけなら必ずしもカスタムコントローラーは必要ではないというのが最初分かっていなかった。

軽く試す。使用バージョンは以下の通り:

  • Kubernetes v1.12.3
  • minikube v0.32.0

検証用環境はminikubeで起動:

$ minikube start --memory=8192 --cpus=4 \
    --kubernetes-version=v1.12.3 \
    --vm-driver=hyperkit \
    --bootstrapper=kubeadm

kubernetes/sample-controller を参考にしつつ、CRD用のマニフェストを作成する。グループやバージョンがリソースへアクセスする際のAPIとなる(/apis/<group>/<version>)。リソースのkindや単数形、複数形なども定義できる。

---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: foos.samplecontroller.k8s.io
spec:
  group: samplecontroller.k8s.io
  version: v1alpha
  scope: Namespaced
  names:
    plural: foos
    singular: foo
    kind: Foo
    shortNames:
      - foo

このマニフェストを適用すると、以下のようにcrdリソースとして見えるようになる:

$ kubectl get crd
NAME                           AGE
foos.samplecontroller.k8s.io   34m

Fooリソースを作ってみる。以下のようなマニフェストを用意し、 kubectl applyする:

---
apiVersion: samplecontroller.k8s.io/v1alpha
kind: Foo
metadata:
  name: foo-001
spec:
  deploymentName: deploy-foo-001
  replicas: 1
---
apiVersion: samplecontroller.k8s.io/v1alpha
kind: Foo
metadata:
  name: foo-002
spec:
  deploymentName: deploy-foo-002
  replicas: 1
  hoge: huga

Fooリソースが作成されていることがわかる:

$ kubectl get foos
NAME      AGE
foo-001   50s
foo-002   50s

リソースの詳細を見ると、マニフェストで指定したspecが確認できる:

$ kubectl describe foo foo-001
Name:         foo-001
Namespace:    default
Labels:       <none>
Annotations:  kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"samplecontroller.k8s.io/v1alpha","kind":"Foo","metadata":{"annotations":{},"name":"foo-001","namespace":"default"},"spec":{"deploymentNa...
API Version:  samplecontroller.k8s.io/v1alpha
Kind:         Foo
Metadata:
  Creation Timestamp:  2019-01-02T03:42:45Z
  Generation:          1
  Resource Version:    3649
  Self Link:           /apis/samplecontroller.k8s.io/v1alpha/namespaces/default/foos/foo-001
  UID:                 77506f4f-0e40-11e9-95ac-263ada282756
Spec:
  Deployment Name:  deploy-foo-001
  Replicas:         1
Events:             <none>

リソース毎にspecが異なることも確認できる:

$ kubectl describe foo foo-002
Name:         foo-002
Namespace:    default
Labels:       <none>
Annotations:  kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"samplecontroller.k8s.io/v1alpha","kind":"Foo","metadata":{"annotations":{},"name":"foo-002","namespace":"default"},"spec":{"deploymentNa...
API Version:  samplecontroller.k8s.io/v1alpha
Kind:         Foo
Metadata:
  Creation Timestamp:  2019-01-02T03:42:45Z
  Generation:          1
  Resource Version:    93582
  Self Link:           /apis/samplecontroller.k8s.io/v1alpha/namespaces/default/foos/foo-002
  UID:                 775194f8-0e40-11e9-95ac-263ada282756
Spec:
  Deployment Name:  deploy-foo-002
  Hoge:             huga
  Replicas:         1
Events:             <none>

CRDでバリデーションを行うこともできる(OpenAPIv3.0を使うようだ)。kubernetes/sample-controller を見つつ、replicasのバリデーションを設定してみる:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: foos.samplecontroller.k8s.io
spec:
  group: samplecontroller.k8s.io
  version: v1alpha
  scope: Namespaced
  names:
    plural: foos
    singular: foo
    kind: Foo
    shortNames:
      - foo
  validation:
    openAPIV3Schema:
      properties:
        spec:
          properties:
            replicas:
              type: integer
              minimum: 1
              maximum: 10

これで、replicasの最小値と最大値が設定される。replicasが10より大きいマニフェストを用意して適用してみる:

---
apiVersion: samplecontroller.k8s.io/v1alpha
kind: Foo
metadata:
  name: foo-003
spec:
  deploymentName: deploy-foo-003
  replicas: 20

バリデーションに引っかかり、作成に失敗することが確認できた。

$ kubectl apply -f ./foo-invalid.yaml
The Foo "foo-003" is invalid: []: Invalid value: map[string]interface {}{"apiVersion":"samplecontroller.k8s.io/v1alpha", "kind":"Foo", "metadata":map[string]interface {}{"name":"foo-003", "namespace":"default", "creationTimestamp":"2019-01-03T01:05:13Z", "annotations":map[string]interface {}{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"samplecontroller.k8s.io/v1alpha\",\"kind\":\"Foo\",\"metadata\":{\"annotations\":{},\"name\":\"foo-003\",\"namespace\":\"default\"},\"spec\":{\"deploymentName\":\"deploy-foo-003\",\"replicas\":20}}\n"}, "generation":1, "uid":"a02a984d-0ef3-11e9-95ac-263ada282756"}, "spec":map[string]interface {}{"replicas":20, "deploymentName":"deploy-foo-003"}}: validation failure list:
spec.replicas in body should be less than or equal to 10

API Aggregationというものもあるようだが、そちらはまた今度。

参考: