LabHub

블로그

Go 말고도 — Kopf, Metacontroller, Shell Operator로 Operator 만들기

한국어English日本語

들어가며 — Operator는 Go의 전유물이 아니다

"Operator를 만든다"고 하면 거의 반사적으로 Go와 kubebuilder가 떠오릅니다. 실제로 Operator SDK와 kubebuilder가 Go 생태계를 중심으로 발전해 왔고, controller-runtime이라는 강력한 라이브러리도 Go용입니다. 그래서 많은 팀이 "우리는 Go를 잘 모르니 Operator는 무리"라고 지레 포기합니다.

그러나 이것은 사실이 아닙니다. Operator의 본질은 "쿠버네티스 API를 watch하고 reconcile하는 컨트롤러"이지 특정 언어가 아닙니다. API 서버는 언어를 가리지 않고 누구에게나 같은 watch/REST 인터페이스를 제공합니다. 따라서 Python으로도, 셸 스크립트로도, 심지어 코드를 거의 쓰지 않고도 Operator를 만들 수 있습니다.

이 글은 Go/kubebuilder 외의 대표적 선택지들을 실제 코드와 함께 비교하고, 각각이 어떤 상황에 맞는지 판단 기준을 제시합니다.

큰 그림 — 비-Go Operator 프레임워크의 세 갈래

비-Go 선택지는 크게 세 갈래로 나뉩니다.

1) 다른 언어로 직접 reconcile 작성
   - Kopf (Python): 데코레이터 기반, 가장 Go-Operator에 가까운 경험

2) 선언적 훅 (코드 최소화)
   - Metacontroller: 상태를 JSON in/out 함수로 위임, 언어 무관

3) 셸/설정 기반
   - shell-operator: 훅을 셸 스크립트로
   - Ansible Operator: reconcile를 Ansible 플레이북으로

핵심 차이는 "reconcile 로직을 어디에, 어떤 형태로 담느냐"입니다. Kopf는 Python 함수에, Metacontroller는 외부 웹훅 함수에, shell/Ansible Operator는 스크립트/플레이북에 담습니다.

Kopf — Python으로 만드는 Operator

Kopf(Kubernetes Operator Pythonic Framework)는 Python 개발자에게 가장 자연스러운 선택입니다. 데코레이터로 이벤트 핸들러를 등록하는 방식이 직관적이고, Go-Operator의 reconcile 모델과 개념적으로 가깝습니다.

다음은 가상의 CRD(kind: Database)에 반응해 ConfigMap을 만드는 간단한 Kopf 핸들러입니다.

import kopf
import kubernetes


@kopf.on.create('example.com', 'v1', 'databases')
def create_fn(spec, name, namespace, logger, **kwargs):
    size = spec.get('size', 1)
    logger.info(f"Database {name} 생성 요청 — size={size}")

    api = kubernetes.client.CoreV1Api()
    cm = kubernetes.client.V1ConfigMap(
        metadata=kubernetes.client.V1ObjectMeta(name=f"{name}-config"),
        data={"size": str(size)},
    )
    api.create_namespaced_config_map(namespace=namespace, body=cm)

    # status에 반영할 값을 반환하면 Kopf가 자동으로 기록한다.
    return {"provisioned": True, "size": size}


@kopf.on.update('example.com', 'v1', 'databases')
def update_fn(spec, status, name, namespace, logger, **kwargs):
    new_size = spec.get('size', 1)
    logger.info(f"Database {name} 업데이트 — 새 size={new_size}")
    # 변경 reconcile 로직...


@kopf.on.delete('example.com', 'v1', 'databases')
def delete_fn(name, logger, **kwargs):
    logger.info(f"Database {name} 정리")
    # finalizer 정리 로직은 Kopf가 자동 관리한다.

Kopf의 강점은 다음과 같습니다.

주의할 점은 CRD 자체는 별도로 정의해 적용해야 한다는 것입니다. Kopf는 핸들러를 다루지 CRD 스키마 생성을 대신해 주지는 않습니다. 또한 Python 런타임 특성상 Go 대비 메모리 사용과 콜드 스타트가 무겁습니다.

Metacontroller — 코드 없이 선언적 훅으로

Metacontroller는 발상이 다릅니다. reconcile 루프 전체를 Metacontroller가 운영하고, 사용자는 "현재 상태(JSON)를 받아 바람직한 자식 리소스(JSON)를 돌려주는 함수"만 제공합니다. 이 함수는 단순한 HTTP 엔드포인트라서 어떤 언어로든 작성할 수 있습니다.

Metacontroller의 두 가지 핵심 컨트롤러는 다음과 같습니다.

