Kubernetes Operator를 만들어보자 2일차 - Operator 설계

0

Kubernetes Operator

목록 보기
2/5

Operator 설계 - CRD, API, target reconciliation

operator를 설계해보면서 어떻게 만들지 고민해보도록 하자.

API와 CRD 설계

CRD의 사용은 operator의 특징을 정의하는데, CRD를 통해 operator가 object를 생성해 user와 상호작용할 수 있도록 해준다. 이 object가 바로 operator를 제어하는 interface인 것이다. 즉, Custom Resource(CR) object는 operator의 main function에 대한 창문과도 같다.

창문이 깨끗해야지 내부가 보이고, 단단해야지 안전한 것처럼 CRD 역시도 잘 디자인되어야 operator의 기능과 보안성 측면에서 좋은 평가를 받을 수 있다.

CRD는 kubernetes API안에 존재하기 때문에 API와 상호작용 할 때, 기존의 convention들을 지키는 것이 좋다. 이러한 convention들은 kubernetes community에 문서화되어있다. https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md

위 docs는 kubernetes API object의 모든 것들을 다루기 때문에, 이 중에서 몇 가지 주요한 부분만 보도록 하자.

  1. 모든 API object들은 kindapiVersion을 가진다. kind는 object type이고 apiVersion은 해당 object에 대한 API인 것이다.
  2. (option) resourceVersiongeneration으로 object에 대한 변화를 적어주는 것이 좋다. resourceVersion은 object가 수정되면 증가시키는 internal reference이다. generation은 object에 대해 관련된 변화가 있을 때 증가한다. 참고로 resourceVersion은 개발자가 직접 넣어주는 것으로 operator에게 이 object가 변경되었음을 알려주는 것이다. 반면 generation system에서 증가시켜주는 것으로 deployment와 같은 object에서 rollout, rollback 같은 작업들로 인한 세대의 변화를 generation을 증가시켜주어, 관리자에게 알려준다.
  3. (option) creationTimestampdeletionTimestamp는 object의 생애주기를 알려주는 참조로 쓰인다.
  4. (option) annotation은 object에 대한 metadata이며, labels는 kubernetes API를 통해 object를 필터링하는 기준으로 사용된다.
  5. specstatus으로 spec은 말 그대로 object에 대한 specification을 정의하는 부분이고, status는 현재 object의 상태를 알려준다. operator구동에 있어서는 status가 매우 중요하다.
  6. status는 추가적인 context없이, 그 자체로 설명이 가능해야한다. 또한, 한 번 정해지면 하위호환성을 위해서 변경되지 않아야하며, 다른 어떤 field와 동일한 API 호환성 규칙을 지켜야한다. 또한, True 또는 False로 정상적인 작동 상태를 보고할 수 있어야 한다. 이는 어떠한 고정된 방법이 있는 것은 아니지만, Ready=true, NotReady=false라고 이해할 수 있도록 해야한다는 것이다.
  7. status는 status의 전환을 이야기하는 것이 아니라, 클러스터의 현재 알려진 status를 나타내야 하는 것이다. 왜냐면 다수의 reconciliation loop에서는 현재의 status를 기반으로 동작하지, 앞으로 들어오는 event를 기반으로 동작하지 않는다. 그러나, 만약 status가 전이되는 과정이 너무 길다면 현재의 status를 Unknown으로 정의해도 된다.
  8. API object의 sub-object들은 list로 표현되어야하지, map으로 표현되어서는 안된다. 가령, nginx deployment는 다른 이름을 가진 port들이 list형식으로 나열되고 있다.
  9. optional field들은 pointer value로 표현되어 zero value(초기값)과 설정이 안된 값을 구분해야한다.

CRD schema에 대한 이해

먼저 다음의 CRD를 보도록 하자.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myoperator.operator.example.com
spec:
  group: operator.example.com
  names:
    kind: MyOperator
    listKind: MyOperatorList
    plural: myoperators
    singular: myoperator
  scope: Namespaced
  versions:
    - name: v1alpha1
      schema:
        openAPIV3Schema:
         ...
      served: true
      storage: true
      subresources:
        status: {}
status:
  acceptedNames:
    kind: ""
    plural: ""
  conditions: []
  storedVersions: []

