[쿠버네티스 오퍼레이터] 3. 오퍼레이터 개발 모범 사례

안녕하세요! 펜타시큐리티 클라우드개발팀입니다.

지난 1편에서는 쿠버네티스 오퍼레이터와 커스텀 리소스에 대해 전반적인 내용을, 2편에서는 오퍼레이터 프레임워크 (Operator SDK)를 이용한 쿠버네티스 오퍼레이터 구현에 대해 소개해드렸습니다!

  1. 오퍼레이터와 커스텀 리소스
  2. 오퍼레이터 구현
  3. 오퍼레이터 개발 모범 사례

오늘은 저희 팀의 오퍼레이터 개발 경험을 곁들인 오퍼레이터 개발 모범 사례를 소개해 드리려고 합니다.

출처: https://github.com/kubernetes/kubernetes/tree/master/logo

아래 내용은 다음 4개의 문서에서 몇가지 요소들을 뽑아서 정리한 것입니다. 저희가 정리하지 못한 좋은 내용들도 많기 때문에 꼭 한번씩 읽어보시기를 추천드립니다!

Operator SDK와 같은 도구 사용하기

Operator SDK나 Kubebuilder 같은 오퍼레이터 개발 도구를 사용하면, 오퍼레이터 동작에 필요한 기본 컴포넌트를 제공해주고, 기본적인 보일러플레이트 코드와 YAML 매니페스트를 만들어줍니다. 또한, 쿠버네티스 API를 추상화된 함수들을 제공해줍니다.

이런 도구를 잘 활용하면 개발자가 다른 부분에 큰 신경 쓰지 않고 비즈니스 로직 작성에만 집중할 수 있습니다.

애플리케이션 당 하나의 오퍼레이터 사용하기

오퍼레이터는 특정 애플리케이션이나 프로세스에 대한 도메인 지식을 기반으로 애플리케이션 배포, 관리 자동화를 제공해주기 위해 사용됩니다.

이 때, 하나의 오퍼레이터가 여러 애플리케이션을 관리하기보다, 애플리케이션마다 별도의 오퍼레이터를 사용하도록 하면 코드 복잡도를 줄일 수 있습니다. 왜냐하면, 각 애플리케이션의 도메인 지식, 관련 코드를 오퍼레이션별로 관리할 수 있기 때문입니다.

기능별 커스텀 리소스, 컨트롤러 사용하기

오퍼레이터에서 애플리케이션에 대해 제공하는 기능이 다양할 수 있습니다. 대표적인 관리 및 운영 기능의 예로, 스케일링, 백업, 무중단 배포, 복구, 모니터링 등이 있습니다.

만약 하나의 커스텀 리소스나 컨트롤러에 위와 같은 기능들을 모두 담으면 복잡해질 수 있습니다. 이 경우, 애플리케이션 당 하나의 오퍼레이터를 사용하는 것과 마찬가지로, 특정 기능을 특정 커스텀 리소스나 컨트롤러로 분리하게 하면 복잡도를 줄일 수 있습니다.

예를 들면, 애플리케이션을 생성하고 관리하는 메인 컨트롤러, 백업을 담당하는 백업 컨트롤러, 복구를 담당하는 복구 컨트롤러로 분리할 수 있습니다.

Reconcile 함수를 서브루틴으로 쪼개기

컨트롤러의 대부분의 로직은 Reconcile 함수에 작성이 됩니다. 컨트롤러에 많은 기능이 추가될수록 Reconcile 함수가 복잡해질 수 있습니다.

Reconcile 함수 내 로직들을 적절하게 서브루틴으로 쪼개고, 각 서브루틴이 Reconcile 함수의 리턴값과 같은 값을 반환할 수 있습니다. 그럼, Reconcile 함수의 동작 과정과 결과를 파악하기 쉬워집니다.

또한, 서브루틴별로 테스트를 수행할 수 있으니 테스트하기 쉬운 코드를 작성할 수 있으며, 코드가 한 눈에 파악될 수 있어 유지 보수하기 쉬워집니다.

참고하기 좋은 코드로 GCP Project 오퍼레이터의 Project Reference 컨트롤러가 있는데, 여기서는 각 서브루틴을 ReferenceReconcileOperation으로 정의하고, 각 서브루틴을 순차적으로 실행하도록 구현했습니다.

Reconcile 함수를 멱등적 (Idempotent)으로 만들기

“Reconcile 함수를 서브루틴으로 쪼개기” 원칙을 반영하게 되면, 새로운 커스텀 리소스가 생기거나 기존 커스텀 리소스가 변경되었을 때 각 서브루틴이 자신이 담당하고 있는 부분에 대해 필요한 작업들을 수행하도록 할 수 있습니다.

