Published on

Kubernetes custom controller 작성해보기

Authors
  • avatar
    Name
    Jay
    Twitter

Programming Kubernetes 책에서 나온 예제를 따라서 custom controller를 작성해보았다.

curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)"\n
chmod +x kubebuilder && mv kubebuilder /usr/local/bin/
kubebuilder init --domain study.jayground8
kubebuilder create api --group cnat --version v1alpha1 --kind At

이제 Kind At의 Spec에 언제 실행될지 Schdule과 어떤 명령어가 실행될지 Command 프로퍼티들을 가지도록 정의한다. 그리고 Kind At의 Status는 Phase 프로퍼티를 가지고, PENDING, RUNNING, DONE 값을 가지도록 할 것이다.

api/v1alpha1/at_types.go

const (
	PhasePending = "PENDING"
	PhaseRunning = "RUNNING"
	PhaseDone    = "DONE"
)

type AtSpec struct {
	Schedule string `json:"schedule,omitempty"`
	Command  string `json:"command,omitempty"`
}

type AtStatus struct {
	Phase string `json:"phase,omitempty"`
}

make manifest 명령어를 실행하면 /config/crd/bases/cnat.study.jayground8_ats.yaml 파일이 아래처럼 생성된다. at_types.go에 정의한 내용대로 CustomResourceDefinition 정의가 되었다. 내가 참고한 Programming Kubernetes 책은 2019년에 발간되었고, apiextensions.k8s.io/v1beta1 버전을 사용하고 있다. 하지만 Kubernetes v1.22에서 apiextensions.k8s.io/v1beta1는 제거 되었다. 나는 Minikube로 Local에서 Kubernetes v1.26.3으로 테스트를 해보고 있다. v1에서 변경된 점을 문서로 확인하였고, 책에서 사용된 예제에서는 아래와 같은 점이 변경이 되었다.

  • spec.version is removed in v1; use spec.versions instead
  • spec.subresources is removed in v1; use spec.versions[*].subresources instead
  • spec.versions[*] .schema.openAPIV3Schema is now required when creating v1 CustomResourceDefinition objects, and must be a structural schema
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.12.0
  name: ats.cnat.study.jayground8
spec:
  group: cnat.study.jayground8
  names:
    kind: At
    listKind: AtList
    plural: ats
    singular: at
  scope: Namespaced
  versions:
    - name: v1alpha1
      schema:
        openAPIV3Schema:
          description: At is the Schema for the ats API
          properties:
            apiVersion:
              type: string
            kind:
              type: string
            metadata:
              type: object
            spec:
              properties:
                command:
                  type: string
                schedule:
                  type: string
              type: object
            status:
              properties:
                phase:
                  type: string
              type: object
          type: object
      served: true
      storage: true
      subresources:
        status: {}

OpenAPI V3 schema를 정의하면 apiextensions-apiserver에서 validation을 하게 된다. 그리고 kubectl를 사용할 때 해당 정보를 통해서 client-side validation을 한다. required field를 설정하려면 아래와 같이 추가할 수 있다.

// AtSpec defines the desired state of At
type AtSpec struct {
	// +kubebuilder:validation:Required
	Schedule string `json:"schedule"`
	// +kubebuilder:validation:Required
	Command  string `json:"command"`
}

다시 make manifest를 해보면 아래와 같이 required가 추가된 것을 확인할 수 있다.

spec:
  description: AtSpec defines the desired state of At
  properties:
    command:
      type: string
    schedule:
      type: string
  required:
    - command
    - schedule
  type: object

make install로 변경된 CRD를 적용하고, 아래처럼 required로 정의된 command property가 빠지면 The At "example-at" is invalid: spec.command: Required value 메세지를 보여주게 된다.

apiVersion: cnat.study.jayground8/v1alpha1
kind: At
metadata:
  labels:
    controller-tools.k8s.io: '1.0'
  name: example-at
spec:
  schedule: '2023-07-14T10:12:00Z'