apiVersionkind는 이 yaml파일이 CRD라는 것을 정의한다. 이를 통해 API server는 해당 CRD를 파싱하고 우리의 CR instance들을 만들어낸다.

다음으로 metadata.Name field는 우리의 CRD 이름을 정의한다. 좀 더 구체적으로 생각해보면, 이는 CRD에 대한 이름인 것이지, 이 CRD로 인해 만들어지는 CR의 이름은 아니다. 위의 경우 kubectl get crd/myoperator.operator.example.com으로 호출이 가능해지는 것이다.

spec부분이 CRD가 실제로 CR object들을 만들어내는 정보들이 담긴 곳이다. group 부분은 새로운 CR object들이 포함될 custom API group을 정의하는데, 이 부분은 unique한 값을 가져야 충돌이 발생하지 않는다.

names section은 우리의 object들이 참조될 여러 방법들에 대해서 정의한다. 이 부분은 오직 kindplural만 필수이고, 나머지 항목들은 이 두 항목에 의해서 추론된다. 이 두 요소를 통해서 kubernetes cluster에서 해당 object들을 불러올 수 있는데, 가령 kubectl get podkubectl get pods로 pod들을 호출할 수 있는 이유도 여기에 있는 것이다. 우리의 CR역시도 kubectl edit myoperator foo, kubectl edit myoperators 이런 방식으로 호출이 가능한 것이다.

다음으로 scope는 custom obejct를 namespace 또는 cluster scope로 정의한다. Namespaced이면 namespace에 정속된 custom object라고 생각하면 된다.

Versions는 우리의 CR에 대해 가능한 API version들을 알려준다. API server는 해당 version을 인식하여 object에 대한 하위호환성을 고려한 효율적인 운용을 도와준다. 각 version에서는 하나의 schema 정보가 있는데, 이 정보는 openAPI3Schema로 해당 version의 object구조를 식별한다. 즉, CRD에 대한 구조화된 schema를 제공하기 위해서 이 부분은 필수적이다.

object에 대한 schema는 openAPI3Schema를 준수하기 때문에 OpenAPI version 3 validation이 수행된다. 이 validation rule은 각 field에 대해서 다양한 pattern, 제약 사항들을 준수해야하는 경우들이 많은데, 하나하나 손수 만들기 쉽지 않다. 따라서 golang의 구조체로 이러한 field들의 각 조건을 구조체로 정립하여, 이를 기반으로 OpenAPI3 Schema를 만드는 방법이 있는데, 이것이 바로 kubebuilder이다. https://book.kubebuilder.io/reference/generating-crd.html 대부분의 경우 kubebuilder를 사용하여 개발하기를 추천되고 있다.

다음 section은 servedstorage로 해당 version을 REST API와 storage를 통해서 설치할 것인지 true, false로 표시할 수 있다. 단, CRD당 오직 하나의 version만이 storage로 설치할 수 있다는 것을 알아두도록 하자.

마지막 section으로는 subresourcesstatus로, 이들은 operator의 현재 상태에 대한 정보를 보고하기 위해서 사용될 status field를 정의하기 때문에 서로 관련이 있다. 이에 대해서는 추후에 더 자세히 알아보도록 하자.

Operator CRD 예제

위의 Operator CRD에서 spec field가 Operand의 CRD를 정의하는 부분인 것을 알았다. 그렇다면 어떤 option들이 Operand CRD에 필요할까?? 우리의 Operator로 nginx operand를 구동시킨다고 하자. 다음의 option들이 필요할 것이다.

  1. port: cluster에서 nginx pod를 노출시킬 port number를 설정하는 것으로, 직접적으로 nginx pod를 건드리지 않고 바꿀 수 있도록 한다.
  2. replicas: 이 option을 통해 operand의 수를 조절하도록 한다.
  3. forceRedeploy: 이 field를 통해서 operand에 대한 변경사항이 없더라도 operator가 강제로 operand를 재배포시킬 수 있도록 한다. 이러한 field를 no-operation(no-op)이라고 하는데, 주로 다른 field들은 operand에 대한 field들이지만, 이 field는 operand에 대한 operation(동작)을 지시하기 보다는, operator에게 operand를 어떻게 할 지 지시하는 것으로 no-operation field라고 한다.

