공부하고 기록하는, 경제학과 출신 개발자의 노트

학습일지/kubernetes

if kakao 2022 - Kubernetes Controller를 위한 테스트코드 작성

inspirit941 2023. 2. 24. 00:15
반응형

https://youtu.be/B80-GByJFEA

 

스크린샷 2023-02-23 오후 9 09 12

 

스크린샷 2023-02-23 오후 9 12 35

 

 

 

Controller : Loop 돌면서 클러스터의 상태를 확인하고, 상태 변화 / 유지에 필요한 작업을 Request 보내거나 직접 수행하는 컴포넌트.

대표적인 예시가 replicaSet. ingress-nginx controller는 http routing rule 설정을 담당.

 

 

스크린샷 2023-02-23 오후 9 15 24

 

  • 하나의 클러스터가 연관된 여러 리소스의 상태를 체크하고 유지시켜준다는 점에서는 편리함
  • controller 로직에 문제가 있을 경우 연관된 리소스가 전부 영향을 받을 수 있다는 단점이 있음.
    그러므로 테스트코드 작성이 중요함.

 

예시: 간단한 CRD와 Reconcile 로직 - BlueGreen CRD

아래 예시는 BlueGreen이라는 CRD를 관리하는 Controller를 생성하고, 테스트코드를 작성하는 방법.

 

스크린샷 2023-02-23 오후 9 18 00스크린샷 2023-02-23 오후 9 18 21

 

BlueGreen CRD가 생성하는 리소스는

  • Blue 라벨과 Green 라벨이 붙어 있는 Deployment 각각 1개
  • Traffic 관리하는 Service 1개.
    • CRD에 정의된 routeTo 필드를 보고 service의 labelSelector를 조정하는 식.

 

스크린샷 2023-02-23 오후 9 23 24

 

kubebuilder를 사용했으나, 원칙은 똑같으므로

  • kubeOps
  • OperatorFramework
  • java Operator SDK

등 다른 환경에서도 응용할 수 있다.


스크린샷 2023-02-23 오후 9 27 16

 

Reconcile() 메소드에서

  • CRD인 BlueGreen 리소스 읽어오기
  • 읽어온 정보를 토대로 k8s Service 생성 / 업데이트
  • Blue / Green이 CRD에 정의되어 있다면 Deployment도 생성해준다.

 

스크린샷 2023-02-23 오후 9 27 16

 

테스트해야 할 것들.


방법 1. Mocking

스크린샷 2023-02-23 오후 9 31 25스크린샷 2023-02-23 오후 9 32 43

 

객체지향적 접근방식으로, 실제 테스트할 객체와 같은 인터페이스 구현체인 Mock Object를 생성함.

  • 적절한 수준의 Abstraction이 선행되어야 함.
  • 예컨대 위 예시의 경우는 kubernetes Client를 직접 사용해서 CRUD를 호출하고 있음.
    • 여기서 k8s Client를 mocking해버리면, Reconcile 로직에서 호출하는 수많은 k8s Client 메소드 호출을 테스트해야 함.
      • Create/Update Service, Create/Update Deploymen같은...
    • 그래서 Mocking용 객체를 만들 때, 위 예시는 Service / Deployment의 Create, Update만 테스트함.

 

스크린샷 2023-02-23 오후 9 38 12스크린샷 2023-02-23 오후 9 38 17

 

goMock 라이브러리 사용.

// INIT -> mock 객제 초기화
c := bluegreen.NewMockClient(ctrl)
r := &BlueGreenReconciler{
 Client: c,
 Scheme: testScheme,
}

// EXPECTING
c.EXPECT(). // 파라미터 두 개가 들어올 것인지 (service, deployment)
 Get(gomock.Any(), gomock.Eq(req.NamespacedName), gomock.Any()).
 SetArg(2, bg).
 Return(nil)


c.EXPECT(). // service 생성이 이루어졌는지
 CreateOrUpdateService(gomock.Any(),
    &GenericMatcher[*appv1.BlueGreen]{
    func(in *appv1.BlueGreen) bool { return reflect.DeepEqual(bg, *in) },
    }).
    Return(nil)
c.EXPECT(). // Deployment 생성이 이루어졌는지
 CreateOrUpdateDeployment(gomock.Any(),
    &GenericMatcher[*appv1.BlueGreen]{
    func(in *appv1.BlueGreen) bool { return reflect.DeepEqual(bg, *in) },
    },
    gomock.Eq(appv1.Blue), gomock.Eq(*bg.Spec.BlueSpec)).
 Return(nil)
