Published on

if(kakao)2022 Testing Kubernetes Controller 발표 따라 만들기

Authors
  • avatar
    Name
    Jay
    Twitter

if(kakao)2022 영상들 중에 Controller 관련 영상이 있어서 따라서 Controller를 작성해보았다. 아래와 같이 Custom Resource BlueGreen을 만들면, spec.routeTo에 따라서 ClusterIP service가 blue pod에 갈지 green pod에 갈지 결정하게 된다.

apiVersion: app.demo.kakao.com/v1
kind: BlueGreen
metadata:
  name: demo
spec:
  routeTo: Blue
  blueSpec:
    containers:
      - name: blue
        image: demo:version-blue
  greenSpec:
    containers:
      - name: green
        image: demo:version-green

kubebuilder로 API 생성

발표에서 공유된 CRD를 바탕으로 kubebuilder를 통해서 api를 만들었다.

go mod init blue_green_controller
kubebuilder init --domain demo.kakao.com
kubebuilder create api --group app --version v1 --kind BlueGreen

type 작성

이제 bluegreen_types.go를 CRD에 맞춰서 작성을 한다. 이제 RouteTo는 Blue와 Green 값만 가질수 있다라는 가정하에 아래와 같이 Constant를 정의하였다.

type BlueOrGreen string

const (
	Blue  BlueOrGreen = "Blue"
	Green BlueOrGreen = "Green"
)

그리고 BlueGreen Kind의 spec과 status를 아래와 같이 정의하였다.

type BlueGreenSpec struct {
	RouteTo   string      `json:"routeTo"`
	BlueSpec  *v1.PodSpec `json:"blueSpec,omitempty"`
	GreenSpec *v1.PodSpec `json:"greenSpec,omitempty"`
}

type BlueGreenStatus struct {
	RouteTo string `json:"routeTo"`
}

CRD 생성

make install로 해당 type으로 생성된 CRD를 Minikube cluser에 적용한다. 하지만 v1.PodSpec때문에 생성된 CRD file의 size가 커졌고, 아래와 같은 에러가 발생하였다.

The CustomResourceDefinition "bluegreens.app.demo.kakao.com" is invalid: metadata.annotations: Too long: must have at most 262144 bytes
make: *** [install] Error 1

make installkustomize build config/crd | kubectl apply -f -를 실행하게 되는데, kubectl apply는 client-side apply로 last-applied-configuration annotation을 추가하고 이 정보를 바탕으로 현재 resource의 state에서 어떤 것을 update할지 판단하게 된다. 그런데 client-side는 이제 kubectl apply로 하는 동안 controller나 다른 agent에 의해서 resource state가 변경되면 detect를 하지 못하고, 그냥 overwrite를 할 수 있게 된다. Kubernetes v1.22에서 stable 상태가 된 server-side apply를 적용하면, 이제 conflict를 detect해서 이제 update를 되는 것을 막을 수 있게 된다.

v1.PodSpec때문에 CRD의 크기가 커졌고, kubectl apply를 통해서 annotation이 추가되니 사이즈 제한을 넘어서게 된 것이다. 따라서 client-side apply 대신에 server-side apply를 사용하여 이러한 annotation 생성없이 CRD를 생성하도록 해서 해결 할 수 있다. make manifests로 CRD file을 생성하고, 아래와 같이 별도로 CRD를 cluster에 생성하도록 하였다.

kustomize build config/crd | kubectl apply --server-side=true -f -

Controller Business logic 추가

그리고 이제 BlueGreen resource를 contoller하기 위해서 bluegreen_controller.go에 business logic을 추가한다. 발표자료에서 이 business logic을 공유해주었기 때문에 동일하게 작성을 하였다.

메모리 할당 new vs &something

Custom resource At을 control하는 business logic을 작성할 때는, 아래와 같이 instance 변수에 메모리를 할당하였다.

instance := &v1.BlueGreen{} // return a pointer

하지만 발표자료에서는 아래처럼 new function을 사용했다.

instance := new(v1.BlueGreen)

struct promoted field

At Controller에서는 아래처럼 struct type가 정의가 되었을 때, client.Client가 promoted field가 되어서 Client를 통해서 resource를 GET할 때 r.Get()으로 작성했다. 하지만 발표자료에서는 r.Client.Get()로 name을 명시적으로 작성하였다.

type BlueGreenReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

initialization statement

발표자료에서는 initialization statement을 사용했다.

if err := r.Client.Get(ctx, req.NamespacedName, blueGreen); err != nil {
  return ctrl.Result{}, client.IgnoreNotFound(err)
}

Client.IgnoreNotFound

At Controller에서는 resource를 찾을 수 없는 에러인지 이렇게 errors package를 통해서 확인을 했다.

if errors.IsNotFound(err) {
  return reconcile.Result{}, nil
}

하지만 발표자료에서는 Client의 IgnoreNotFound을 사용하여, NotFound Error일 때는 nil를 반환하도록 하였다.

return ctrl.Result{}, client.IgnoreNotFound(err)

declarative error

발표자료에서 fmt.Errorf 함수를 사용하여 custom message를 에러에 추가하여 어떤 에러인지 알기 쉽게 하였다.

fmt.Errorf("fail to set owner reference for a service: %w", err)

pointer type

