Skip to content
Published on

Rust로 쿠버네티스 Operator 만들기 (kube-rs)

Authors

들어가며 — 운영 지식을 코드로

쿠버네티스를 처음 배우면 Deployment, Service, ConfigMap 같은 기본 리소스로 앱을 배포합니다. 하지만 실제 운영에서는 그 이상이 필요합니다. 데이터베이스 하나를 제대로 운영하려면 백업을 걸고, 장애가 나면 페일오버하고, 스키마를 마이그레이션하고, 스토리지가 차면 늘려야 합니다. 이런 절차는 보통 사람의 머릿속(runbook)에 있습니다.

Operator는 바로 이 운영 지식을 코드로 옮긴 것입니다. 사람이 클러스터를 지켜보며 "상태가 A인데 B여야 하니 이렇게 고친다"를 반복하듯, 오퍼레이터는 컨트롤러 프로그램으로 같은 일을 자동으로 합니다. 2016년 CoreOS가 이 개념을 정립했고, 지금은 Prometheus, Cert-Manager, 각종 데이터베이스가 모두 오퍼레이터로 관리됩니다.

이 글은 Operator 패턴의 원리를 짚고, Rust 생태계의 kube-rs로 실제 오퍼레이터를 만드는 법을 다룹니다. 그리고 왜 이 영역에서 Rust가 매력적인 선택인지도 이야기합니다.

Operator 패턴 — 세 가지 조각

오퍼레이터는 개념적으로 세 조각으로 이루어집니다.

  • CRD(Custom Resource Definition): 쿠버네티스에 새로운 리소스 종류를 등록하는 스키마입니다. 기본 제공 리소스가 Pod, Service라면, CRD로 우리만의 리소스(예: Database, Backup)를 API 서버에 추가할 수 있습니다. CRD를 등록하면 사용자는 그 리소스를 YAML로 선언하고 kubectl get database처럼 다룰 수 있게 됩니다.
  • 커스텀 리소스(CR): CRD가 정의한 스키마에 맞춰 실제로 만든 인스턴스입니다. "이름은 my-db, 복제본은 3, 스토리지는 10Gi" 같은 사용자의 의도(desired state)를 담습니다.
  • 컨트롤러(controller): 커스텀 리소스를 감시하며 실제 상태(actual state)를 원하는 상태로 맞추는 프로그램입니다. 이 맞추는 과정이 핵심이고, 이를 reconcile(조정)이라고 부릅니다.

이 구조의 바탕에는 쿠버네티스 전체를 관통하는 사고방식이 있습니다. 바로 선언적(declarative) 제어입니다. 사용자는 "무엇을 원하는지"만 선언하고, 컨트롤러가 "어떻게 거기에 도달할지"를 책임집니다.

reconcile 루프 — 오퍼레이터의 심장

컨트롤러의 핵심은 reconcile 루프입니다. 동작 원리는 놀랍도록 단순합니다.

  1. 원하는 상태(spec)를 읽는다
  2. 실제 상태를 관찰한다
  3. 둘의 차이를 계산한다
  4. 실제 상태를 원하는 상태 쪽으로 한 걸음 옮긴다
  5. 다시 1로 (또는 일정 시간 후 재실행)

여기서 중요한 원칙이 두 가지 있습니다.

첫째, reconcile은 멱등(idempotent) 해야 합니다. 같은 입력으로 몇 번을 실행하든 결과가 같아야 합니다. reconcile 함수는 "생성하라"가 아니라 "이 상태가 되도록 보장하라"로 짜야 합니다. 이미 원하는 상태면 아무것도 하지 않고, 부족하면 채우고, 그게 전부입니다.