c.EXPECT().UpdateStatus(gomock.Any(),
    &GenericMatcher[*appv1.BlueGreen]{func(bg *appv1.BlueGreen) bool { return *bg.Status.RouteTo == appv1.Blue }},
).Return(nil)

// Reconcile 실행
result, err := r.Reconcile(ctx, req)

// Validation
assert.NoError(t, err)
assert.Equal(t, reconcile.Result{Requeue: false}, result)

스크린샷 2023-02-23 오후 9 44 30스크린샷 2023-02-23 오후 9 44 58

 

  • 호출 순서와 flow를 세밀하게 지정할 수 있으나, 코드가 길고 가독성이 좋지 않다
  • 핵심로직인 CreateOrUpdateService / CreateOrUpdateDeployment의 동작은 검증되지 않음. 해당 함수를 호출했는지 아닌지만 체크함.

방법 2. fake Client

스크린샷 2023-02-23 오후 9 45 05

 

k8s 리소스의 manifest만 key-value store에 저장하고, 호출 시 리턴해주는 fake client 객체.

// CreateOrUpdateService의 정상동작 여부를 테스트하는 코드.
func TestBlueGreenReconciler_CreateOrUpdateService(t *testing.T) {
 t.Run("Should Create a New Service", func(t *testing.T) {

    // 1. fakeClient 객체 초기화
    c := fake.NewClientBuilder().WithScheme(testScheme).Build()
    r := &BlueGreenClient{Client: c}
    NN := types.NamespacedName{
        Namespace: "test-namespace",
        Name: "test-name",
    }

    svc := new(corev1.Service)
    assert.Error(t, c.Get(context.TODO(), NN, svc))

    // 2. 테스트 대상이 되는 메소드 실행
    err := r.CreateOrUpdateService(
        context.TODO(),
        &appv1.BlueGreen{
        ObjectMeta: metav1.ObjectMeta{Namespace: NN.Namespace, Name: NN.Name},
        Spec: appv1.BlueGreenSpec{RouteTo: appv1.Blue},
    })

    // 3. fake client로부터 결과 회신.
    assert.NoError(t, err)
    assert.NoError(t, c.Get(context.TODO(), NN, svc))

    // 4. Validation
    assert.Equal(t, 1, len(svc.ObjectMeta.OwnerReferences))
    assert.Equal(t, "app.demo.kakao.com", svc.Spec.Selector["app.kubernetes.io/managed-by"])
    assert.Equal(t, "Blue", svc.Spec.Selector["app.kubernetes.io/phase"])
    assert.Equal(t, NN.Name, svc.Spec.Selector["app.kubernetes.io/name"])
 })
}

 

스크린샷 2023-02-23 오후 9 50 56

 

  • k8s가 실제 작동하는 것처럼 CRUD 테스트를 할 수 있음
  • 호출 순서와 같은 디테일한 부분을 검증할 수 없고, k8s 로직이 전부 fake Client에 구현되어 있지 않으며, golang을 제외한 다른 언어는 아직 지원하지 않음.

특히, 현 시점에서 Fake Client에 구현되지 않은 부분은

  • k8s Garbage Collection / Finalizer
  • Validating, Mutating Admission Webhook
  • Resource Versioning
  • Defaulting

등등.

cf. knative에서 다 쓰는 것들인데...


방법 3. Real Test in Local Env

스크린샷 2023-02-23 오후 11 30 24

  • 실제 manifest를 선언하고, 리소스의 최종 형상을 직접 검증하는 방식.
  • 진짜 리소스를 만드는 것이므로 사용자가 테스트할 부분을 제한 없이 결정할 수 있음.
  • kubeBuilder에서도 권장하는 테스트 방식.