CompositeController의 동작 흐름은 이렇습니다.

[Metacontroller]
  부모 CR + 현재 자식들을 JSON으로 묶어
        │  POST (sync 요청)
[사용자의 webhook 함수]  (Python/JS/무엇이든)
  desired 자식 리소스 목록(JSON)을 반환
[Metacontroller]
  반환된 desired 상태로 자식 리소스를 생성/수정/삭제

sync 훅이 받는 요청과 반환하는 응답은 모두 단순 JSON입니다. 예를 들어 Python으로 작성한 sync 훅의 핵심은 이렇습니다.

from flask import Flask, request, jsonify

app = Flask(__name__)


@app.route('/sync', methods=['POST'])
def sync():
    observed = request.get_json()
    parent = observed['parent']
    replicas = parent['spec'].get('replicas', 1)
    name = parent['metadata']['name']

    # 바람직한 자식 리소스(Deployment)를 JSON으로 구성해 반환
    desired_deployment = {
        "apiVersion": "apps/v1",
        "kind": "Deployment",
        "metadata": {"name": name},
        "spec": {
            "replicas": replicas,
            "selector": {"matchLabels": {"app": name}},
            "template": {
                "metadata": {"labels": {"app": name}},
                "spec": {"containers": [{"name": "app", "image": "nginx"}]},
            },
        },
    }

    return jsonify({"status": {"replicas": replicas}, "children": [desired_deployment]})

Metacontroller의 매력은 reconcile의 어려운 부분(watch, 큐, 재시도, 가비지 컬렉션, 소유 관계)을 전부 Metacontroller가 책임진다는 것입니다. 사용자는 순수 함수("이 입력에 대한 바람직한 출력")만 작성합니다. 함수형 사고에 익숙하다면 매우 깔끔합니다. 단점은 Metacontroller 자체를 클러스터에 설치·운영해야 하고, 복잡한 명령형 운영 로직(순차 작업, 외부 시스템 연동)에는 표현력이 부족하다는 점입니다.

shell-operator와 Ansible Operator — 스크립트/플레이북 기반

shell-operator

shell-operator는 "이벤트가 발생하면 셸 스크립트(또는 임의 실행파일)를 실행"하는 단순하고 강력한 프레임워크입니다. 각 훅은 자신이 어떤 이벤트에 반응할지 설정(JSON)을 출력하고, 실제 이벤트가 오면 본문이 실행됩니다.

#!/usr/bin/env bash

if [[ $1 == "--config" ]]; then
  # 이 훅이 구독할 이벤트를 선언
  cat <<EOF
configVersion: v1
kubernetes:
  - apiVersion: v1
    kind: ConfigMap
    executeHookOnEvent: ["Added", "Modified"]
EOF
else
  # 실제 이벤트 처리: 바인딩 컨텍스트가 파일 경로로 전달된다
  echo "ConfigMap 이벤트 감지, kubectl로 후속 작업 수행"
  # kubectl ... 같은 명령으로 reconcile
fi

shell-operator는 운영 자동화를 이미 셸/kubectl로 하고 있는 팀에 친숙합니다. 다만 멱등성, 에러 처리, 재시도를 모두 스크립트 작성자가 책임져야 하므로 복잡해질수록 관리가 어렵습니다.

Ansible Operator

Operator SDK는 Go 외에 Ansible 기반 Operator도 지원합니다. CR의 변화를 watch하면 그에 대응하는 Ansible 플레이북이 실행되어 바람직한 상태를 구현합니다. 이미 Ansible로 인프라를 관리하던 조직이 그 자산을 그대로 쿠버네티스 reconcile로 옮기기에 좋습니다. 멱등성은 Ansible 모듈이 상당 부분 보장해 주는 것이 장점입니다.

Kopf 더 깊이 — 주기 실행과 멱등성

Kopf는 단발 이벤트 핸들러뿐 아니라 주기적 reconcile도 지원합니다. 외부 상태(예: 클라우드 자원, SaaS API)를 주기적으로 점검해 클러스터를 맞추는 데 유용합니다.

import kopf


@kopf.timer('example.com', 'v1', 'databases', interval=60.0)
def reconcile_periodically(spec, status, name, logger, **kwargs):
    # 60초마다 호출되어 desired state를 외부와 대조한다.
    desired = spec.get('size', 1)
    current = status.get('observedSize')
    if current != desired:
        logger.info(f"드리프트 감지: 현재={current}, 목표={desired}")
        # 외부 시스템을 desired에 맞추는 로직
        return {"observedSize": desired}
    # 변화가 없으면 아무것도 반환하지 않아 status 갱신을 피한다 (멱등)

