Chapter 05

Controllerのテスト

zoetro
zoetro
2022.08.22に更新

テスト対象

まずはテスト対象となるControllerの実装をおこないます。

実装の詳細についての解説はおこないませんが、作成されたカスタムリソース(Sampleリソース)の内容に応じて、Deploymentリソースを作成するだけのシンプルな実装となっています。

package controllers

import (
	"context"

	testv1 "github.com/zoetrope/test-controller/api/v1"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
	"sigs.k8s.io/controller-runtime/pkg/log"
)

// SampleReconciler reconciles a Sample object
type SampleReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=test.zoetrope.github.io,resources=samples,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=test.zoetrope.github.io,resources=samples/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=test.zoetrope.github.io,resources=samples/finalizers,verbs=update

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *SampleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	logger := log.FromContext(ctx)

	var sample testv1.Sample
	err := r.Get(ctx, req.NamespacedName, &sample)
	if err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}
	if !sample.DeletionTimestamp.IsZero() {
		return ctrl.Result{}, nil
	}

	dep := &appsv1.Deployment{}
	dep.SetNamespace(sample.Namespace)
	dep.SetName(sample.Name)

	op, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error {
		if dep.Labels == nil {
			dep.Labels = map[string]string{}
		}
		dep.Labels["app.kubernetes.io/name"] = "nginx"
		dep.Labels["app.kubernetes.io/instance"] = req.Name

		dep.Spec = appsv1.DeploymentSpec{
			Replicas: sample.Spec.Replicas,
			Selector: &metav1.LabelSelector{
				MatchLabels: map[string]string{
					"app.kubernetes.io/name":     "nginx",
					"app.kubernetes.io/instance": req.Name,
				},
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: map[string]string{
						"app.kubernetes.io/name":     "nginx",
						"app.kubernetes.io/instance": req.Name,
					},
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{
						{
							Name:  "nginx",
							Image: sample.Spec.Image,
							Ports: []corev1.ContainerPort{
								{
									ContainerPort: 80,
								},
							},
						},
					},
				},
			},
		}
		return ctrl.SetControllerReference(&sample, dep, r.Scheme)
	})
	if err != nil {
		logger.Error(err, "failed to create or update Deployment")
		return ctrl.Result{}, err
	}

	if op != controllerutil.OperationResultNone {
		logger.Info("reconcile Deployment successfully", "op", op)
	}

	return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *SampleReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&testv1.Sample{}).
		Owns(&appsv1.Deployment{}).
		Complete(r)
}

Controllerの起動と停止

実際のテストケースを書く前に、セットアップノードを用意します。

KubernetesのControllerは、kube-apiserverとは独立して非同期に動作します。
そのため、AfterEachでリソースの片付けをおこなっているときにControllerのReconcile処理が予期せぬタイミングで動作してしまい、リソースが削除されずに残ってしまう問題が発生することがあります。

このような問題を避けるために、BeforeEachでControllerを起動し、AfterEachで停止をおこなうように実装することをおすすめします。

package controllers

import (
	"context"
	"time"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	ctrl "sigs.k8s.io/controller-runtime"
)

var _ = Describe("Test SampleController", func() {
	ctx := context.Background()
	var stopFunc func()

	BeforeEach(func() {
		mgr, err := ctrl.NewManager(cfg, ctrl.Options{
			Scheme:             scheme,
			LeaderElection:     false,
			MetricsBindAddress: "0",
		})
		Expect(err).ShouldNot(HaveOccurred())

		reconciler := &SampleReconciler{
			Client: mgr.GetClient(),
			Scheme: scheme,
		}
		err = reconciler.SetupWithManager(mgr)
		Expect(err).ShouldNot(HaveOccurred())

		ctx, cancel := context.WithCancel(ctx)
		stopFunc = cancel
		go func() {
			err := mgr.Start(ctx)
			if err != nil {
				panic(err)
			}
		}()
		time.Sleep(100 * time.Millisecond)
	})

	AfterEach(func() {
		stopFunc()
		time.Sleep(100 * time.Millisecond)
	})
})

Controllerのテスト

それでは、Controllerのテストを書いてみましょう。

このテストでは、カスタムリソース(Sampleリソース)を作成し、その後に期待するDeploymentリソースが作成できているかどうかを確認します。

package controllers

import (
	"context"
	"time"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	. "github.com/onsi/gomega/gstruct"
	testv1 "github.com/zoetrope/test-controller/api/v1"
	appsv1 "k8s.io/api/apps/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

var _ = Describe("Test SampleController", func() {
	ctx := context.Background()
	var stopFunc func()

	BeforeEach(func() {
		// 省略
	})

	AfterEach(func() {
		// 省略
	})

	It("should be success", func() {
		sample := &testv1.Sample{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "default",
				Name:      "test",
			},
			Spec: testv1.SampleSpec{
				Image:    "nginx:1.14.2",
				Replicas: pointer.Int32(2),
			},
		}

		err := k8sClient.Create(ctx, sample)
		Expect(err).ShouldNot(HaveOccurred())

		dep := &appsv1.Deployment{}
		err = k8sClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "test"}, dep)
		Expect(err).ShouldNot(HaveOccurred())

		Expect(dep.Spec.Replicas).Should(HaveValue(BeNumerically("==", 2)))
		Expect(dep.Spec.Template.Spec.Containers).Should(MatchAllElements(containerIdentity, Elements{
			"nginx": HaveField("Image", "nginx:1.14.2"),
		}))
	})
})