이제 특정 Regex로 validation을 설정할 수 있는데, kubebuilder에서 아래처럼 정의하여 make manifest를 할 수 있다. schedule property는 regex pattern을 정의해놨기 때문에,regex가 맞지 않으면 The At "example-at" is invalid: spec.schedule: Invalid value: "2019-04-12T10:12Z": spec.schedule in body should match '^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d' 메세지를 보여주게 된다.

// AtSpec defines the desired state of At
type AtSpec struct {
	// +kubebuilder:validation:Required
	// +kubebuilder:validation:Pattern=`^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d`
	Schedule string `json:"schedule"`
	// +kubebuilder:validation:Required
	Command  string `json:"command"`
}

기본 설정에서 kubectl로 Kind At을 가져왔을 때 아래처럼 보이게 된다.

$ kubectl get ats
NAME         AGE
example-at   4s

additionalPrinterColumns를 아래와 같이 Kubebuilder에서 정의해주면

//+kubebuilder:printcolumn:name="schedule",type=string,JSONPath=`.spec.schedule`
//+kubebuilder:printcolumn:name="command",type=string,JSONPath=`.spec.command`
//+kubebuilder:printcolumn:name="phase",type=string,JSONPath=`.status.phase`

// At is the Schema for the ats API
type At struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   AtSpec   `json:"spec,omitempty"`
	Status AtStatus `json:"status,omitempty"`
}

이제는 아래처럼 정의된 추가 정보가 보이게 된다.

$ kubectl get ats
NAME         SCHEDULE               COMMAND    PHASE
example-at   2023-07-14T10:12:00Z   echo YAY   DONE

Custom Resource는 subresource로 /status와 /scale이 지원된다. kubebuilder에서 아래와 같이 subresource status를 enable하도록 설정했다.

//+kubebuilder:subresource:status

따라서 subresource를 통해서 RBAC를 아래와 같이 분리할 수 있다. metadata나 spec 데이터를 같이 보내도 그것들은 무시하고 status만 업데이트 할 수 있다.

- apiGroups:
    - cnat.study.jayground8
  resources:
    - ats/status
  verbs:
    - get
    - patch
    - update

이제 internal/controller 경로의 at_controller.go에 business logic을 추가한다. At resource를 가져오고 상태값에 따라서 로직을 수행한다. CRD의 schedule의 시간이 지나면 이제 phase를 RUNNING으로 변경하고, busybox에 CRD의 command 명령어를 실행하기 위한 pod를 name과 namespace로 가져와서 없으면 이제 pod를 생성하게 된다. pod의 상태값이 failed나 succeeded이면 이제 phase는 DONE이 된다.

return 되는 것에 따라서 다시 reconcilation을 하게 된다.

  • return reconcile.Result{}, nil : 이제 reconcilation이 성공해서 retry를 할 필요가 없다.
  • return reconcile.Result{}, err : error가 발생해서 retry가 필요하다.
  • return reconcile.Result{RequeueAfter: d}, nil : d만큼 기다렸다가 다시 work queue에 들어가서 다시 reconcilation을 하게 된다. Schedule 값이 아직 미래라면 그때까지 기다렸다가 다시 Reconcilation을 하도록 한다.

SetControllerReference는 Custom resource에 pod가 자식 관계를 가지게 한다. 그래서 custom resource가 delete되면 관계된 pod도 GC되도록 하려고 설정한다.

err := controllerutil.SetControllerReference(instance, pod, r.Scheme)

subresource status를 enable했기 때문에 이렇게 Status만 업데이트 할 수 있다.

err = r.Status().Update(context.TODO(), instance)

최종적으로 아래와 같이 작성이 되었다.

package controller

import (
	"context"
	"strings"
	"time"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"

	cnatv1alpha1 "my_controller/api/v1alpha1"
)

var globalLog = logf.Log.WithName("global")