여기서 중요한 원칙이 멱등성입니다. timer 핸들러는 60초마다 호출되므로, 매번 무조건 외부 API를 호출하거나 status를 갱신하면 부하와 무한 갱신 루프를 부릅니다. "이미 목표 상태면 아무것도 하지 않는다"를 코드에 명시해야 합니다. 이 원칙은 Go-Operator와 완전히 동일하며, 언어가 바뀌어도 reconcile의 본질은 변하지 않음을 보여줍니다.

Kopf는 이 밖에도 자식 리소스 watch(on.event), 서브핸들러, 에러 시 지수 백오프, 동시성 제어 등을 제공합니다. Python 팀이 프로덕션급 Operator를 만들기에 충분한 기능 집합입니다.

배포 방법 비교 — 각 프레임워크는 어떻게 클러스터에 올라가는가

만든 Operator를 실제로 클러스터에 올리는 방식도 프레임워크마다 다릅니다.

공통 구조 (어떤 프레임워크든)
  [컨테이너 이미지]  ← reconcile 로직(코드/스크립트/플레이북)
        +
  [CRD]            ← 사용자가 선언할 어휘
        +
  [RBAC]           ← 컨트롤러가 다룰 리소스 권한
        +
  [Deployment]     ← 컨트롤러를 실제로 실행

언어와 프레임워크가 달라도 "이미지 + CRD + RBAC + 실행 워크로드"라는 골격은 동일합니다. 이 공통 구조를 이해하면 어떤 프레임워크를 만나도 빠르게 적응할 수 있습니다.

비교 테이블 — 한눈에 보는 선택지

항목Go/kubebuilderKopf(Python)Metacontrollershell-operatorAnsible Operator
주 언어GoPython언어 무관(JSON)셸/임의Ansible YAML
학습 곡선높음중간중간낮음낮음(Ansible 경험 시)
표현력매우 높음높음중간(선언적)낮음~중간중간
성능/리소스가장 우수보통보통가벼움보통
멱등성 책임개발자일부 자동프레임워크개발자Ansible 모듈
생태계 성숙도가장 성숙성숙안정적·틈새안정적·틈새성숙
적합 상황프로덕션 표준Python 팀, ML 연동단순 합성 패턴가벼운 운영 자동화Ansible 자산 보유

이 표에서 도출되는 단순한 지침은 이렇습니다. 프로덕션 표준이 필요하고 장기 유지보수가 예상되면 Go가 여전히 1순위입니다. 그러나 팀 역량, 자동화의 복잡도, 기존 자산에 따라 비-Go 선택지가 훨씬 빠르고 합리적인 길이 됩니다.

프로토타이핑 vs 프로덕션

도구 선택에서 가장 중요한 질문 하나는 "이것이 프로토타입인가, 프로덕션인가"입니다.

현명한 전략 하나는 "Kopf로 프로토타입을 빠르게 만들어 CRD 스키마와 reconcile 로직의 가치를 검증한 뒤, 정말로 대규모·장수명이 확정되면 Go로 다시 구현"하는 것입니다. CRD 스키마 자체는 언어 독립적이므로, 사용자 인터페이스(CR)를 바꾸지 않고 내부 구현만 교체할 수 있습니다.

언어 선택 기준

선택을 좌우하는 실질적 요인은 다음과 같습니다.

  1. 팀의 역량: 팀이 Python에 능하고 Go 경험이 없다면, Go로 억지로 가는 것보다 Kopf로 잘 만든 Operator가 운영상 더 안전합니다. 유지보수 주체가 읽고 고칠 수 있는 코드가 최고의 코드입니다.
  2. 외부 연동의 성격: reconcile 안에서 머신러닝 추론, 복잡한 데이터 변환, 특정 SaaS SDK 호출이 필요하다면 그 SDK가 잘 갖춰진 언어(흔히 Python)가 유리합니다.
  3. 자동화의 복잡도: 단순한 "부모 → 자식" 합성이면 Metacontroller로 코드를 거의 안 써도 됩니다. 반대로 순차적 단계와 상태 머신이 복잡하면 명령형 언어(Go/Python)가 낫습니다.
  4. 기존 자산: 이미 Ansible로 인프라를 굴린다면 Ansible Operator가 자산 재사용 측면에서 합리적입니다.

성능과 리소스 — 언어 선택의 숨은 비용

비-Go 프레임워크의 매력은 개발 속도지만, 런타임 비용도 함께 봐야 합니다.

