- Published on
Kubernetes custom controller 작성해보기
- Authors
- Name
- Jay
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