이 3가지 field들을 통해서 operator의 CRD spec을 만들 수 있는 것이다. 이 CRD spec을 만족하는 CR object를 만들어보도록 하자.

apiVersion: v1alpha1
kind: NginxOperator
metadata:
  name: instance
spec:
  port: 80
  replicas: 1
status:
  ...

주의할 것은 위의 manifest는 CR이지 CRD가 아니다. kind만 봐도 CustomResourceDefinition가 아니라는 것을 알 수 있다. name부분에 instance로 일반적인 kubernetes object instance를 만든 것을 알 수 있다. forceRedeploy는 optional이라 생략되었다.

다음의 object는 아래의 명령어를 통해서 호출이 가능하다.

kubectl get -o yaml nginxoperator instance

다른 resource들과의 동작

operator를 통해 들어온 CR을 기반으로 operator는 workload를 실행할 operand를 만들어야한다. 우리의 경우 위의 CR이 들어오면 nginx deployment를 만들고 관련된 ServiceAccount, Role, RoleBinding 들을 만들어주어야 한다고 하자. helm이나 ansible을 통해서 operator가 이들을 만들어낼 수 있지만, 더 좋은 방법은 golang code를 통해서 개발하는 방법이다.

아래의 code는 golang을 통해서 Deployment를 만들어내는 code이다.

import appsv1 "k8s.io/api/apps/v1"
…
nginxDeployment := &appsv1.Deployment{
  TypeMeta: metav1.TypeMeta{
    Kind: "Deployment",
    apiVersion: "apps/v1",
  },
  ObjectMeta: metav1.ObjectMeta{
    Name: "nginx-deploy",
    Namespace: "nginx-ns",
  },
  Spec: appsv1.DeploymentSpec{
    Replicas: 1
    Selector: &metav1.LabelSelector{
      MatchLabels: map[string]string{"app":"nginx"},
    },
    Template: v1.PodTemplateSpec{
      Spec: v1.PodSpec{
        ObjectMeta: metav1.ObjectMeta{
          Name: "nginx-pod",
          Namespace: "nginx-ns",
          Labels: map[string]string{"app":"nginx"},
        },
        Containers: []v1.Container{
          {
             Name: "nginx",
             Image: "nginx:latest",
             Ports: []v1.ContainerPort{{ContainerPort: int32(80)}},
          },
        },
      },
    },
  },
}

kubernetes clinet api를 사용하여 직접 golang에 manifest를 만들어내는 방법이다. 문제는 golang code로 yaml형식의 구조체를 만드는 것이기 때문에 굉장히 복잡하고 불편하다. 특히 golang을 잘다루는 사람이 아니라면 사용하기 어려울 것이다.

다행히도 golang binary에 직접 file을 embedding하는 방법이 go 1.16이후로 추가되었다. 따라서 다음의 yaml파일을 golang에 직접 임베딩해주어 접근하고 관리할 수 있도록 하면 된다.

kind: Deployment
apiVersion: apps/v1
metadata:
  name: nginx-deploy
  namespace: nginx-ns
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
         image: nginx:latest
         ports:
          - containerPort: 80

golang에서 직접 kubernetes manifest를 만들기보다는 다음과 같이 yaml파일을 따로 만들어 제공해주는 것이, 유지 보수나 개발에 측면에서 더 이점이 많다. 따라서, helm으로 manifest를 만들고 해당 manifest를 operator에 제공해주어 배포하는 것이 더 좋은 방법이다.

target reconciliation loop 설계

event가 발생하여 operator의 reconciliation loop가 동작할 때, reconciliation loop는 전체 event의 context를 받지 못한다. 다만, operator는 reconiliation loop logic을 수행하기 위해서 cluster의 전체 state를 재평가한다. 이를 level-based-triggering이라고 한다. 이와 반대로 edge-based-triggering은 operator logic이 오직 event 그자체를 두고만 실행된다.