이 비용들은 소규모·프로토타입에서는 거의 무시할 만합니다. 그러나 대규모 프로덕션에서 누적되면 무시할 수 없어집니다. 그래서 "프로토타입은 빠른 도구로, 대규모 프로덕션은 Go로"라는 전형적 경로가 합리적인 것입니다.

마이그레이션 — 프레임워크 간 이동

비-Go에서 Go로(또는 그 반대로) 옮기는 일은 생각보다 부드럽습니다. 핵심은 CRD라는 계약은 그대로 두고 구현만 바꾼다는 것입니다.

  1. CR 스키마를 고정한다. 사용자가 보는 인터페이스(apiVersion, kind, spec 필드)는 마이그레이션 동안 변하지 않아야 합니다.
  2. 동등성 테스트를 준비한다. 같은 CR 입력에 대해 옛 구현과 새 구현이 동일한 자식 리소스/상태를 만드는지 비교합니다.
  3. 컨트롤러를 교체한다. 한 클러스터에서 한 시점에는 하나의 컨트롤러만 해당 CRD를 reconcile해야 합니다. 두 구현이 동시에 같은 CR을 건드리면 충돌합니다.
  4. 점진 전환. 가능하면 새 구현을 별도 네임스페이스/별도 CRD 버전에서 검증한 뒤 전환합니다.

생태계 성숙도 — 현실적 시각

각 도구의 성숙도는 솔직히 평가할 필요가 있습니다.

성숙도를 볼 때는 "프로젝트가 살아 있는가, 사용 사례가 내 문제와 닮았는가, 문제가 생겼을 때 참고할 자료와 커뮤니티가 있는가"를 함께 보는 것이 좋습니다.

함정 모음

Metacontroller DecoratorController — 기존 리소스에 덧붙이기

앞서 CompositeController가 "부모 CR → 자식 리소스"를 다룬다면, DecoratorController는 발상이 조금 다릅니다. 기존 리소스(빌트인 또는 다른 CR)에 추가 동작을 "장식(decorate)"합니다. 예를 들어 "특정 라벨이 붙은 Deployment가 생기면 항상 짝이 되는 Service를 함께 만든다" 같은 규칙을 코드 거의 없이 구현할 수 있습니다.

DecoratorController의 사용 흐름
  대상: 라벨 app-type=web 이 붙은 Deployment
  Metacontroller가 sync 훅 호출 (해당 Deployment를 JSON으로 전달)
  훅이 "이 Deployment에 딸린 Service" desired JSON 반환
  Metacontroller가 Service를 생성/유지

이 패턴은 앞 글에서 다룬 "CRD 없는 컨트롤러"의 아이디어와 만납니다. 즉 DecoratorController를 쓰면 새로운 CRD를 만들지 않고도, 기존 리소스에 대한 자동화를 선언적 훅으로 구현할 수 있습니다. Go 컨트롤러를 짜는 것보다 진입 장벽이 훨씬 낮습니다.

적합 시나리오 — 구체적 사례로 보기

각 프레임워크가 빛나는 실제 상황을 그려 보면 선택이 한결 쉬워집니다.

Kopf가 맞는 경우

사내 머신러닝 플랫폼 팀이 "TrainingJob이라는 CR이 생기면 데이터셋을 검증하고, 외부 피처 스토어 API를 호출해 메타데이터를 가져온 뒤, 적절한 Job을 만든다"는 자동화를 원합니다. 이 로직은 Python 데이터 라이브러리와 사내 SDK(역시 Python)에 깊이 의존합니다. 팀의 주력 언어도 Python입니다. 이때 Go로 가면 SDK를 다시 래핑하느라 시간을 허비합니다. Kopf가 명백한 정답입니다.

Metacontroller가 맞는 경우

"우리 회사의 표준 마이크로서비스 CR(kind: Microservice)을 만들면 항상 Deployment + Service + HPA + ServiceMonitor 세트가 같이 생겨야 한다"는 요구가 있습니다. 로직은 순수하게 "이 spec → 이 자식들"입니다. 명령형 절차도, 외부 호출도 없습니다. CompositeController의 sync 훅으로 입력 spec을 받아 desired 자식 목록을 반환하면 끝입니다. Go 컨트롤러를 새로 짤 이유가 없습니다.

shell-operator가 맞는 경우

운영팀이 이미 모든 자동화를 kubectl과 bash로 하고 있고, "특정 네임스페이스에 Secret이 추가되면 외부 비밀 관리 시스템에 동기화"라는 작은 자동화 하나만 필요합니다. 새 언어를 배우거나 빌드 파이프라인을 만들 여유가 없습니다. shell-operator로 기존 스크립트 자산을 거의 그대로 이벤트 기반으로 바꿀 수 있습니다.

