5.3. Go 기반 Operator 생성

Operator 개발자는 Operator SDK의 Go 프로그래밍 언어 지원을 활용하여 분산형 키-값 저장소인 Memcached에 대한 Go 기반 Operator 예제를 빌드하고 라이프사이클을 관리할 수 있습니다.

참고

Kubebuilder 는 Go 기반 Operator의 스캐폴딩 솔루션으로 Operator SDK에 포함되어 있습니다.

5.3.1. Operator SDK를 사용하여 Go 기반 Operator 생성

Operator SDK를 사용하면 Kubernetes 네이티브 애플리케이션을 더 쉽게 빌드할 수 있으며 애플리케이션별 운영 지식이 필요할 수 있습니다. SDK는 이러한 장벽을 낮출 뿐만 아니라 미터링 또는 모니터링과 같은 많은 공통 관리 기능에 필요한 상용구 코드의 양을 줄이는 데 도움이 됩니다.

이 절차에서는 SDK에서 제공하는 툴과 라이브러리를 사용하여 간단한 Memcached Operator를 생성하는 예를 설명합니다.

사전 요구 사항

  • 개발 워크스테이션에 Operator SDK v0.19.4 CLI가 설치되어 있습니다.
  • Kubernetes 기반 클러스터에 설치된 OLM(Operator Lifecycle Manager) (애플리케이션 /v1beta2 API 그룹을 지원하기 위해 v1.8 이상) (예: OpenShift Container Platform 4.6
  • cluster -admin 권한이 있는 계정을 사용하여 클러스터에 대한 액세스
  • OpenShift CLI (oc) v4.6 이상이 설치됨

절차

  1. Operator 프로젝트를 생성합니다.

    1. 프로젝트에 사용할 디렉터리를 생성합니다.

      $ mkdir -p $HOME/projects/memcached-operator
    2. 디렉터리로 변경합니다.

      $ cd $HOME/projects/memcached-operator
    3. Go 모듈에 대한 지원을 활성화합니다.

      $ export GO111MODULE=on
    4. operator-sdk init 명령을 실행하여 프로젝트를 초기화합니다.

      $ operator-sdk init \
          --domain=example.com \
          --repo=github.com/example-inc/memcached-operator
      참고

      operator-sdk init 명령은 기본적으로 go.kubebuilder.io/v2 플러그인을 사용합니다.

  2. 지원되는 이미지를 사용하도록 Operator를 업데이트합니다.

    1. 프로젝트 루트 수준 Dockerfile에서 기본 러너 이미지 참조를 에서 변경합니다.

      FROM gcr.io/distroless/static:nonroot

      다음으로 변경합니다.

      FROM registry.access.redhat.com/ubi8/ubi-minimal:latest
    2. Go 프로젝트 버전에 따라 Dockerfile에 USER 65532:65532 또는 USER nonroot:nonroot 지시문이 포함될 수 있습니다. 두 경우 모두 지원되는 실행기 이미지에 행이 필요하지 않으므로 행을 제거합니다.
    3. config/default/manager_auth_proxy_patch.yaml 파일에서 image 값을 변경합니다.

      gcr.io/kubebuilder/kube-rbac-proxy:<tag>

      지원되는 이미지를 사용하려면 다음을 실행합니다.

      registry.redhat.io/openshift4/ose-kube-rbac-proxy:v4.6
  3. 다음 행을 대체하여 이후 빌드 중에 필요한 종속성을 설치하도록 Makefile에서 테스트 대상을 업데이트합니다.

    예 5.1. 기존 테스트 대상

    test: generate fmt vet manifests
            go test ./... -coverprofile cover.out

    다음 행을 사용하여 다음을 수행합니다.

    예 5.2. 업데이트된 테스트 대상

    ENVTEST_ASSETS_DIR=$(shell pwd)/testbin
    test: manifests generate fmt vet ## Run tests.
    	mkdir -p ${ENVTEST_ASSETS_DIR}
    	test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/v0.7.2/hack/setup-envtest.sh
    	source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test ./... -coverprofile cover.out
  4. CRD(사용자 정의 리소스 정의) API 및 컨트롤러를 생성합니다.

    1. 다음 명령을 실행하여 캐시 버전 v1 및 종류의 Memcached 그룹이 있는 API를 생성합니다.

      $ operator-sdk create api \
          --group=cache \
          --version=v1 \
          --kind=Memcached
    2. 메시지가 표시되면 리소스 및 컨트롤러 모두 생성하도록 y를 입력합니다.

      Create Resource [y/n]
      y
      Create Controller [y/n]
      y

      출력 예

      Writing scaffold for you to edit...
      api/v1/memcached_types.go
      controllers/memcached_controller.go
      ...

      이 프로세스는 api/v1/memcached_types.go 에 Memcached 리소스 API를 생성하고 controllers/memcached_controller.go 에 컨트롤러를 생성합니다.

    3. specstatus가 다음과 같도록 api/v1/memcached_types.go에서 Go 유형 정의를 수정합니다.

      // MemcachedSpec defines the desired state of Memcached
      type MemcachedSpec struct {
      	// +kubebuilder:validation:Minimum=0
      	// Size is the size of the memcached deployment
      	Size int32 `json:"size"`
      }
      
      // MemcachedStatus defines the observed state of Memcached
      type MemcachedStatus struct {
      	// Nodes are the names of the memcached pods
      	Nodes []string `json:"nodes"`
      }
    4. +kubebuilder:subresource:status 마커를 추가하여 CRD 매니페스트에 status 하위 리소스를 추가합니다.

      // Memcached is the Schema for the memcacheds API
      // +kubebuilder:subresource:status 1
      type Memcached struct {
      	metav1.TypeMeta   `json:",inline"`
      	metav1.ObjectMeta `json:"metadata,omitempty"`
      
      	Spec   MemcachedSpec   `json:"spec,omitempty"`
      	Status MemcachedStatus `json:"status,omitempty"`
      }
      1
      이 행을 추가합니다.

      그러면 컨트롤러에서 CR 오브젝트의 나머지 부분을 변경하지 않고도 CR 상태를 업데이트할 수 있습니다.

    5. 리소스 유형에 대해 생성된 코드를 업데이트합니다.

      $ make generate
      작은 정보

      *_types.go 파일을 수정한 후에는 make generate 명령을 실행하여 해당 리소스 유형에 대해 생성된 코드를 업데이트해야 합니다.

      위의 Makefile 대상은 controller-gen 유틸리티를 호출하여 api/v1/zz_generated.deepcopy.go 파일을 업데이트합니다. 이렇게 하면 API Go 유형 정의에서 모든 종류의 유형에서 구현해야 하는 runtime.Object 인터페이스를 구현할 수 있습니다.

  5. CRD 매니페스트를 생성하고 업데이트합니다.

    $ make manifests

    이 Makefile 대상은 controller-gen 유틸리티를 호출하여 config/crd/bases/cache.example.com_memcacheds.yaml 파일에서 CRD 매니페스트를 생성합니다.

    1. 선택 사항: CRD에 사용자 지정 검증을 추가합니다.

      매니페스트가 생성될 때 spec.validation 블록의 CRD 매니페스트에 OpenAPI v3.0 스키마가 추가됩니다. 이 검증 블록을 사용하면 Kubernetes가 생성 또는 업데이트될 때 Memcached CR(사용자 정의 리소스)의 속성을 검증할 수 있습니다.

      Operator 작성자는 Kubebuilder 마커 라는 주석과 같은 단일 줄 주석을 사용하여 API에 대한 사용자 정의 검증을 구성할 수 있습니다. 이러한 마커에는 항상 +kubebuilder:validation 접두사가 있어야 합니다. 예를 들어 열거형 사양을 추가하는 작업은 다음 마커를 추가하여 수행할 수 있습니다.

      // +kubebuilder:validation:Enum=Lion;Wolf;Dragon
      type Alias string

      API 코드의 마커 사용은 Kubebuilder Generating CRD 및 Markers for Config/Code Generation 설명서에서 설명합니다. 전체 OpenAPIv3 검증 마커 목록은 Kubebuilder CRD 검증 설명서에서도 사용할 수 있습니다.

      사용자 정의 검증을 추가하는 경우 다음 명령을 실행하여 CRD에 대한 OpenAPI 검증 섹션을 업데이트합니다.

      $ make manifests
  6. 새 API 및 컨트롤러를 생성하면 컨트롤러 논리를 구현할 수 있습니다. 이 예제에서는 생성된 컨트롤러 파일 controllers/memcached_controller.go를 다음 예제 구현으로 교체합니다.

    예 5.3. memcached_controller.go의 예

    /*
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at
    
        http://www.apache.org/licenses/LICENSE-2.0
    
    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.
    */
    
    package controllers
    
    import (
    	"context"
    	"reflect"
    
    	"github.com/go-logr/logr"
    	appsv1 "k8s.io/api/apps/v1"
    	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"
    
    	cachev1 "github.com/example-inc/memcached-operator/api/v1"
    )
    
    // MemcachedReconciler reconciles a Memcached object
    type MemcachedReconciler struct {
    	client.Client
    	Log    logr.Logger
    	Scheme *runtime.Scheme
    }
    
    // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete
    // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/status,verbs=get;update;patch
    // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
    // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;
    
    func (r *MemcachedReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    	ctx := context.Background()
    	log := r.Log.WithValues("memcached", req.NamespacedName)
    
    	// Fetch the Memcached instance
    	memcached := &cachev1.Memcached{}
    	err := r.Get(ctx, req.NamespacedName, memcached)
    	if err != nil {
    		if errors.IsNotFound(err) {
    			// Request object not found, could have been deleted after reconcile request.
    			// Owned objects are automatically garbage collected. For additional cleanup logic use finalizers.
    			// Return and don't requeue
    			log.Info("Memcached resource not found. Ignoring since object must be deleted")
    			return ctrl.Result{}, nil
    		}
    		// Error reading the object - requeue the request.
    		log.Error(err, "Failed to get Memcached")
    		return ctrl.Result{}, err
    	}
    
    	// Check if the deployment already exists, if not create a new one
    	found := &appsv1.Deployment{}
    	err = r.Get(ctx, types.NamespacedName{Name: memcached.Name, Namespace: memcached.Namespace}, found)
    	if err != nil && errors.IsNotFound(err) {
    		// Define a new deployment
    		dep := r.deploymentForMemcached(memcached)
    		log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
    		err = r.Create(ctx, dep)
    		if err != nil {
    			log.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
    			return ctrl.Result{}, err
    		}
    		// Deployment created successfully - return and requeue
    		return ctrl.Result{Requeue: true}, nil
    	} else if err != nil {
    		log.Error(err, "Failed to get Deployment")
    		return ctrl.Result{}, err
    	}
    
    	// Ensure the deployment size is the same as the spec
    	size := memcached.Spec.Size
    	if *found.Spec.Replicas != size {
    		found.Spec.Replicas = &size
    		err = r.Update(ctx, found)
    		if err != nil {
    			log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
    			return ctrl.Result{}, err
    		}
    		// Spec updated - return and requeue
    		return ctrl.Result{Requeue: true}, nil
    	}
    
    	// Update the Memcached status with the pod names
    	// List the pods for this memcached's deployment
    	podList := &corev1.PodList{}
    	listOpts := []client.ListOption{
    		client.InNamespace(memcached.Namespace),
    		client.MatchingLabels(labelsForMemcached(memcached.Name)),
    	}
    	if err = r.List(ctx, podList, listOpts...); err != nil {
    		log.Error(err, "Failed to list pods", "Memcached.Namespace", memcached.Namespace, "Memcached.Name", memcached.Name)
    		return ctrl.Result{}, err
    	}
    	podNames := getPodNames(podList.Items)
    
    	// Update status.Nodes if needed
    	if !reflect.DeepEqual(podNames, memcached.Status.Nodes) {
    		memcached.Status.Nodes = podNames
    		err := r.Status().Update(ctx, memcached)
    		if err != nil {
    			log.Error(err, "Failed to update Memcached status")
    			return ctrl.Result{}, err
    		}
    	}
    
    	return ctrl.Result{}, nil
    }
    
    // deploymentForMemcached returns a memcached Deployment object
    func (r *MemcachedReconciler) deploymentForMemcached(m *cachev1.Memcached) *appsv1.Deployment {
    	ls := labelsForMemcached(m.Name)
    	replicas := m.Spec.Size
    
    	dep := &appsv1.Deployment{
    		ObjectMeta: metav1.ObjectMeta{
    			Name:      m.Name,
    			Namespace: m.Namespace,
    		},
    		Spec: appsv1.DeploymentSpec{
    			Replicas: &replicas,
    			Selector: &metav1.LabelSelector{
    				MatchLabels: ls,
    			},
    			Template: corev1.PodTemplateSpec{
    				ObjectMeta: metav1.ObjectMeta{
    					Labels: ls,
    				},
    				Spec: corev1.PodSpec{
    					Containers: []corev1.Container{{
    						Image:   "memcached:1.4.36-alpine",
    						Name:    "memcached",
    						Command: []string{"memcached", "-m=64", "-o", "modern", "-v"},
    						Ports: []corev1.ContainerPort{{
    							ContainerPort: 11211,
    							Name:          "memcached",
    						}},
    					}},
    				},
    			},
    		},
    	}
    	// Set Memcached instance as the owner and controller
    	ctrl.SetControllerReference(m, dep, r.Scheme)
    	return dep
    }
    
    // labelsForMemcached returns the labels for selecting the resources
    // belonging to the given memcached CR name.
    func labelsForMemcached(name string) map[string]string {
    	return map[string]string{"app": "memcached", "memcached_cr": name}
    }
    
    // getPodNames returns the pod names of the array of pods passed in
    func getPodNames(pods []corev1.Pod) []string {
    	var podNames []string
    	for _, pod := range pods {
    		podNames = append(podNames, pod.Name)
    	}
    	return podNames
    }
    
    func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
    	return ctrl.NewControllerManagedBy(mgr).
    		For(&cachev1.Memcached{}).
    		Owns(&appsv1.Deployment{}).
    		Complete(r)
    }

    예제 컨트롤러는 각 Memcached CR에 대해 다음 조정 논리를 실행합니다.

    • Memcached 배포가 없는 경우 생성합니다.
    • 배포 크기가 Memcached CR 사양에 지정된 것과 같은지 확인합니다.
    • Memcached CR 상태를 memcached Pod의 이름으로 업데이트합니다.

    다음 두 하위 단계에서는 컨트롤러에서 리소스를 감시하는 방법과 조정 반복문이 트리거되는 방법을 검사합니다. 이러한 단계를 건너뛰어 Operator를 직접 빌드하고 실행할 수 있습니다.

    1. controllers/memcached_controller.go 파일에서 컨트롤러 구현을 검사하여 컨트롤러에서 리소스를 감시하는 방법을 확인합니다.

      SetupWithManager() 함수는 해당 컨트롤러가 소유하고 관리하는 CR 및 기타 리소스를 조사하기 위해 컨트롤러가 빌드되는 방법을 지정합니다.

      예 5.4. SetupWithManager() 함수

      import (
      	...
      	appsv1 "k8s.io/api/apps/v1"
      	...
      )
      
      func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
      	return ctrl.NewControllerManagedBy(mgr).
      		For(&cachev1.Memcached{}).
      		Owns(&appsv1.Deployment{}).
      		Complete(r)
      }

      NewControllerManagedBy()에서는 다양한 컨트롤러 구성을 허용하는 컨트롤러 빌더를 제공합니다.

      For(&cachev1.Memcached{})는 조사할 기본 리소스로 Memcached 유형을 지정합니다. Memcached 유형에 대한 각 추가, 업데이트 또는 삭제 이벤트의 경우 조정 반복문은 해당 Memcached 오브젝트의 조정 Request 인수(네임스페이스 및 이름 키로 구성됨)로 전송됩니다.

      Owns(&appsv1.Deployment{})는 조사할 보조 리소스로 Deployment 유형을 지정합니다. 이벤트 핸들러는 Deployment 유형, 즉 추가, 업데이트 또는 삭제 이벤트가 발생할 때마다 각 이벤트를 배포 소유자의 조정 요청에 매핑합니다. 이 경우 소유자는 배포가 생성된 Memcached 오브젝트입니다.

    2. 모든 컨트롤러에는 조정 반복문을 구현하는 Reconcile() 메서드가 포함된 조정기 오브젝트가 있습니다. 조정 반복문에는 캐시에서 기본 리소스 오브젝트인 Memcached를 찾는 데 사용되는 네임스페이스 및 이름 키인 Request 인수가 전달됩니다.

      예 5.5. 조정 반복문

      import (
      	ctrl "sigs.k8s.io/controller-runtime"
      
      	cachev1 "github.com/example-inc/memcached-operator/api/v1"
      	...
      )
      
      func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        // Lookup the Memcached instance for this reconcile request
        memcached := &cachev1.Memcached{}
        err := r.Get(ctx, req.NamespacedName, memcached)
        ...
      }

      Reconcile() 함수의 반환 값에 따라 조정 요청이 다시 큐에 추가될 수 있으며 루프가 다시 트리거될 수 있습니다.

      예 5.6. 대기열 논리 재지정

      // Reconcile successful - don't requeue
      return reconcile.Result{}, nil
      // Reconcile failed due to error - requeue
      return reconcile.Result{}, err
      // Requeue for any reason other than error
      return reconcile.Result{Requeue: true}, nil

      유예 기간 후 요청을 다시 큐에 추가하도록 Result.RequeueAfter 를 설정할 수 있습니다.

      예 5.7. 유예 기간 후 다시 큐에 추가

      import "time"
      
      // Reconcile for any reason other than an error after 5 seconds
      return ctrl.Result{RequeueAfter: time.Second*5}, nil
      참고

      주기적으로 CR을 조정하도록 RequeueAfter를 설정하여 Result를 반환할 수 있습니다.

      조정기, 클라이언트, 리소스 이벤트와의 상호 작용에 대한 자세한 내용은 Controller Runtime Client API 설명서를 참조하십시오.

추가 리소스