// 테스트 시작 전, 초기 환경설정 코드
var _ = BeforeSuite(func() {
    logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
    ctx, cancel = context.WithCancel(context.TODO())
    By("bootstrapping test environment")

    testEnv = &envtest.Environment{
        CRDDirectoryPaths: []string{
            filepath.Join("..", "config", "crd", "bases")
        },
        ErrorIfCRDPathMissing: true,
    }
    var err error

    // cfg is defined in this file globally.
    // 1. kube-apiserver 프로세스의 자식 프로세스로 etcd를 먼저 실행해준다.
    cfg, err = testEnv.Start()
    Expect(err).NotTo(HaveOccurred())
    Expect(cfg).NotTo(BeNil())

    err = appv1.AddToScheme(scheme.Scheme)
    Expect(err).NotTo(HaveOccurred())

    // 2. kube-apiserver / etcd 프로세스에서 k8s Client를 생성한다.
    //+kubebuilder:scaffold:scheme
    k8sClient, err = client.New(cfg, client.Options{
        Scheme: scheme.Scheme
    })
    Expect(err).NotTo(HaveOccurred())
    Expect(k8sClient).NotTo(BeNil())

    k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
        Scheme: scheme.Scheme,
    })
    Expect(err).ToNot(HaveOccurred())

    // 3. k8s Client에서 BlueGreen CRD를 초기화한다.
    err = (&BlueGreenReconciler{
        Client: &bluegreen.BlueGreenClient{
            Client: k8sManager.GetClient(),
        },
        Scheme: k8sManager.GetScheme(),
    }).SetupWithManager(k8sManager)

    Expect(err).ToNot(HaveOccurred())

    // 4. 별도의 goroutine에서 BlueGreen Controller를 실행한다.
    go func() {
        defer GinkgoRecover()
        err = k8sManager.Start(ctx)
        Expect(err).ToNot(HaveOccurred(), "failed to run manager")
    }()
}, 60)

 

아래의 코드는 Behavior-Driven Development 지향하는 테스트 코드 프레임워크 ginkgo 사용한 예시

var _ = Describe("BlueGreen controller", func() {
    const (
        timeout = time.Second * 10
        duration = time.Second * 10
        interval = time.Millisecond * 250
    )
    // BlueGreen 리소스 생성
    Context("When creating a New BlueGreen Resource", func() {
        // Blue Spec만 정의한 테스트 케이스
        Context("When defining Blue Spec only", func() {
            // service 1개, deployment 1개만 생성되어야 한다.
            It("Should Create a One Service and a One Deployment", func() {
                Expect(
                    // 환경설정에서 세팅한 k8s Client에 
                    // 실제로 service, deployment의 Create을 실행
                    k8sClient.Create(ctx,
                        &v1.BlueGreen{
                            ObjectMeta: metav1.ObjectMeta{
                                Name: "bluegreen-test",
                                Namespace: "default",
                            },
                            Spec: v1.BlueGreenSpec{
                                RouteTo: v1.Blue,
                                BlueSpec: &corev1.PodSpec{
                                    Containers: []corev1.Container{
                                        {Name: "blue", Image: "nginx"},
                                    },
                                },
                            },
                        },
                    ),
                ).Should(Succeed())

                // polling 방식으로 실제 리소스가 생성되었는지 체크.
                svcList := &corev1.ServiceList{}
                Eventually(func() bool {
                    if err := k8sClient.List(ctx, svcList); err != nil {
                        return false
                    }
                    return len(svcList.Items) != 0
                }, timeout, interval).Should(BeTrue())
                Expect(len(svcList.Items)).To(Equal(1))

                /* More Validation Logics here */
                ///
            })
        })
        /* More Test Scenarios here */
        ///
    })
})

 

스크린샷 2023-02-23 오후 11 47 51

 

  • 실제 k8s 대상으로 테스트 진행. End to End 테스트에 적합.
  • fake Client와 마찬가지로 최종 형상만 확인 가능하므로 세밀한 검증은 어려움
  • 실제 네트워크 스택을 거쳐 만들어지고 polling 방식으로 검증하므로 시간, 리소스 비용이 큰 편
  • 병렬로 테스트하려면 Concurrency 문제 해결이 필요함.

어떤 방식이 어떤 상황에 적절한가?

스크린샷 2023-02-23 오후 11 52 04스크린샷 2023-02-23 오후 11 52 13

 

Mock : Reconciler의 Abstraction이 선행되어야 쓸만함

  • Reconcile 함수 내부 로직을 Mock으로 추상화 -> 코드 가독성 향상.
  • 추상화하지 않은 채 Reconciler를 Mock할 경우 로직 바뀌면 테스트코드를 다시 써야 한다.

스크린샷 2023-02-23 오후 11 52 22스크린샷 2023-02-23 오후 11 52 29

 

Fake Client

  • 이미 구현된 fake Client로 특정 로직의 동작여부를 테스트하기 쉬움.
  • 로직의 중간과정을 검토할 때 사건의 전후관계를 검증하기 쉽지 않고, fake Client에서 구현되지 않은 기능이 있음.

스크린샷 2023-02-23 오후 11 52 36스크린샷 2023-02-23 오후 11 52 43

 

Real Environment

  • 동일한 환경에서 테스트 가능. 코드만으로도 테스트하려는 로직의 목적을 공유할 수 있음
  • 테스트 비용이 비싸고, 길이가 길어진다
반응형