しかし、このテストはおそらく成功したり失敗したりするでしょう。
KubernetesのControllerはkube-apiserverとは独立して非同期に動いているため、Sampleリソースがつくられたことを検出してからDeploymentリソースがつくるまでにタイムラグが発生するからです。

ではこの問題を解決するためにはどうすればいいでしょうか。
スリープを入れようと思ってもテストの実行環境によって待ち時間は異なるでしょう。かといって長めのスリープを入れるとテストが遅くなってしまいます。

そこで、Gomegaが提供しているEventuallyという機能を利用することにします。

Eventually/Consistentlyの利用

Gomegaは非同期処理のテストをサポートするために、EventuallyとConsistentlyという以下のような機能を持つ関数を提供しています。

  • Eventually
    • 指定したMatcherが条件を満たすまで繰り返しポーリングする。
    • 指定した時間が経過しても条件を満たさない場合はタイムアウトしテストは失敗となる。
    • 指定した条件を1度でも満たせばテストは成功となりポーリングを終了する。
  • Consistently
    • 何度もポーリングを繰り返し、指定したMatcherが条件を満たし続けていることを確認する。
    • Matcherが1度でも条件を満たさなければテストは失敗となる。
    • 条件を満たしたまま指定された時間がが経過するとテストは成功となりポーリングを終了する。

では、先ほどのテストをEventuallyを利用して書き換えてみましょう。

package controllers

import (
	"context"
	"time"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	. "github.com/onsi/gomega/gstruct"
	testv1 "github.com/zoetrope/test-controller/api/v1"
	appsv1 "k8s.io/api/apps/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

var _ = Describe("Test SampleController", func() {
	ctx := context.Background()
	var stopFunc func()

	BeforeEach(func() {
		// 省略
	})

	AfterEach(func() {
		// 省略
	})

	It("should be success", func() {
		sample := &testv1.Sample{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "default",
				Name:      "test",
			},
			Spec: testv1.SampleSpec{
				Image:    "nginx:1.14.2",
				Replicas: pointer.Int32(2),
			},
		}

		err := k8sClient.Create(ctx, sample)
		Expect(err).ShouldNot(HaveOccurred())

		Eventually(func(g Gomega) {
			dep := &appsv1.Deployment{}
			err = k8sClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "test"}, dep)
			g.Expect(err).ShouldNot(HaveOccurred())

			g.Expect(dep.Spec.Replicas).Should(HaveValue(BeNumerically("==", 2)))
			g.Expect(dep.Spec.Template.Spec.Containers).Should(MatchAllElements(containerIdentity, Elements{
				"nginx": HaveField("Image", "nginx:1.14.2"),
			}))
		}).Should(Succeed())
	})
})

先ほどとは異なり、Eventuallyに渡した関数の中でkube-apiserverからDeploymentリソースを取得し、期待する値が得られたかどうかを確認しています。
このEventuallyに渡した関数は、成功するまで何度も実行されることになります。

なお、Eventuallyに渡した関数の中では、これまで使ってきたExpect()を利用することはできません。
代わりにg Gomegaを引数で渡し、g.Expect()を利用してください。

また、Eventuallyのタイムアウトは1秒、ポーリング間隔は10ミリ秒、Consistentlyの待ち時間は100ミリ秒、ポーリング間隔が10ミリ秒がデフォルトとして設定されています。
これらの時間はKubernetesのControllerの動作を考えるとやや短いかもしれません。

Eventuallyでは、以下のようにWithTimeoutWithPollingを利用するとタイムアウト時間やポーリング間隔を変更することができます。

    Eventually(func(g Gomega) error {
        // 略
    }).WithTimeout(10*time.Second).WithPolling(100*time.Millisecond).Should(Succeed())

しかし、毎回この設定を書くのは面倒なので、以下のようにエントリーポイント関数の中でデフォルト値を設定しておくのがおすすめです。

func TestAPIs(t *testing.T) {
	RegisterFailHandler(Fail)

	SetDefaultEventuallyTimeout(10 * time.Second)
	SetDefaultEventuallyPollingInterval(100 * time.Millisecond)
	SetDefaultConsistentlyDuration(3 * time.Second)
	SetDefaultConsistentlyPollingInterval(100 * time.Millisecond)

	RunSpecs(t, "Controller Suite")
}