이 때, 서브루틴을 멱등적이게 만들어야합니다. 서브루틴 내에서 작업이 수행이 되어야하는지 (현재 상태가 선언된 상태와 다른지) 먼저 체크하고, 필요할 경우에만 작업을 수행하도록 해야합니다. 그럼, 중복 작업으로 인해 발생하는 문제를 피할 수 있습니다.

예를 들어, GCP Project 오퍼레이터에서는 할당된 프로젝트 ID가 없는 커스텀 리소스에 대해 새로운 프로젝트 ID를 생성 및 할당해주는 서브루틴을 가지고 있을 수 있습니다. 이 때, 해당 서브루틴은 먼저 커스텀 리소스가 프로젝트 ID를 가지고 있는지 먼저 체크하고, 없을 경우 프로젝트 ID를 만들어주도록 해야합니다.

그럼, 프로젝트 ID가 있는 커스텀 리소스에 대해 서브루틴이 실행될 때마다 새로운 프로젝트 ID가 생성되는 문제를 피할 수 있습니다.

오퍼레이터 설계상, 감시 중인 리소스에 대해 발생한 이벤트 종류 (생성, 변경, 삭제 등)가 Reconcile 함수로 전달되지 않습니다. 따라서 Reconcile 함수 및 서브루틴은 이벤트 종류에 따라 다른 작업을 수행하는 대신, 매번 전체 상태를 체크한 후 필요한 작업을 수행하도록 개발되어야 합니다.

이러한 방식은 level-based trigger와 유사하다고 볼 수 있습니다. 상태 변화에 대한 작업을 수행해주는 것이 아니라, 상태에 대해 필요한 작업을 수행해주는 것이죠.

매번 모든 상태를 체크해야해서 비효율적일 수 있지만, 복잡하고 신뢰성이 떨어지는 환경에서 이벤트가 유실되는 문제에 대응할 수 있습니다. 왜냐하면 Reconcile 함수가 멱등적일 경우, 이벤트가 유실되더라도 해당 이벤트에 대해 수행되었어야할 작업을 이후 Reconcile 함수 실행 시 수행해줄 수 있기 때문입니다.

또한 서브루틴 내에서 이벤트 종류별로 분기해서 각 종류마다 필요한 작업을 작성해줄 필요가 없으니, 코드가 깔끔하고 읽기 쉬워지는 장점도 있습니다.

Reconcile 함수 실행 시 감시 대상 리소스를 한번만 수정하기

2편에서 For(), Owns(), Watches()를 이용해 감시할 리소스를 지정해줄 수 있다고 말씀드렸었는데, 혹시 기억 나시나요?

앞선 함수를 통해 감시 대상이 된 리소스가 생성, 변경 또는 삭제되면, 이벤트가 오퍼레이터 Watcher에 의해 감지되어 컨트롤러의 Reconcile 함수가 실행됩니다.

사용자나 다른 오퍼레이터가 감시 대상 리소스에 대해 연산을 수행할 때뿐만 아니라, Reconcile 함수 내에서 감시 대상 리소스에 대한 연산을 수행하는 경우에도 이벤트가 발생하여 또 다른 Reconcile 함수가 실행됩니다.

위 특성을 고려하여 Reconcile 함수 내에서 감시 대상 리소스에 대한 연산을 수행하게 되면, 그 즉시 Reconcile 함수를 종료하도록 작성해야합니다. 그럼 Reconcile 함수는 멱등적이므로, 다음 Reconcile 함수에서는 이전 Reconcile 함수에서 수행된 로직 이후부터 수행될 것입니다.

하나의 컨트롤러를 사용하고 있다면, 컨트롤러에서 현재 Reconcile 함수가 종료된 후 다음 Reconcile 함수에서 후속 이벤트를 처리할 것입니다. 병렬 처리를 위해 여러 컨트롤러를 사용하고 있다면, 감시 대상 리소스에 대한 연산이 수행된 즉시 다른 컨트롤러에서 후속 이벤트를 처리할 것입니다.

Reconcile 함수 실행 한번에 감시 대상 리소스 수정을 한번만 하도록 함으로써, 여러 이벤트가 불필요하게 큐에 쌓이는 문제를 해결하고 병렬 처리 시 컨트롤러 간 레이스 컨디션 (race condition)이 발생하는 위험을 줄일 수 있습니다.

리소스 연산 시 충돌 고려하기

여러 컨트롤러가 동시에 하나의 리소스에 대해 수정 연산을 수행하거나 컨트롤러 동작 중 사용자가 리소스를 업데이트할 경우 , 오퍼레이터 로그에서 The resourceVersion for the provided watch is too old.the object has been modified; please apply your changes to the latest version and try again 와 같은 로그를 확인할 수 있습니다. 이는 리소스 연산 시 충돌이 발생했음을 의미합니다.