// AtReconciler reconciles a At object
type AtReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;create;watch;
//+kubebuilder:rbac:groups=cnat.study.jayground8,resources=ats,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=cnat.study.jayground8,resources=ats/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=cnat.study.jayground8,resources=ats/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 At 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 *AtReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	reqLogger := globalLog.WithValues("namespace", req.Namespace, "at", req.Name)
	reqLogger.Info("=== Reconciling At")
	// TODO(user): your logic here
	instance := &cnatv1alpha1.At{}
	err := r.Get(context.TODO(), req.NamespacedName, instance)
	if err != nil {
		if errors.IsNotFound(err) {
			return reconcile.Result{}, nil
		}
		return reconcile.Result{}, err
	}

	if instance.Status.Phase == "" {
		instance.Status.Phase = cnatv1alpha1.PhasePending
	}

	switch instance.Status.Phase {
	case cnatv1alpha1.PhasePending:
		d, err := timeUntilSchedule(instance.Spec.Schedule)
		if err != nil {
			return reconcile.Result{}, err
		}
		if d > 0 {
			return reconcile.Result{RequeueAfter: d}, nil
		}
		instance.Status.Phase = cnatv1alpha1.PhaseRunning
	case cnatv1alpha1.PhaseRunning:
		pod := newPodForCR(instance)
		err := controllerutil.SetControllerReference(instance, pod, r.Scheme)
		if err != nil {
			return reconcile.Result{}, err
		}
		found := &corev1.Pod{}
		nsName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace}
		err = r.Get(context.TODO(), nsName, found)
		if err != nil && errors.IsNotFound(err) {
			err = r.Create(context.TODO(), pod)
			if err != nil {
				return reconcile.Result{}, err
			}
		} else if err != nil {
			return reconcile.Result{}, err
		} else if found.Status.Phase == corev1.PodFailed || found.Status.Phase == corev1.PodSucceeded {
			instance.Status.Phase = cnatv1alpha1.PhaseDone
		} else {
			return reconcile.Result{}, nil
		}
	case cnatv1alpha1.PhaseDone:
		return reconcile.Result{}, nil
	default:
		return reconcile.Result{}, nil
	}

	err = r.Status().Update(context.TODO(), instance)
	if err != nil {
		return reconcile.Result{}, nil
	}

	return reconcile.Result{}, nil
}

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

func timeUntilSchedule(schedule string) (time.Duration, error) {
	now := time.Now().UTC()
	layout := "2006-01-02T15:04:05Z"
	s, err := time.Parse(layout, schedule)
	if err != nil {
		return time.Duration(0), err
	}
	return s.Sub(now), nil
}

func newPodForCR(cr *cnatv1alpha1.At) *corev1.Pod {
	labels := map[string]string{
		"app": cr.Name,
	}
	return &corev1.Pod{
		ObjectMeta: metav1.ObjectMeta{
			Name:      cr.Name + "-pod",
			Namespace: cr.Namespace,
			Labels:    labels,
		},
		Spec: corev1.PodSpec{
			Containers: []corev1.Container{
				{
					Name:    "busybox",
					Image:   "busybox",
					Command: strings.Split(cr.Spec.Command, " "),
				},
			},
			RestartPolicy: corev1.RestartPolicyOnFailure,
		},
	}
}

아래와 같이 image를 생성하고, minikube에 해당 image를 load한다.

make docker-build IMG=controller:latest
minikube image load controller:latest

그리고 local에 있는 image를 사용도록 아래처럼 imagePullPolicy를 변경해줬다.

/config/manager/manager.yaml

image: controller:latest
imagePullPolicy: Never

마지막으로 Minikube cluster에 배포를 한다.

make deploy

controller business logic중에 pod에 대해서 get하고 create하는게 있기 때문에, RBAC에 pod 관련 권한을 주기 위해서 at_controller.go에서 아래처럼 설정해놨다.

//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;create;watch;

custom resource At을 생성하면, informer가 event를 받아서 busybox container를 생성하여 command를 수행하는 것을 확인할 수 있다.

kubectl apply -f config/crd/samples/cnat.study.jayground8_ats.yaml