발표자료에서 range를 통해서 for loop iteration을 하도록 작성하였다. 그런데 struct에 Spec의 Type이 *corev1.PodSpec으로 정의되어서 pointer를 사용하도록 하였다.

for _, tgt := range []struct {
  Phase v1.BlueOrGreen
  Spec  *corev1.PodSpec
}{
  {Phase: v1.Blue, Spec: blueGreen.Spec.BlueSpec},
  {Phase: v1.Green, Spec: blueGreen.Spec.GreenSpec},
}

따라서 BlueGreenSpec에서도 동일하게 type을 정의하였다.

type BlueGreenSpec struct {
	RouteTo   string      `json:"routeTo"`
	BlueSpec  *v1.PodSpec `json:"blueSpec,omitempty"`
	GreenSpec *v1.PodSpec `json:"greenSpec,omitempty"`
}

그래서 value를 가져오기 위해서 아래처럼 *tgt.Spec으로 작성되었다.

deploy.Spec.Template = corev1.PodTemplateSpec{
  ObjectMeta: metav1.ObjectMeta{Labels: label},
  Spec:       *tgt.Spec,
}

전체 business logic code

Service를 CreateOrUpdate 함수로 생성하거나 업데이트를 하고, CRD에서 BlueSpec, GreenSpec에 정의된 container image로 Deployment resource를 두 개 생성하거나 업데이트하게 된다. 이제 service의 selector를 통해서 blue pod 요청할지 green pod에 요청할지 결정하게 된다.

package controller

import (
	"context"
	"fmt"
	"strings"

	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/log"

	v1 "blue_green_controller/api/v1"
)

// BlueGreenReconciler reconciles a BlueGreen object
type BlueGreenReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=app.demo.kakao.com,resources=bluegreens,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=app.demo.kakao.com,resources=bluegreens/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=app.demo.kakao.com,resources=bluegreens/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.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the BlueGreen object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/reconcile
func (r *BlueGreenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := log.FromContext(ctx)
	log.Info("=== Reconciling BlueGreen")

	blueGreen := new(v1.BlueGreen)
	if err := r.Client.Get(ctx, req.NamespacedName, blueGreen); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: req.Namespace, Name: req.Name}}
	if _, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error {
		if err := ctrl.SetControllerReference(blueGreen, svc, r.Scheme); err != nil {
			return fmt.Errorf("fail to set owner reference for a service: %w", err)
		}
		svc.Spec.Ports = []corev1.ServicePort{
			{Name: "http", Protocol: corev1.ProtocolTCP, Port: 80},
		}
		svc.Spec.Selector = map[string]string{
			"app.kubernetes.io/managed-by": "app.demo.kakao.com",
			"app.kubernetes.io/name":       req.Name,
			"app.kubernetes.io/phase":      string(blueGreen.Spec.RouteTo),
		}
		return nil
	}); err != nil {
		return ctrl.Result{}, fmt.Errorf("fail to create (or update) service: %w", err)
	}

	for _, tgt := range []struct {
		Phase v1.BlueOrGreen
		Spec  *corev1.PodSpec
	}{
		{Phase: v1.Blue, Spec: blueGreen.Spec.BlueSpec},
		{Phase: v1.Green, Spec: blueGreen.Spec.GreenSpec},
	} {
		if tgt.Spec == nil {
			continue
		}
		deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: req.Namespace, Name: req.Name + strings.ToLower(string(tgt.Phase)) + "-deployment"}}
		if _, err := ctrl.CreateOrUpdate(ctx, r.Client, deploy, func() error {
			if err := ctrl.SetControllerReference(blueGreen, deploy, r.Scheme); err != nil {
				return fmt.Errorf("fail to set owner reference for a deployment: %w", err)
			}
			label := map[string]string{
				"app.kubernetes.io/managed-by": "app.demo.kakao.com",
				"app.kubernetes.io/name":       req.Name,
				"app.kubernetes.io/phase":      string(tgt.Phase),
			}
			deploy.Spec.Selector = &metav1.LabelSelector{MatchLabels: label}
			deploy.Spec.Template = corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{Labels: label},
				Spec:       *tgt.Spec,
			}
			return nil
		}); err != nil {
			return ctrl.Result{}, fmt.Errorf("fail to create (or update) deployment: %w", err)
		}
	}

	blueGreen.Status = v1.BlueGreenStatus{
		RouteTo: blueGreen.Spec.RouteTo,
	}
	if err := r.Client.Status().Update(ctx, blueGreen); err != nil {
		return ctrl.Result{}, fmt.Errorf("fail to update bluegreen status: %w", err)
	}
	return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *BlueGreenReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&v1.BlueGreen{}).
		Complete(r)
}

로컬에서 동작 확인

make run으로 이제 해당 controller을 local에서 실행하고, custom resource BlueGreen을 Minikube cluster에 생성한다. routeTo를 Blue로 선택했기 때문에 echo server가 실행되고 있는 container로 요청하게 된다. 이제 Green으로 변경하면 Terminated된 busybox container로 요청하여 정상적인 응답을 못 받게 된다.

apiVersion: app.demo.kakao.com/v1
kind: BlueGreen
metadata:
  name: demo
spec:
  routeTo: Blue
  blueSpec:
    containers:
      - name: blue
        image: ealen/echo-server
  greenSpec:
    containers:
      - name: green
        image: busybox