쿠버네티스에서는 리소스 버전에 기반한 낙관적 동시성 컨트롤 (Optimistic Concurrency Control)을 통해 동시 연산으로 인한 충돌 발생 시 데이터 유실을 방지합니다.

리소스 버전은 쿠버네티스 API 서버에서 사용되는 오브젝트에 대한 버전 정보입니다. 낙관적 동시성 컨트롤에서 “낙관적”의 의미는 충돌 방지를 위해 사전에 Lock을 사용하는 대신, 충돌이 발생하면 충돌 해결 매커니즘을 실행한다는 의미입니다.

쿠버네티스의 기본 충돌 해결 매커니즘을 좀 더 자세히 알아보겠습니다.

  1. 쿠버네티스 API 서버에서 오브젝트를 Get하면, 리소스 버전이 포함된 오브젝트가 반환이 됩니다.
  2. 오퍼레이터에서 해당 오브젝트를 기반으로 데이터를 수정하고 Update합니다.
  3. 쿠버네티스 API 서버에서 전달된 오브젝트의 리소스 버전을 확인합니다.
  4. 만약 이 리소스 버전이 쿠버네티스 Etcd에 저장된 최신 오브젝트의 리소스 버전과 다를 경우 충돌로 판단하고 에러를 발생시킵니다.
  5. 오퍼레이터에서 에러를 확인하면 현재 Reconcile 함수를 종료시키고, Reconcile 함수를 다시 실행시켜 최신 오브젝트를 받아와 이전에 실패한 작업을 수행합니다 (이는 Reconcile 함수가 멱등적이기 때문에 가능한 것입니다).

쿠버네티스와 같은 분산 시스템에서 충돌이 발생하는 것은 흔한 일입니다. 컨트롤러에서는 충돌이 발생할 경우 Reconcile 함수를 재실행하면 되니 충돌을 해결하는 방법도 복잡하지 않습니다.

하지만, 저희의 경우 잦은 충돌로 인해 컨트롤러의 성능이 저하될 수 있는 점이 우려되었습니다. 그래서 저희는 충돌 횟수를 줄이기 위해, 컨트롤러 간 의존 관계를 파악해서 충돌이 발생하는 부분을 독립적으로 담당하는 커스텀 리소스와 컨트롤러를 개발하는 방법을 사용하기도 하였습니다.

리소스 상태 및 이벤트 표시하기

쿠버네티스에서 Status는 리소스의 현재 상태를 나타내는 필드입니다. 컨트롤러에서 리소스에 대한 조정을 수행한 후 리소스의 Status에 조정 결과를 나타낼 수 있습니다.

2편에서 Status를 업데이트할 때에는 아래와 같이 리소스의 Status 서브리소스를 업데이트한다고 말씀드렸습니다.

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

“Reconcile 함수 실행 시 감시 대상 리소스를 한번만 수정하기” 원칙에서 Reconcile 함수 내에서 리소스 업데이트를 수행하면, 업데이트 이벤트가 발생하여 또 다른 Reconcile 함수가 실행된다고 말씀드렸는데요.

Reconcile 함수에서 작업을 다 끝내고 Status를 업데이트하면 또 다른 Reconcile 함수가 실행되고, 이 함수도 마지막에 Status를 업데이트한다면, 또 다른 Reconcile 함수를 실행시켜 끝나지 않는 루프가 될 수 있습니다. 이를 막기 위해 Status 서브 리소스를 사용합니다.

쿠버네티스 공식 문서에 따르면, metadata나 status에 대한 업데이트는 metadata.generation 값을 증가시키지 않는다고 합니다.

The .metadata.generation value is incremented for all changes, except for changes to .metadata or .status.

오퍼레이터의 Watcher에서는 이런 특성을 활용해 Status 서브 리소스 업데이트로 인해 발생하는 이벤트를 Watcher가 무시하도록 하는 Predicates를 작성하여 사용하는 것으로 보입니다. (참고 자료)

Status 외에도 쿠버네티스 이벤트를 이용해 조정 결과와 에러를 나타내는 방법도 있습니다.

쿠버네티스 이벤트란 kubectl describe 를 이용해 리소스를 조회했을 때 제일 하단에 뜨는 로그를 말합니다.

오퍼레이터에서 이벤트를 생성하는 방법은 kubebuilder: Creating Eventscontroller-runtime의 recorder 문서를 참고하시기 바랍니다.

Finalizer 활용하기

쿠버네티스 Finalizer는 리소스가 완전히 삭제되기 전에 아직 처리할 게 남아 있음을 쿠버네티스에 알려 바로 삭제하지 못하도록 할 때 사용하는 필드입니다.