이 둘의 trade-off는 두 시스템 design의 신뢰성에 대한 정도이다. edge-based-triggering의 경우는 event가 들어오는데로 event만 보고 실행을 하면 되지만, 전체 status 정보에 대한 개념이 없기 때문에 event를 잃어버리게되면 data 정합성에 있어서 고통을 받게된다.

level-based-system의 경우는 전체 system의 상태를 인지하기 때문에 데이터 정합성과 같은 문제가 발생하지 않는다.

operator는 reconciliation loop를 level-based-system으로 설계하여 kubernetes cluster의 현재 state를 토대로 event들을 처리하는 것이 맞다. 이를 위해서 operator는 현재 cluster에 대한 state를 메모리에 저장하고 있어야 한다.

reconcile logic 설계

reconcile loop는 operator의 핵심 기능으로 operator가 event를 받을 때마다 호출하게 된다. 즉, operator의 main logic이 되는 것이다. 추가적으로 reconcile loop는 오직 하나의 CRD를 관리하기 위해서 설계되는 것이 이상적이다. 즉, 하나의 reconcile loop에 여러 책임(기능)들을 담당하도록 하면 좋지 않다.

Operator SDK를 사용하게되면 다음의 reconciliation loop 함수를 볼 수 있다.

func (r *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)

이 함수에 대해서는 추후에 더 자세히 이야기해보도록 하자. 우리의 operator의 경우 다음과 같은 Reconcile 함수를 구현할 수 있다.

func Reconcile:
  // Get the Operator's CR, if it doesn't exist then return
  // an error so the user knows to create it
  operatorCr, error = getMyCR()
  if error != nil {
    return error
  }
  // Get the related resources for the Operator (ie, the
  // Operand's Deployment). If they don't exist, create them
  resources, error = getRelatedResources()
  if error == ResourcesNotFound {
    createRelatedResources()
  }
  // Check that the related resources relevant values match
  // what is set in the Operator's CRD. If they don't match,
  // update the resource with the specified values.
  if resources.Spec != operatorCr.Spec {
    updateRelatedResources(operatorCr.Spec)
  }

위는 실제 Reconcile 함수라기 보다는 하나의 pseudocode이다. 위의 logic을 다음과 같이 나눌 수 있다.

  1. 먼저 getMyCR를 호출하는데, operator CR를 반환한다. operator CR에는 operand가 어떻게 동작해야할 지에 대한 정의가 있는데, operator에서 CR를 관리하지 않는 것이 가장 좋은 사용 방법이다. 만약 CR이 cluster에 없다면 error를 반환하도록 하여 user에게 CR object를 생성하라는 것을 알려주도록 한다.

  2. 두번째로 cluster에서 관련된 resource의 존재를 확인한다. 우리의 경우 Operand의 deployment가 된다. 만약 deployment가 존재하지않으면 operator는 이를 만들어주면 된다.

  3. 이제 operand의 resource들이 생성되었다면 operator CRD와 비교하여 현재의 status를 원하는 status로 맞춰준다.

위의 3 step에서의 대부분이 kubernetes cluster API를 호출한다. kubernetes cluster에 접근하는 client api의 사용은 생각보다 복잡하고 어려운데, operator sdk framework가 이러한 일들 대부분을 해주는 것이다.

Upgrade와 downgrade 다루기

operand와 operator의 version을 관리하는 것은 굉장히 중요한 일이다. 그러나 operand는 version관리가 어렵지 않은 반면 operator는 좀 다르다. operand의 version이 증가함에 따라 operand를 호환하는 operator의 version 역시도 upgrade해야하는데 이것이 쉽지 않다.

Operator Lifecycle Manager(OLM)은 user에게 operator를 새로운 version으로 업그레이드할 수 있도록 해준다. operator의 ClusterServiceVersion(CSV)는 개발자가 특정 업그레드 path를 지정하도록 허용하여 maintiner들에게 새로운 버전에 대한 구체적인 정보를 제공하도록 한다. 이에 대해서는 추후에 더 자세히 배워보도록 하자.

개발이 진행되다보면 Operator의 CRD가 바뀌어 이전의 version을 호환하지 못할 수도 있다. 이 경우에는 Operator의 API version을 증가시켜야 한다. 가령 v1alpha1에서 v1alpha2v1beta1으로 바꾸어야 한다.

0개의 댓글