들어가며
Kubernetes 위에서 뭔가를 배포하다보면, Deployment, Helm을 알게되고, 그러다 어느순간 CRD와 Operator라는 개념을 마주하게 된다. 처음엔 그저 이게 뭐지하고 사용 하다가, 어느 순간 꽤 많은 애플리케이션들이 자체적인 CRD와 Operator를 쓴다는 것을 느끼게 된다.
Kubernetes CRD와 Operator는 어떻게 구현하는건지 평소에 궁금했었는데, 최근에 Kopf 라는 툴을 알게되어 이에 대해 소개해보고자 한다. CRD 구현부터 Kopf를 통해 파이썬으로 Operator를 구현하는 것까지 이 글에서 다루어보겠다.
CRD
CRD 란?
CRD는 Custom Resource Definition의 약자로, 이름 그대로 사용자가 정의하는 Kubernetes 리소스다. 우리는 CRD를 먼저 정의하고 배포한 후 CR(Custom Resource)를 배포할 수 있다.
Kubernetes에는 Pod, Deployment, Service 등 기본적인 리소스 유형들이 존재한다. 그런데 이렇게 기본적인 리소스 유형말고 개발자가 직접 리소스 유형을 정의해서 사용할 수 있는데, 이렇게 직접 정의된 리소스들을 CRD라고 부르는 것이다.
사실 CRD는 아예 새로운 것을 만들어내는 것은 아니고, Kubernetes의 기본 리소스 유형을 조합하여 사용에 맞게 추상화한 리소스라고 보면 된다.
Kubernetes의 리소스 유형 확장 방법으로 CRD가 유일한 것은 아니다. API Aggregation 이라는 방법도 있는데, 이에 대해서는 Alice님 블로그 글에 잘 소개가 되어 있어 공유해본다.
CRD 작성하기
먼저, CRD 작성 전에 현재 마주한 문제 상황은 다음과 같다.
보통 Kubernetes에서 앱을 배포하기 위해서는 Deployment와 Service를 배포해야 한다. 그런데 매번 Deployment와 Service의 yaml 파일을 일일이 다 작성하는게 귀찮다. 개발자는 배포에 다음 설정 값들만 작성해도 충분하다고 해보자.
- Deployment에 어떤 컨테이너 이미지를 쓸지 (image)
- Deployment에 Pod 수는 몇 개로 할지 (replicas)
- Service에 포트는 몇번으로 할지 (port)
위 문제 상황을 해결하기 위해 Application
이라는 CR을 만드려고 한다.
Application
리소스를 작성할 때 위 3개의 설정 값만 주고, Application
을 배포하면 Deployment와 Service가 자동으로 만들어지게끔 할 것이다.
이제 Application
의 CRD를 작성을 시작해보자.
crd.yaml
을 만들어 다음처럼 작성한다.
# crd.yaml
apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata: # name must match the spec fields below, and be in the form: <plural>.<group> name: applications.heumsi.github.io
- CRD를 작성할 때는 위와 같은
apiVersion
과kind
를 사용한다. metadata.name
값은 CRD의 Full Name이다.- 이 값은
<plural>.<group>
형태로 작성되어야 하는데, 아래에서 설명하겠다. - 리소스의 Full Name은 API Group(위의 경우
heumsi.github.io
)을 포함하는데, API Group은 리소스 구분을 위한 일종의 네임스페이스라 보면 된다.
- 이 값은
계속해서 더 작성해보자.
# crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
# name must match the spec fields below, and be in the form: <plural>.<group>
name: applications.heumsi.github.io
spec: # group name to use for REST API: /apis/<group>/<version> group: heumsi.github.io # either Namespaced or Cluster scope: Namespaced names: # plural name to be used in the URL: /apis/<group>/<version>/<plural> plural: applications # singular name to be used as an alias on the CLI and for display singular: application # kind is normally the CamelCased singular type. Your resource manifests use this. kind: Application # shortNames allow shorter string to match your resource on the CLI shortNames: - app
spec.group
의 값은 API Group이다. API Group은 보통 자신의 도메인을 사용한다.spec.scope
의 값은 이 커스텀 리소스가 배포되었을 때의 유효 범위이다.Namespaced
로 주면 커스텀 리소스는 네임스페이스 범위로 배포된다.Cluster
로 주면 커스텀 리소스는 클러스터 범위로 배포된다.- CRD 자체는 Cluster 범위다. 여기서 말하는
scoped
값은 CR(여기선Applicaton
)의 범위를 말하는 것이다.
spec.names
에는 이 리소스를 부르는 여러 이름을 담는다.plural
의 값은 이 리소스의 복수 형태의 이름이다.singular
의 값은 이 리소스의 단수 형태의 이름이다.kind
의 값은 이 리소스를 생성할 때 작성하는 단수 형태의 이름이다.shortNames
의 값은 이 리소스를 부르는 단축 형태의 이름으로, 일종의 Alias다.
이제 실제로 Application
은 어떤 스펙을 담고있어야 하는지 작성해보자.
# crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
# name must match the spec fields below, and be in the form: <plural>.<group>
name: applications.heumsi.github.io
spec:
# group name to use for REST API: /apis/<group>/<version>
group: heumsi.github.io
# either Namespaced or Cluster
scope: Namespaced
names:
# plural name to be used in the URL: /apis/<group>/<version>/<plural>
plural: applications
# singular name to be used as an alias on the CLI and for display
singular: application
# kind is normally the CamelCased singular type. Your resource manifests use this.
kind: Application
# shortNames allow shorter string to match your resource on the CLI
shortNames:
- app
# list of versions supported by this CustomResourceDefinition versions: - name: v1 # Each version can be enabled/disabled by Served flag. served: true # One and only one version must be marked as the storage version. storage: true schema: openAPIV3Schema: type: object properties: spec: type: object x-kubernetes-preserve-unknown-fields: true properties: image: type: string replicas: type: integer port: type: integer required: - image - replicas - port status: type: object x-kubernetes-preserve-unknown-fields: true required: - spec
spec.versions
내에 버전 단위로 스펙을 작성한다.served
와storage
는 실제 운영에 제공할 버전에 대한 표시이며, 제공할 버전은true
로 둔다.schema
항목에 실제 스펙을 작성하며, 스펙 포맷은openAPIV3Schema
를 사용한다.- 우리가 json 스키마를 정의할 때 흔히 쓰는 그 OpenAPI 스키마가 맞다.
x-kubernetes-preserve-unknown-fields
를true
로 주면 추후에 동적으로 해당 field 값을 수정할 수 있다. 왠만하면true
로 주자.
x-kubernetes-preserve-unknown-fields
에 대해서 좀 더 알아보고 싶다면, 공식 문서를 확인해봐도 좋다.
여기서는 위에서 필요하다고 이야기한 설정 값에 맞게 image
, replicas
, port
이 3개의 속성 값을 spec
안에 설정하였다.
CRD 배포하기
이제 위에서 작성한 CRD를 다음처럼 배포하자.
$ kubectl apply -f crd.yaml
customresourcedefinition.apiextensions.k8s.io/applications.heumsi.github.io created
이제 다음처럼 조회도 되고
$ kubectl get crd
NAME CREATED AT
applications.heumsi.github.io 2022-07-13T12:16:19Z
다음처럼 확인할 수도 있다.
$ kubectl describe crd applications.heumsi.github.io
Name: applications.heumsi.github.io
Namespace:
Labels: <none>
Annotations: <none>
API Version: apiextensions.k8s.io/v1
Kind: CustomResourceDefinition
Metadata:
...
Spec:
...
Status:
...
Events:
...
이렇게 CRD 자체는 클러스터 전역에서 배포된다.
CR 작성하기
이제 위에서 CRD로 정의한 CR을 작성해보자.
application.yaml
을 만들고 다음처럼 작성한다.
# application.yaml
apiVersion: heumsi.github.io/v1
kind: Application
metadata:
name: nginx
spec:
image: nginx:latest
replicas: 1
port: 80
위 crd.yaml
에서 작성한 spec
에 맞춰 application.yaml
을 작성하였다.
이제 우리는 이 application.yaml
을 배포하면 다음의 일들이 일어나길 기대한다.
Deployment
가 배포된다.- Container는 하나이며
image
는nginx:latest
이다. replicas
는1
이다.
- Container는 하나이며
Service
가 배포된다.Deployment
로 만들어진Pod
들에 연결된다.- 포트는
80:80
으로 설정된다.
CR 배포하기
위에서 작성한 application.yaml
을 배포하자.
$ kubectl apply -f application.yaml
application.heumsi.github.io/nginx created
마찬가지로 다음처럼 조회할 수 있다.
$ kubectl get applications
NAME AGE
nginx 35s
다음처럼 crd.yaml
에서 설정한 shortNames
이나 group
으로도 조회 가능하다.
$ kubectl get app
NAME AGE
nginx 2m38s
$ kubectl get applications.heumsi.github.io
NAME AGE
nginx 2m29s
CR을 배포했으나 아직 Deployment나 Service는 만들어지지 않았다. CR을 모니터링하며 어떤 추가적인 작업을 실행하려면 Operator를 만들어야 한다.
Operator
Operator 란?
Operator는 위에서 설명한대로, 특정 리소스를 계속 모니터링하며 무언가의 작업을 수행하는 역할을 담당한다.
예를 들어 어떤 CR이 생성되면 추가적인 특정 파드를 띄운다던가, CR에 변경이 일어나면 이러한 내용을 어딘가에 저장하게 할 수 있다. 이렇게 리소스를 모니터링하며 발생하는 이벤트에 따라 사용자가 추가적인 로직을 부여하고 싶을 때 Operator를 사용한다.
Kopf 란?
Kopf는 Kubernetes OPerator Framework의 약자로, Python으로 Operator를 만들 수 있게 도와주는 프레임워크다.
말 그대로 프레임워크다 보니, 많은 것들이 추상화 되어있고 우리는 주어진 프레임 안에서 적당히 잘 코딩을 하면 된다. 비슷한 도구로 CoreOS Operator SDK나 CoreOS Operator Framework들도 있지만, Kopf 개발자들은 Kopf가 이들보다 사용하기 훨씬 쉬운 프레임워크라고 말한다.
kopf 대안에 대해 좀 더 자세히 적어둔 글이 있는데, 궁금하면 공식 문서를 확인하자.
그럼 이제 kopf로 Operator를 작성해보자.
Kopf 설치하기
먼저 다음처럼 kopf
를 설치한다.
$ pip install kopf
kopf --version
kopf, version 1.35.5
pip는 Python package manager로, Python을 설치하면 같이 설치된다. 아직 pip가 설치되지 않았다면, Python 설치를 먼저 진행하자. Python은 pyenv로 쉽게 설치할 수 있다.
yaml 파일 작성하기
먼저 Deployment와 Service로 사용할 yaml 파일을 다음처럼 작성하자.
service.yaml
을 만든 뒤 다음처럼 작성한다.
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: "{name}"
spec:
selector:
app: "{name}"
ports:
- name: http
protocol: TCP
port: {port}
targetPort: {port}
deployment.yaml
을 만든 뒤 다음처럼 작성한다.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: "{name}"
labels:
app: "{name}"
spec:
replicas: {replicas}
selector:
matchLabels:
app: "{name}"
template:
metadata:
labels:
app: "{name}"
spec:
containers:
- name: "{name}"
image: "{image}"
ports:
- containerPort: {port}
yaml
파일 내 {}
로 표현된 변수들은 Python 코드에서 주입해줄 예정이다.
CR 생성 이벤트 핸들러 작성하기
이제 진짜로 Operator를 작성해보자.
main.py
를 만든 뒤 다음처럼 작성한다.
# main.py
import kopf
@kopf.on.create('heumsi.github.io', 'v1', 'applications')
def create(name, logger, **kwargs):
logger.info(f"Application '{name}' is created.")
applications.v1.heumsi.github.io
리소스가 생성될 때 이를 감지하여 create
함수가 실행되는 코드다.
즉 create
라고 하는 이벤트의 핸들러를 kopf 에서는 위처럼 작성하게 된다.
위 코드를 다음처럼 실행한다.
$ kopf run main.py --verbose
[2022-07-13 22:18:08,392] kopf._core.reactor.r [DEBUG ] Starting Kopf 1.35.5.
[2022-07-13 22:18:08,392] kopf._core.engines.a [INFO ] Initial authentication has been initiated.
[2022-07-13 22:18:08,392] kopf.activities.auth [DEBUG ] Activity 'login_via_client' is invoked.
[2022-07-13 22:18:08,417] kopf.activities.auth [DEBUG ] Client is configured via kubeconfig file.
[2022-07-13 22:18:08,418] kopf.activities.auth [INFO ] Activity 'login_via_client' succeeded.
[2022-07-13 22:18:08,418] kopf._core.engines.a [INFO ] Initial authentication has finished.
[2022-07-13 22:18:08,464] kopf._cogs.clients.w [DEBUG ] Starting the watch-stream for customresourcedefinitions.v1.apiextensions.k8s.io cluster-wide.
[2022-07-13 22:18:08,465] kopf._cogs.clients.w [DEBUG ] Starting the watch-stream for applications.v1.heumsi.github.io cluster-wide.
[2022-07-13 22:18:08,573] kopf.objects [DEBUG ] [default/nginx] Creation is in progress: {'apiVersion': 'heumsi.github.io/v1', 'kind': 'Application', 'metadata': {'annotations': {'kubectl.kubernetes.io/last-applied-configuration': '{"apiVersion":"heumsi.github.io/v1","kind":"Application","metadata":{"annotations":{},"name":"nginx","namespace":"default"},"spec":{"image":"nginx:latest","port":80,"replicas":1}}\n'}, 'creationTimestamp': '2022-07-13T13:18:02Z', 'generation': 1, 'managedFields': [{'apiVersion': 'heumsi.github.io/v1', 'fieldsType': 'FieldsV1', 'fieldsV1': {'f:metadata': {'f:annotations': {'.': {}, 'f:kubectl.kubernetes.io/last-applied-configuration': {}}}, 'f:spec': {'.': {}, 'f:image': {}, 'f:port': {}, 'f:replicas': {}}}, 'manager': 'kubectl-client-side-apply', 'operation': 'Update', 'time': '2022-07-13T13:18:02Z'}], 'name': 'nginx', 'namespace': 'default', 'resourceVersion': '79214', 'uid': 'f6f0f256-aaa8-4a42-83f2-3df27755ab06'}, 'spec': {'image': 'nginx:latest', 'port': 80, 'replicas': 1}}
[2022-07-13 22:18:08,574] kopf.objects [DEBUG ] [default/nginx] Handler 'create' is invoked.
[2022-07-13 22:18:08,574] kopf.objects [INFO ] [default/nginx] Application 'nginx' is created.
[2022-07-13 22:18:08,576] kopf.objects [INFO ] [default/nginx] Handler 'create' succeeded.
[2022-07-13 22:18:08,576] kopf.objects [INFO ] [default/nginx] Creation is processed: 1 succeeded; 0 failed.
[2022-07-13 22:18:08,577] kopf.objects [DEBUG ] [default/nginx] Patching with: {'metadata': {'annotations': {'kopf.zalando.org/last-handled-configuration': '{"spec":{"image":"nginx:latest","port":80,"replicas":1}}\n'}}}
[2022-07-13 22:18:08,690] kopf.objects [DEBUG ] [default/nginx] Something has changed, but we are not interested (the essence is the same).
[2022-07-13 22:18:08,690] kopf.objects [DEBUG ] [default/nginx] Handling cycle is finished, waiting for new changes.
로그를 보면 핸들러에서 applications.heumsi.github.io
리소스가 생성된 것을 감지하고 있다.
또한 생성된 CR의 Events에도 위 Operator가 작업한 이벤트가 기록된다.
$ kubectl describe app nginx
Name: nginx
Namespace: default
Labels: <none>
Annotations: kopf.zalando.org/last-handled-configuration: {"spec":{"image":"nginx:latest","port":80,"replicas":1}}
API Version: heumsi.github.io/v1
Kind: Application
Metadata:
Creation Timestamp: 2022-07-10T06:45:11Z
Generation: 1
Managed Fields:
API Version: heumsi.github.io/v1
Fields Type: FieldsV1
fieldsV1:
f:metadata:
f:annotations:
.:
f:kubectl.kubernetes.io/last-applied-configuration:
f:spec:
.:
f:image:
f:port:
f:replicas:
Manager: kubectl-client-side-apply
Operation: Update
Time: 2022-07-10T06:45:11Z
API Version: heumsi.github.io/v1
Fields Type: FieldsV1
fieldsV1:
f:metadata:
f:annotations:
f:kopf.zalando.org/last-handled-configuration:
Manager: kopf
Operation: Update
Time: 2022-07-10T06:45:59Z
Resource Version: 3413
UID: 289ff6b3-29a1-4896-b8f3-82c20c4a904e
Spec:
Image: nginx:latest
Port: 80
Replicas: 1
Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Logging 8s kopf Creation is processed: 1 succeeded; 0 failed. Normal Logging 8s kopf Handler 'create_fn' succeeded.
그러면 이제 main.py
내 create
함수 내에 Deployment와 Service를 배포하는 코드를 작성해보자.
코드에서 Kubernetes API를 Python SDK 형태로 쓰기 위해 다음 패키지를 설치한다.
$ pip install kubernetes
$ pip list | grep kubernetes
kubernetes 24.2.0
main.py
를 다음처럼 수정한다.
import os
import kopf
import kubernetes
import yaml
@kopf.on.create('heumsi.github.io', 'v1', 'applications')
def create(name, spec, namespace, logger, **kwargs):
logger.info(f"Application '{name}' is created.")
kapps_v1 = kubernetes.client.AppsV1Api()
kcore_v1 = kubernetes.client.CoreV1Api()
# Create Deployment
path = os.path.join(os.path.dirname(__file__), 'deployment.yaml')
tmpl = open(path, 'rt').read()
text = tmpl.format(name=name, **spec)
data = yaml.safe_load(text)
kopf.adopt(data) # Add as child
deployment = kapps_v1.create_namespaced_deployment(
namespace=namespace,
body=data
)
logger.info(f"Deployment '{name}' is created.")
logger.debug(deployment)
# Create Service
path = os.path.join(os.path.dirname(__file__), 'service.yaml')
tmpl = open(path, 'rt').read()
text = tmpl.format(name=name, **spec)
data = yaml.safe_load(text)
kopf.adopt(data) # Add as child
service = kcore_v1.create_namespaced_service(
namespace=namespace,
body=data
)
logger.info(f"Service '{name}' is created.")
logger.debug(service)
return {
"deployment": deployment.metadata.name,
"service": service.metadata.name,
}
별 다른 내용은 없고, kubernetes
패키지로 Deployment와 Service를 생성하는 코드다.
tmpl.format(name=name, **spec)
코드를 통해 이전에 작성한 deployment.yaml
와 service.yaml
에
name
및 spec
의 데이터를 주입하여 실제로 배포할 yaml 파일을 렌더링한다.
이후 kubernetes.client
를 통해 이 yaml 파일을 배포한다.
kubernetes.client
는 리소스의apiVersion
에 따라 나뉜다. 예를 들어apiVersion
이v1
인 리소스는kubernetes.client.CoreV1Api
를 사용하면 되고,apiVersion
이apps/v1
인 리소스는kubernetes.client.AppsV1Api
를 사용하면 된다.
그리고 kopf.adopt(data)
코드를 넣어주게 되면, 배포되는 yaml 파일은 이 CR의 자식 리소스가 된다.
즉, 이 CR이 지워질 때 자식 리소스(여기서는 Deployment와 Service)가 같이 지워지게 된다.
마지막에는 Dict
를 반환하는데, Key, Value들은 CR의 status
항목에 핸들러 함수 이름과 함께 저장된다.
이는 아래에서 CR 배포 후 확인해보자.
이벤트 핸들러의 파라미터로
kwargs
를 받게 되는데, 이 안에 이벤트에 대한 각종 정보들이 담겨있다.kwargs
에 어떤 정보가 있는지 직접 디버깅하며 스크린샷을 찍어보았다.
다시 코드로 표현하면 다음과 같다.
{ 'annotations': {'kubectl.kubernetes.io/last-applied-configuration': '{"apiVersion":"heumsi.github.io/v1","kind":"Application","metadata":{"annotations":{},"name":"nginx","namespace":"default"},"spec":{"image":"nginx:latest","port":80,"replicas":1}}\n'}, 'diff': (('add', (), None, {'spec': {'image': 'nginx:latest', 'port': 80, 'replicas': 1}}),), 'labels': {}, 'logger': <ObjectLogger kopf.objects (INFO)>, 'memo': {}, 'meta': {'annotations': {'kubectl.kubernetes.io/last-applied-configuration': '{"apiVersion":"heumsi.github.io/v1","kind":"Application","metadata":{"annotations":{},"name":"nginx","namespace":"default"},"spec":{"image":"nginx:latest","port":80,"replicas":1}}\n'}, 'creationTimestamp': '2022-07-10T08:10:10Z', 'generation': 1, 'managedFields': [{'apiVersion': 'heumsi.github.io/v1', 'fieldsType': 'FieldsV1', 'fieldsV1': {'f:metadata': {'f:annotations': {'.': {}, 'f:kubectl.kubernetes.io/last-applied-configuration': {}}}, 'f:spec': {'.': {}, 'f:image': {}, 'f:port': {}, 'f:replicas': {}}}, 'manager': 'kubectl-client-side-apply', 'operation': 'Update', 'time': '2022-07-10T08:10:10Z'}], 'name': 'nginx', 'namespace': 'default', 'resourceVersion': '9488', 'uid': 'f66918a2-31d7-4dff-8a77-5c7434ee7d26'}, 'name': 'nginx', 'namespace': 'default', 'new': {'spec': {'image': 'nginx:latest', 'port': 80, 'replicas': 1}}, 'old': None, 'param': None, 'patch': {}, 'reason': <Reason.CREATE: 'create'>, 'resource': applications.v1.heumsi.github.io, 'retry': 0, 'runtime': datetime.timedelta(microseconds=447), 'spec': {'image': 'nginx:latest', 'port': 80, 'replicas': 1}, 'started': datetime.datetime(2022, 7, 10, 8, 10, 11, 102275), 'status': {}, 'uid': 'f66918a2-31d7-4dff-8a77-5c7434ee7d26' }
핸들러에서 이
kwargs
내 key들을 파라미터로 두고 사용할 수 있다. 우리가 위에서 사용한name
,logger
등이 바로 이 안에 포함된 key라고 보면 된다.
이제 Operator도 다시 실행하고, CR도 지웠다가 다시 생성해보자.
$ kopf run main.py --verbose
$ kubectl delete -f application.yaml
application.heumsi.github.io "nginx" deleted
$ kubectl apply -f application.yaml
application.heumsi.github.io/nginx created
이제 정상적으로 CR(Application)와 Deployment, Service가 잘 배포되었는지 확인해보자.
$ kubectl get app
NAME AGE
nginx 20s
$ kubectl get deployment
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 1/1 1 1 24s
$ kubectl get service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 3d8h
nginx ClusterIP 10.96.249.187 <none> 80/TCP 27s
잘 배포된 것을 확인했다.
배포된 Deployment를 확인해보면 다음처럼 ownerReferences
에 CR이 설정된 것을 확인할 수 있다.
이는 Deployment의 상위 리소스가 CR이라는 표현으로, kopf.adopt(data)
코드에 의해 생성된 것이다.
$ kubectl get deploy nginx -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
...
ownerReferences: - apiVersion: heumsi.github.io/v1 blockOwnerDeletion: true controller: true kind: Application name: nginx uid: 963a4fd2-effe-4c5e-bdcf-c089a3ea9480 ...
spec:
...
한편 핸들러에서 반환한 Dict
데이터도 CR의 status
에 잘 저장되었는지 확인해보자.
$ kubectl get app nginx -o yaml
apiVersion: heumsi.github.io/v1
kind: Application
metadata:
annotations:
kopf.zalando.org/last-handled-configuration: |
{"spec":{"image":"nginx:latest","port":8080,"replicas":2}}
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"heumsi.github.io/v1","kind":"Application","metadata":{"annotations":{},"name":"nginx","namespace":"default"},"spec":{"image":"nginx:latest","port":80,"replicas":1}}
creationTimestamp: "2022-07-13T14:55:53Z"
generation: 4
name: nginx
namespace: default
resourceVersion: "82893"
uid: 1b31a0d8-81e6-4671-a8b4-f54ace7f101c
spec:
image: nginx:latest
port: 8080
replicas: 2
status: create: deployment: nginx service: nginx
status.create
내에 데이터가 잘 저장된 것을 확인했다.
CR 수정 이벤트 핸들러 작성하기
위에서는 CR이 생성되었을 때 동작하는 핸들러를 작성했다. 이번에는 CR이 수정될 때 동작하는 핸들러를 작성해보자.
main.py
를 다음처럼 수정한다.
import os
import kopf
import kubernetes
import yaml
@kopf.on.create('heumsi.github.io', 'v1', 'applications')
def create(name, spec, namespace, logger, **kwargs):
logger.info(f"Application '{name}' is created.")
kapps_v1 = kubernetes.client.AppsV1Api()
kcore_v1 = kubernetes.client.CoreV1Api()
# Create Deployment
path = os.path.join(os.path.dirname(__file__), 'deployment.yaml')
tmpl = open(path, 'rt').read()
text = tmpl.format(name=name, **spec)
data = yaml.safe_load(text)
kopf.adopt(data) # Add as child
deployment = kapps_v1.create_namespaced_deployment(
namespace=namespace,
body=data
)
logger.info(f"Deployment '{name}' is created.")
logger.debug(deployment)
# Create Service
path = os.path.join(os.path.dirname(__file__), 'service.yaml')
tmpl = open(path, 'rt').read()
text = tmpl.format(name=name, **spec)
data = yaml.safe_load(text)
kopf.adopt(data) # Add as child
service = kcore_v1.create_namespaced_service(
namespace=namespace,
body=data
)
logger.info(f"Service '{name}' is created.")
logger.debug(service)
return {
"deployment": deployment.metadata.name,
"service": service.metadata.name,
}
@kopf.on.update('heumsi.github.io', 'v1', 'applications')def update(name, status, spec, namespace, logger, **kwargs): kapps_v1 = kubernetes.client.AppsV1Api() kcore_v1 = kubernetes.client.CoreV1Api() # Update Deployment path = os.path.join(os.path.dirname(__file__), 'deployment.yaml') tmpl = open(path, 'rt').read() text = tmpl.format(name=name, **spec) data = yaml.safe_load(text) deployment = kapps_v1.replace_namespaced_deployment( name=status["create"]["deployment"], namespace=namespace, body=data ) logger.info(f"Deployment '{name}' is replaced.") logger.debug(deployment) # Update Service path = os.path.join(os.path.dirname(__file__), 'service.yaml') tmpl = open(path, 'rt').read() text = tmpl.format(name=name, **spec) data = yaml.safe_load(text) service = kcore_v1.replace_namespaced_service( name=status["create"]["service"], namespace=namespace, body=data ) logger.info(f"Service '{name}' is replaced.") logger.debug(service) return { "deployment": deployment.metadata.name, "service": service.metadata.name, }
새로 추가한 update
함수는 create
함수와 별로 다르지 않다.
중간에 create_namespaced_deployment
, create_namespaced_service
대신 replace_namespaced_deployment
, replace_namespaced_service
로 변경되었을 뿐이다.
또 kopf.adopt(data)
와 같은 코드는 빠졌다.
CR을 수정했을 때 잘 반영되는지 확인해보자.
이제 다시 Operator를 실행한다.
$ kopf run main.py --verbose
CR의 스펙 중, port
값은 8080
으로, replicas
는 2
로 수정한다.
$ kubectl edit app nginx
apiVersion: heumsi.github.io/v1
kind: Application
metadata:
annotations:
kopf.zalando.org/last-handled-configuration: |
{"spec":{"image":"nginx:latest","port":8080,"replicas":2}}
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"heumsi.github.io/v1","kind":"Application","metadata":{"annotations":{},"name":"nginx","namespace":"default"},"spec":{"image":"nginx:latest","port":80,"replicas":1}}
creationTimestamp: "2022-07-13T14:55:53Z"
generation: 4
name: nginx
namespace: default
resourceVersion: "82893"
uid: 1b31a0d8-81e6-4671-a8b4-f54ace7f101c
spec:
image: nginx:latest
port: 8080 replicas: 2status:
create:
deployment: nginx
service: nginx
update:
deployment: nginx
service: nginx
잘 반영되었는지 확인해보자.
$ kubectl get deployment nginx
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 2/2 2 2 10m
$ kubectl get service nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx ClusterIP 10.96.249.187 <none> 8080/TCP 10m
잘 반영된 것을 확인할 수 있다.
특정 Field만 변경 사항을 감지한다거나, 서브 핸들러를 두는 등의 일들도 할 수 있다. 이에 대한 자세한 내용은 공식 문서를 확인하자.
CR 삭제하기
이제 생성했던 CR을 삭제해보자.
$ kubectl delete app nginx
application.heumsi.github.io "nginx" deleted
그러면 다음처럼 자식 리소스들도 모두 삭제된 것을 확인할 수 있다.
$ kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 8h
$ kubectl get deployment
No resources found in default namespace.
위에서도 말했지만, 이는 CR 생성 시 kopf.adopt(data)
코드로 자식 리소스에 ownerReferences
를 주었기 때문에 가능한 것이다.
나가며
이번 글에서는 CRD 작성 방법과 kopf로 Operator를 구현하는 방법에 대해 배웠다. 이 글에서는 CRD와 kopf를 같이 이야기했는데 kopf를 쓰기 위해 꼭 CRD를 사용해야하는 것은 아니다. 예를 들면 다음처럼 CR이 아닌 네임스페이스가 생성되었을 때 일련의 로직을 처리하는 것도 kopf를 사용하는 방법 중 하나다.
import kopf
@kopf.on.create('namespace')
def create(name, logger, **kwargs):
logger.info(f"Namespace '{name}' is created.")
Kuberentes를 도입한 초기에는 CRD를 직접 작성하거나 kopf로 Operator를 당장 만들 일은 잘 없을거 같다. 어느정도 우리 팀만의 운영 패턴이 잡히고, 이를 추상화하여 더 편하고 자동화하여 쓰고 싶을 때 kopf를 아주 유용하게 사용할 수 있을거란 생각이 든다.
kopf는 내부적으로 어떻게 동작하는지 궁금해서 알아보려 했지만, 공식 문서에 설명이 별로 없어서 유감스럽지만 패스한다. 이 글을 본 누군가가 이에 대해서도 작성해주셨으면 좋겠다.