둘째, reconcile은 레벨 기반(level-triggered) 이지 이벤트 기반(edge-triggered)이 아닙니다. "무슨 일이 일어났는가(이벤트)"가 아니라 "지금 상태가 어떤가(레벨)"를 보고 판단합니다. 이 덕분에 이벤트를 몇 개 놓쳐도, 컨트롤러가 재시작해도, 결국 현재 상태만 보고 올바르게 수렴합니다. 오퍼레이터의 견고함은 여기서 나옵니다.

kube-rs — Rust로 쿠버네티스 다루기

kube-rs는 Rust로 쿠버네티스 클라이언트와 컨트롤러를 만드는 사실상의 표준 크레이트입니다. 크게 세 부분으로 구성됩니다.

  • kube::Client — API 서버와 통신하는 클라이언트
  • kube::Api — 특정 리소스 종류에 대한 타입 안전한 접근(get, list, patch 등)
  • kube::runtimeController, watcher, reflector 등 컨트롤러를 짜는 데 필요한 상위 도구

의존성은 대략 이렇게 잡습니다. 실제 버전은 crates.io에서 최신을 확인하세요.

[dependencies]
kube = { version = "0.99", features = ["runtime", "derive", "client"] }
k8s-openapi = { version = "0.24", features = ["latest"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
tokio = { version = "1", features = ["full"] }
thiserror = "2"
futures = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"

k8s-openapi는 Pod, Deployment 같은 기본 리소스 타입을 Rust 구조체로 제공하고, schemars는 CRD 스키마(JSON Schema)를 자동 생성하는 데 씁니다.

CustomResource 파생 — CRD를 코드로 정의

kube-rs의 가장 우아한 부분은 CRD를 Rust 구조체로 정의한다는 점입니다. CustomResource 파생 매크로를 붙이면, 스펙 구조체 하나로부터 완전한 커스텀 리소스 타입과 CRD 정의가 만들어집니다.

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// 우리가 관리할 애플리케이션의 "원하는 상태"
#[derive(CustomResource, Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[kube(
    group = "example.com",
    version = "v1",
    kind = "WebApp",
    namespaced,
    status = "WebAppStatus",
    shortname = "wa"
)]
pub struct WebAppSpec {
    /// 실행할 컨테이너 이미지
    pub image: String,
    /// 원하는 레플리카 수
    pub replicas: i32,
}

/// 컨트롤러가 채워 넣는 "관찰된 상태"
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct WebAppStatus {
    pub available_replicas: i32,
    pub ready: bool,
}

이 매크로 하나가 여러 일을 합니다. WebApp이라는 타입이 생기고(WebAppSpecspec 필드로 감싼 형태), WebApp::crd()를 호출하면 클러스터에 등록할 CRD 매니페스트가 나옵니다. status 서브리소스는 스펙과 분리되어, 컨트롤러가 상태를 갱신해도 사용자의 스펙과 충돌하지 않습니다.

생성된 CRD를 YAML로 뽑으면 대략 이런 모습입니다. 이 안에는 중괄호와 꺾쇠 같은 스키마 표현이 들어 있으므로, 프로즈가 아니라 반드시 코드 블록 안에 둡니다.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: webapps.example.com
spec:
  group: example.com
  names:
    kind: WebApp
    plural: webapps
    shortNames: ["wa"]
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image: { type: string }
                replicas: { type: integer }

사용자는 이제 이런 커스텀 리소스를 선언할 수 있습니다.

apiVersion: example.com/v1
kind: WebApp
metadata:
  name: hello
  namespace: default
spec:
  image: nginx:1.27
  replicas: 3

reconcile 함수 — 핵심 로직

이제 컨트롤러의 심장인 reconcile 함수를 짭니다. kube-rs의 Controller는 감시 중인 리소스에 변화가 있을 때(또는 주기적으로) 이 함수를 호출합니다. 시그니처는 "커스텀 리소스 하나와 공유 컨텍스트를 받아, 다음에 언제 다시 부를지(Action)를 돌려준다"입니다.