Ansible Operator가 맞는 경우

이미 수백 개의 Ansible 플레이북으로 온프레미스 인프라를 관리하던 조직이, 그 운영 지식을 쿠버네티스 reconcile로 옮기고 싶어 합니다. 플레이북을 거의 재사용하면서 CR 기반으로 트리거할 수 있어, 학습 비용이 가장 낮습니다.

이 사례들의 공통 교훈은 "맥락이 도구를 결정한다"는 것입니다. 똑같은 "Operator를 만든다"는 목표라도, 팀의 언어·기존 자산·로직의 성격에 따라 최적의 도구가 완전히 달라집니다.

결정 체크리스트 — 우리 팀은 무엇을 골라야 하나

마지막으로 실제 선택을 돕는 체크리스트입니다. 위에서부터 순서대로 답해 보세요.

  1. 이미 잘 관리되는 외부 Operator가 있는가? 있다면 직접 만들지 말고 그것을 쓰세요. 가장 저렴한 선택입니다.
  2. 새 CRD가 정말 필요한가, 아니면 빌트인 리소스 자동화로 충분한가? 후자라면 (이전 글의) CRD 없는 컨트롤러나 Metacontroller DecoratorController를 검토하세요.
  3. 프로토타입인가, 프로덕션인가? 프로토타입이면 Kopf나 Metacontroller로 빠르게. 대규모 프로덕션이면 Go를 진지하게 고려.
  4. 팀의 주력 언어는? Python 팀이면 Kopf가 자연스럽습니다. Ansible 자산이 있으면 Ansible Operator.
  5. 로직이 선언적인가, 명령형인가? "입력 → 바람직한 출력"으로 깔끔히 표현되면 Metacontroller. 복잡한 순차 절차면 Kopf/Go.
  6. 장기 유지보수 주체가 읽고 고칠 수 있는가? 이것이 가장 중요합니다. 아무리 멋진 도구라도 팀이 다룰 수 없으면 부채입니다.

이 여섯 질문을 통과하면, 대부분의 경우 자연스럽게 한두 개 후보로 좁혀집니다. 핵심은 "남들이 쓰니까"가 아니라 "우리 문제와 우리 팀에 맞으니까"를 기준으로 고르는 것입니다.

한 줄 요약 — 다섯 프레임워크의 정체성

마지막으로 각 도구를 한 문장으로 각인해 두겠습니다.

이 다섯 줄을 기억해 두면, 새로운 자동화 요구를 만났을 때 머릿속에서 빠르게 후보를 떠올릴 수 있습니다.

생태계 트렌드 — 2026년 시점에서

비-Go 도구를 도입하기 전에 생태계의 현재 위치를 짚어 둘 필요가 있습니다.

요약하면, 2026년에도 "프로토타입과 특수 언어 요구는 비-Go로, 대규모 표준 프로덕션은 Go로"라는 큰 구도는 유지되고 있습니다.

마치며

Operator는 Go의 전유물이 아닙니다. 쿠버네티스 API는 모든 언어에 공평하게 열려 있고, Kopf, Metacontroller, shell-operator, Ansible Operator는 각자 다른 강점으로 그 문을 통과합니다. 중요한 것은 "가장 멋진 도구"가 아니라 "우리 팀이 빠르게 만들고 오래 유지보수할 수 있는 도구"입니다.

빠른 검증에는 Kopf와 Metacontroller가, 대규모·장수명 프로덕션에는 Go가 여전히 강합니다. 그리고 둘 사이에는 CRD라는 안정적 계약이 있어, 필요하면 인터페이스를 깨지 않고 구현만 갈아끼울 수 있습니다. 언어가 아니라 문제의 본질(reconcile)을 먼저 이해하면, 도구는 자연스럽게 따라옵니다.

마지막으로 한 가지 더 강조하고 싶습니다. "비-Go 프레임워크를 쓴다"는 것이 "타협한다"는 뜻은 아닙니다. 많은 조직이 Kopf로 만든 Operator를 수년간 프로덕션에서 안정적으로 운영하고 있고, Metacontroller로 표준 플랫폼 추상화를 구축한 사례도 많습니다. 중요한 것은 도구의 한계를 정확히 알고, 그 한계가 우리 문제에 닿지 않는 범위에서 가장 생산적인 길을 고르는 것입니다. 도구의 명성이 아니라 우리 팀의 실제 역량과 문제의 모양에 맞추세요. 그것이 오래 살아남는 자동화를 만드는 가장 확실한 방법입니다.

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다