구체적으로, 컨트롤러가 자신이 소유하고 있지 않은 리소스를 참조하거나 사용하는 상황에서, 갑자기 해당 리소스가 다른 컨트롤러에 의해 삭제된다면 컨트롤러 동작에 문제가 발생할 수 있습니다. 이를 막기 위해 리소스에 Finalizer를 설정하여 리소스가 완전히 삭제하기 전에 리소스를 사용하던 컨트롤러에서 정리 (clean up)할 시간을 주는 것입니다.

컨트롤러에서 Finalizer를 사용하는 과정을 간략하게 나타내면 아래와 같습니다.

  1. 컨트롤러가 초기 단계에 리소스에 Finalizer를 설정합니다.
  2. 만약 리소스가 삭제되었을 경우, 해당 리소스에 컨트롤러가 설정해놓은 Finalizer가 있는지 확인합니다.
  3. Finalizer가 있을 경우 정리 작업을 수행합니다. 정리 작업이 끝나면 해당 리소스에 컨트롤러가 설정해놓은 Finalizer를 제거합니다.

Finalizer를 사용하실 때 주의하실 점은, 쿠버네티스에서 리소스에 Finalizer가 존재해 리소스가 Terminating 상태인 경우, 해당 리소스는 사실상 삭제되었다고 간주해야한다는 것입니다. 해당 리소스에 대해 업데이트와 같은 작업이 불가능해지므로, Terminating인 상태로 해당 리소스를 계속 사용할 수 없습니다.

따라서, 컨트롤러 동작에 삭제된 리소스 내 데이터가 필요한 상황이라면, 정리 작업에서 삭제된 리소스 정보를 기반으로 새로운 리소스를 직접 또는 다른 컨트롤러에게 요청하여 생성하도록 하는 방법을 사용할 수 있을 것 같습니다.

Admission Controller 활용하기

컨트롤러에서 읽고 변경하는 리소스는 쿠버네티스 API 서버를 통해 이미 Etcd에 저장된 것입니다. 따라서, 컨트롤러에서 리소스 필드 내용을 검증하고, 리소스가 유효하지 않을 경우 리소스 자체를 쿠버네티스 API 레벨에서 거부하는 작업을 수행할 수 없습니다. 대신, 리소스가 유효하지 않음을 커스텀 리소스 Status를 통해 사용자에게 알릴 수는 있을 것입니다.

Admission Controller를 사용하면 쿠버네티스 API 단계에서 API 요청을 검증하여 에러를 리턴하거나 리소스를 수정하는 작업을 수행할 수 있습니다.

Admission Controller의 구체적인 동작 방식과 생성 방법은 커피고래님의 쿠버네티스 Admission Control 시리즈 문서Operator SDK의 Webhooks 문서를 참고하시기 바랍니다.

마침

여기까지 쿠버네티스 오퍼레이터 3편까지가 끝이 났습니다! 나중에 또 공유하기 좋은 내용이 있다면 추가적으로 연재하도록 하겠습니다.

저희 팀에서 오퍼레이터를 개발할 때 Operator SDK 덕분에 쉽게 개발해나갈 수 있었지만, 이번 모범 사례편에서 다뤘던 것처럼 개발 시 생각보다 고려해야할 점이 많았던 것 같습니다. 저희 쿠버네티스 오퍼레이터 시리즈가 오퍼레이터를 개발하시는 분들께 도움이 되었으면 좋겠습니다.

지금까지 읽어주셔서 감사합니다 🙂

오퍼레이터 시리즈 바로가기

  1. 오퍼레이터와 커스텀 리소스
  2. 오퍼레이터 구현
  3. 오퍼레이터 개발 모범 사례

참고 자료

[1]https://cloud.google.com/blog/products/containers-kubernetes/best-practices-for-building-kubernetes-operators-and-stateful-apps

[2]https://sdk.operatorframework.io/docs/faqs/#what-are-the-the-differences-between-kubebuilder-and-operator-sdk

[3]https://cloud.redhat.com/blog/kubernetes-operators-best-practices

[4]https://cloud.redhat.com/blog/7-best-practices-for-writing-kubernetes-operators-an-sre-perspective

[5]https://alenkacz.medium.com/kubernetes-operators-best-practices-understanding-conflict-errors-d05353dff421

[6]https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#status-subresource

[7]https://cloud.redhat.com/blog/kubernetes-custom-resources-grow-up-in-v1-10

--

--

펜타시큐리티 보안기술연구소
PentaSecurity Labs

펜타시큐리티 보안 기술 연구소 사람들의 생활과 기술 연구 및 각종 활동에 관한 이야기를 담은 블로그