use std::sync::Arc;
use std::time::Duration;
use k8s_openapi::api::apps::v1::Deployment;
use kube::api::{Api, Patch, PatchParams};
use kube::runtime::controller::Action;
use kube::{Client, ResourceExt};

pub struct Context {
    pub client: Client,
}

async fn reconcile(obj: Arc<WebApp>, ctx: Arc<Context>) -> Result<Action, Error> {
    let ns = obj.namespace().unwrap_or_default();
    let name = obj.name_any();
    let deployments: Api<Deployment> = Api::namespaced(ctx.client.clone(), &ns);

    // 원하는 상태에 맞는 Deployment를 구성한다
    let desired = build_deployment(&obj)?;

    // server-side apply로 "이 상태가 되도록 보장"한다 (멱등)
    let pp = PatchParams::apply("webapp-operator").force();
    deployments
        .patch(&name, &pp, &Patch::Apply(&desired))
        .await?;

    tracing::info!(%ns, %name, "reconciled WebApp");

    // 성공하면 5분 뒤 주기적으로 다시 확인한다
    Ok(Action::requeue(Duration::from_secs(300)))
}

핵심은 Patch::Apply(server-side apply)를 쓴다는 점입니다. "생성해라"가 아니라 "결과가 이 매니페스트와 같아지도록 만들어라"이므로, 몇 번을 호출해도 안전합니다. 이것이 앞에서 말한 멱등성의 실제 구현입니다.

build_deployment는 스펙의 imagereplicas로 표준 Deployment 구조체를 만드는 순수 함수입니다. k8s-openapi가 제공하는 타입을 채워 넣으면 됩니다.

에러 처리와 requeue — 지수 백오프

reconcile은 실패할 수 있습니다. API 서버가 잠깐 응답하지 않거나, 다른 컨트롤러와 충돌하거나, 일시적 네트워크 오류가 날 수 있습니다. Rust의 Result와 kube-rs의 Action이 이걸 우아하게 처리합니다.

먼저 에러 타입을 thiserror로 정의합니다.

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Kube API error: {0}")]
    Kube(#[from] kube::Error),

    #[error("Missing object key")]
    MissingKey,
}

그리고 reconcile이 실패했을 때 어떻게 재시도할지 정하는 error_policy를 짭니다. 여기서 지수 백오프의 첫걸음을 표현할 수 있습니다.

fn error_policy(_obj: Arc<WebApp>, err: &Error, _ctx: Arc<Context>) -> Action {
    tracing::warn!("reconcile failed: {err}, retrying");
    // 실패하면 짧게 기다렸다가 다시 시도한다
    Action::requeue(Duration::from_secs(10))
}

Action에는 세 가지 선택지가 있습니다.

  • Action::requeue(duration) — 이 시간 뒤에 다시 reconcile한다(주기적 재검사나 재시도).
  • Action::await_change() — 리소스에 실제 변화가 생길 때까지 기다린다(할 일이 없을 때).
  • 성공 후 requeue로 긴 간격을 주면, 이벤트가 없어도 주기적으로 상태를 확인(드리프트 감지)한다.

reconcile이 Err를 반환하면 Controller가 자동으로 error_policy를 부르고, 반환된 간격 뒤에 다시 시도합니다. 이 조합으로 실패를 자연스럽게 재시도로 흡수합니다.

파이널라이저 — 정리 로직의 안전장치

커스텀 리소스가 삭제될 때, 관련 외부 리소스(클라우드 로드밸런서, 외부 DB, 오브젝트 스토리지 버킷 등)도 함께 정리해야 하는 경우가 많습니다. 문제는 리소스가 삭제되면 컨트롤러가 그 스펙을 더 이상 볼 수 없다는 것입니다. 이걸 해결하는 장치가 파이널라이저(finalizer) 입니다.

