학습일지/kubernetes
if kakao 2022 - Kubernetes Controller를 위한 테스트코드 작성
inspirit941
2023. 2. 24. 00:15
반응형
Controller : Loop 돌면서 클러스터의 상태를 확인하고, 상태 변화 / 유지에 필요한 작업을 Request 보내거나 직접 수행하는 컴포넌트.
대표적인 예시가 replicaSet. ingress-nginx controller는 http routing rule 설정을 담당.
- 하나의 클러스터가 연관된 여러 리소스의 상태를 체크하고 유지시켜준다는 점에서는 편리함
- controller 로직에 문제가 있을 경우 연관된 리소스가 전부 영향을 받을 수 있다는 단점이 있음.
그러므로 테스트코드 작성이 중요함.
예시: 간단한 CRD와 Reconcile 로직 - BlueGreen CRD
아래 예시는 BlueGreen이라는 CRD를 관리하는 Controller를 생성하고, 테스트코드를 작성하는 방법.
BlueGreen CRD가 생성하는 리소스는
- Blue 라벨과 Green 라벨이 붙어 있는 Deployment 각각 1개
- Traffic 관리하는 Service 1개.
- CRD에 정의된 routeTo 필드를 보고 service의 labelSelector를 조정하는 식.
kubebuilder를 사용했으나, 원칙은 똑같으므로
- kubeOps
- OperatorFramework
- java Operator SDK
등 다른 환경에서도 응용할 수 있다.
Reconcile() 메소드에서
- CRD인 BlueGreen 리소스 읽어오기
- 읽어온 정보를 토대로 k8s Service 생성 / 업데이트
- Blue / Green이 CRD에 정의되어 있다면 Deployment도 생성해준다.
테스트해야 할 것들.
방법 1. Mocking
객체지향적 접근방식으로, 실제 테스트할 객체와 같은 인터페이스 구현체인 Mock Object를 생성함.
- 적절한 수준의 Abstraction이 선행되어야 함.
- 예컨대 위 예시의 경우는 kubernetes Client를 직접 사용해서 CRUD를 호출하고 있음.
- 여기서 k8s Client를 mocking해버리면, Reconcile 로직에서 호출하는 수많은 k8s Client 메소드 호출을 테스트해야 함.
- Create/Update Service, Create/Update Deploymen같은...
- 그래서 Mocking용 객체를 만들 때, 위 예시는 Service / Deployment의 Create, Update만 테스트함.
- 여기서 k8s Client를 mocking해버리면, Reconcile 로직에서 호출하는 수많은 k8s Client 메소드 호출을 테스트해야 함.
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)
- 호출 순서와 flow를 세밀하게 지정할 수 있으나, 코드가 길고 가독성이 좋지 않다
- 핵심로직인 CreateOrUpdateService / CreateOrUpdateDeployment의 동작은 검증되지 않음. 해당 함수를 호출했는지 아닌지만 체크함.
방법 2. fake Client
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"])
})
}
- 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
- 실제 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 */
///
})
})
- 실제 k8s 대상으로 테스트 진행. End to End 테스트에 적합.
- fake Client와 마찬가지로 최종 형상만 확인 가능하므로 세밀한 검증은 어려움
- 실제 네트워크 스택을 거쳐 만들어지고 polling 방식으로 검증하므로 시간, 리소스 비용이 큰 편
- 병렬로 테스트하려면 Concurrency 문제 해결이 필요함.
어떤 방식이 어떤 상황에 적절한가?
Mock : Reconciler의 Abstraction이 선행되어야 쓸만함
- Reconcile 함수 내부 로직을 Mock으로 추상화 -> 코드 가독성 향상.
- 추상화하지 않은 채 Reconciler를 Mock할 경우 로직 바뀌면 테스트코드를 다시 써야 한다.
Fake Client
- 이미 구현된 fake Client로 특정 로직의 동작여부를 테스트하기 쉬움.
- 로직의 중간과정을 검토할 때 사건의 전후관계를 검증하기 쉽지 않고, fake Client에서 구현되지 않은 기능이 있음.
Real Environment
- 동일한 환경에서 테스트 가능. 코드만으로도 테스트하려는 로직의 목적을 공유할 수 있음
- 테스트 비용이 비싸고, 길이가 길어진다
반응형