파이널라이저는 오브젝트의 메타데이터에 붙는 문자열 목록입니다. 이 목록이 비어 있지 않으면, 쿠버네티스는 오브젝트를 실제로 삭제하지 않고 deletionTimestamp만 찍어 둡니다. 즉 "삭제 예약" 상태가 됩니다. 컨트롤러가 정리 작업을 마치고 파이널라이저를 떼면, 그제서야 오브젝트가 사라집니다. 이 덕분에 정리 로직을 실행할 시간을 확보할 수 있습니다.

kube-rs는 이 패턴을 finalizer 헬퍼로 감싸 줍니다. 생성/수정과 삭제를 두 갈래로 나눠 처리하게 해 줍니다.

use kube::runtime::finalizer::{finalizer, Event as FinalizerEvent};

async fn reconcile(obj: Arc<WebApp>, ctx: Arc<Context>) -> Result<Action, Error> {
    let ns = obj.namespace().unwrap_or_default();
    let api: Api<WebApp> = Api::namespaced(ctx.client.clone(), &ns);

    finalizer(&api, "webapp.example.com/cleanup", obj, |event| async {
        match event {
            // 생성 또는 갱신될 때: 정상 reconcile
            FinalizerEvent::Apply(app) => apply(app, ctx.clone()).await,
            // 삭제될 때: 외부 리소스 정리 후 파이널라이저 제거
            FinalizerEvent::Cleanup(app) => cleanup(app, ctx.clone()).await,
        }
    })
    .await
    .map_err(|_| Error::MissingKey)
}

이 헬퍼가 파이널라이저 추가/제거를 알아서 처리하므로, 우리는 "적용될 때 할 일"과 "삭제될 때 정리할 일"만 채우면 됩니다. Cleanup 분기가 성공적으로 끝나야 파이널라이저가 제거되고 오브젝트가 실제로 삭제됩니다. 만약 정리가 실패하면 파이널라이저가 남아 오브젝트도 남으므로, 리소스가 누수되지 않습니다.

Controller 조립 — main 함수

마지막으로 이 모든 것을 Controller로 엮어 실행합니다. Controller는 감시 대상(우리의 WebApp)과, 그 오퍼레이터가 소유(own)하는 하위 리소스(여기서는 Deployment)를 함께 지켜봅니다. 하위 리소스가 바뀌어도(누가 Deployment를 손대도) reconcile이 다시 트리거됩니다. 이것이 자기 치유(self-healing)의 원천입니다.

use futures::StreamExt;
use kube::runtime::watcher::Config;
use kube::runtime::Controller;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();
    let client = Client::try_default().await?;

    let webapps: Api<WebApp> = Api::all(client.clone());
    let deployments: Api<Deployment> = Api::all(client.clone());
    let ctx = Arc::new(Context { client });

    Controller::new(webapps, Config::default())
        .owns(deployments, Config::default())
        .run(reconcile, error_policy, ctx)
        .for_each(|res| async move {
            match res {
                Ok((obj, _action)) => tracing::info!("reconciled {:?}", obj.name),
                Err(e) => tracing::warn!("reconcile error: {e}"),
            }
        })
        .await;

    Ok(())
}

Controller는 내부적으로 watcher와 reflector로 대상 리소스의 캐시를 유지하고, 관련 이벤트가 오면 해당 오브젝트를 작업 큐에 넣어 reconcile을 호출합니다. 여러 이벤트가 몰려도 같은 오브젝트에 대한 reconcile은 직렬화되고 중복은 병합되므로, 우리는 동시성 문제를 크게 신경 쓰지 않아도 됩니다.

왜 Go/kubebuilder 대신 Rust인가

쿠버네티스 자체가 Go로 쓰였고, 오퍼레이터의 주류 프레임워크(controller-runtime, kubebuilder, Operator SDK)도 Go 기반입니다. 생태계 성숙도와 예제 양에서는 Go가 여전히 압도적입니다. 그럼에도 Rust가 매력적인 이유가 있습니다.

  • 작은 리소스 풋프린트. 오퍼레이터는 클러스터 안에 상주하며 계속 도는 프로세스입니다. Rust 바이너리는 가비지 컬렉터가 없어 메모리 사용이 작고 안정적이며, 컨테이너 이미지도 정적 링크로 수 MB까지 줄일 수 있습니다. 수백 개 클러스터에 오퍼레이터를 배포하는 상황이라면 이 절약이 쌓입니다. GC로 인한 간헐적 지연(스톱 더 월드)이 없다는 점도 지연에 민감한 컨트롤러에 유리합니다.
  • 메모리 안전성과 강한 타입. reconcile 로직은 여러 리소스의 상태를 다루는 미묘한 코드입니다. Rust의 소유권 모델과 Result 기반 에러 처리는 널 참조, 데이터 레이스, 처리하지 않은 에러를 컴파일 타임에 상당 부분 잡아 줍니다. "컴파일되면 대체로 돈다"는 감각이 운영 코드에서 특히 값집니다.
  • 표현력 있는 타입으로 상태를 모델링. Rust의 열거형(enum)과 패턴 매칭은 리소스의 상태 전이(예: Pending → Provisioning → Ready → Failed)를 타입으로 정확히 표현하기에 좋습니다. 잘못된 상태 조합을 아예 표현 불가능하게 만들 수 있습니다.

물론 트레이드오프는 있습니다. 학습 곡선이 가파르고, 컴파일 시간이 길며, Go만큼 예제가 풍부하지 않습니다. 팀이 이미 Go에 익숙하고 빠르게 오퍼레이터를 찍어내야 한다면 kubebuilder가 실용적입니다. 반대로 오퍼레이터의 효율과 견고함이 중요하고, 특히 리소스가 제한된 엣지나 대규모 멀티클러스터 환경이라면 Rust와 kube-rs가 좋은 선택입니다.

직접 다뤄 보기

오퍼레이터는 결국 쿠버네티스의 네트워킹, 스케줄링, 리소스 모델 위에서 동작합니다. 이 기반을 손에 익혀 두면 오퍼레이터가 무엇을 조정하는지가 훨씬 선명해집니다. 클러스터 안에서 파드와 서비스, 네트워크 정책이 어떻게 연결되는지 직접 실험해 보고 싶다면 이 사이트의 쿠버네티스 네트워크 랩에서 시각적으로 다뤄 볼 수 있습니다. 또 오퍼레이터가 배포하는 것은 결국 컨테이너이므로, 컨테이너가 격리와 리소스 제한을 어떻게 구현하는지 궁금하다면 컨테이너 랩에서 그 원리를 살펴볼 수 있습니다.

마치며

Operator는 운영 지식을 코드로 옮긴 컨트롤러이고, 그 심장은 원하는 상태와 실제 상태의 차이를 반복해서 좁히는 멱등한 reconcile 루프입니다. 쿠버네티스에 CRD로 새로운 리소스 종류를 등록하고, 컨트롤러가 그것을 감시하며 세상을 원하는 모습으로 유지합니다.

Rust 생태계의 kube-rs는 이 패턴을 놀랍도록 매끄럽게 지원합니다. CustomResource 파생 매크로로 CRD를 타입으로 정의하고, Api로 리소스를 타입 안전하게 다루고, Controller로 watcher와 작업 큐를 조립하고, Action으로 requeue와 백오프를 표현하며, finalizer 헬퍼로 삭제 시 정리 로직을 안전하게 처리합니다.

Rust를 택하는 이유는 결국 오퍼레이터라는 워크로드의 성격과 맞아떨어지기 때문입니다. 클러스터에 상주하며 계속 도는 프로세스에는 작은 풋프린트와 예측 가능한 성능이 어울리고, 미묘한 상태 조정 로직에는 강한 타입과 메모리 안전성이 어울립니다. 견고한 오퍼레이터를 만들고 싶다면, Rust와 kube-rs는 진지하게 고려할 만한 조합입니다.

참고 자료