Skip to content
Published on

Rust로 쿠버네티스 GPU 오퍼레이터 만들기 — kube-rs로 실제 클러스터를 진단하다

공유하기
Authors

들어가며 — 오퍼레이터를 Go가 아니라 Rust로

쿠버네티스 오퍼레이터는 보통 Go(controller-runtime)로 만듭니다. 하지만 kube-rs가 성숙하면서 Rust로도 진지하게 만들 수 있게 됐습니다 — 타입 안전성, 작은 바이너리, 그리고 무엇보다 컴파일러가 잡아 주는 안심감. 이 글은 이론이 아니라 실측입니다: 실제로 돌아가는 8노드 홈랩 클러스터(k8s v1.32.5)를 상대로, GPU 상태를 조사하는 오퍼레이터를 Rust로 짜서 띄우고, 오퍼레이터가 뱉은 진짜 결과와 그 과정에서 밟은 진짜 함정들을 기록합니다.

핵심 설계 하나: 오퍼레이터를 클러스터 밖에서(out-of-cluster) 실행합니다. kube::Client::try_default()~/.kube/config를 읽으므로, 개발·디버깅 중에는 컨테이너 이미지를 굽지 않고 로컬 바이너리로 API 서버에 붙습니다 — kube-rs 개발의 표준 워크플로입니다.

1부 — CRD를 Rust 구조체로

오퍼레이터의 심장은 커스텀 리소스입니다. kube-rs는 #[derive(CustomResource)] 하나로 구조체를 CRD로 바꿔 줍니다.

#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
    group = "homelab.youngju.dev",
    version = "v1",
    kind = "GpuInventory",
    plural = "gpuinventories",
    shortname = "gpuinv",
    status = "GpuInventoryStatus"
)]
pub struct GpuInventorySpec {
    #[serde(default)]
    pub note: String,
}

#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)]
pub struct GpuInventoryStatus {
    pub observed_at: String,
    pub total_gpu_nodes: i32,
    pub ready_gpu_nodes: i32,
    pub total_gpus: i32,
    pub nodes: Vec<GpuNodeEntry>,
}

이 derive가 GpuInventory::crd()라는 메서드를 만들어 주고, 오퍼레이터는 시작할 때 그걸 API 서버에 apply해서 자기 자신의 CRD를 스스로 설치합니다. 별도 YAML이 필요 없습니다.

2부 — 리컨사일 루프: 노드를 스캔해 상태를 쓴다

컨트롤러의 본체는 리컨사일 함수입니다. 여기서는 모든 노드를 훑어 NVIDIA NFD 라벨(nvidia.com/gpu.product, gpu.count, gpu.memory)과 Ready 상태를 뽑아 CR의 status에 기록합니다.

async fn reconcile_inventory(obj: Arc<GpuInventory>, ctx: Arc<Ctx>) -> Result<Action, Error> {
    let (status, _cm) = scan_cluster(&ctx.client).await?;   // 모든 노드 순회
    let api: Api<GpuInventory> = Api::all(ctx.client.clone());
    let patch = serde_json::json!({
        "apiVersion": "homelab.youngju.dev/v1",   // ← 이 두 줄이 함정이었다 (3부)
        "kind": "GpuInventory",
        "status": status
    });
    api.patch_status(&obj.name_any(), &PatchParams::apply(FIELD_MANAGER).force(),
                     &Patch::Apply(&patch)).await?;
    Ok(Action::requeue(Duration::from_secs(30)))
}

오퍼레이터는 두 개의 컨트롤러를 한 바이너리로 띄웁니다 — (1) GpuInventory CR을 리컨사일하는 위 루프, (2) 노드 이벤트마다 gpu-node-status라는 ConfigMap을 갱신하는 두 번째 루프. "여러 개의 컨트롤러가 한 프로세스에서 공존"하는 오퍼레이터의 전형입니다.

3부 — 실제로 밟은 함정 두 개

여기가 이 글의 진짜 알맹이입니다. 처음엔 안 돌아갔습니다.

함정 1 — server-side apply의 "invalid object type"

첫 실행에서 리컨사일은 노드를 잘 스캔했는데(로그에 gpu_nodes=4), 상태를 쓰는 순간 이 에러가 터졌습니다:

WARN reconciled GpuInventory cr=cluster gpu_nodes=4 ready=0 gpus=1
WARN inventory reconcile failed: kube error: ApiError:
     invalid object type: /, Kind=: BadRequest (code: 400)

원인: 서버사이드 apply(Patch::Apply)로 status 서브리소스를 쓸 때, 패치 본문에 apiVersionkind가 반드시 있어야 합니다. 저는 { "status": status }만 보냈고, 서버는 "이게 무슨 타입이냐"(/, Kind=)며 400을 던진 겁니다. 2부 코드의 그 두 줄(apiVersion, kind)을 넣자 바로 해결됐습니다. Go의 controller-runtime은 이걸 자동으로 채워 주지만, kube-rs에서 raw JSON으로 apply할 땐 직접 넣어야 합니다.

함정 2 — 상태 기록이 자기 자신을 다시 부른다

고치고 나니 이번엔 리컨사일이 초당 십수 번씩 폭주했습니다.

INFO reconciled GpuInventory cr=cluster ... (15:54:06.484)
INFO reconciled GpuInventory cr=cluster ... (15:54:06.525)
INFO reconciled GpuInventory cr=cluster ... (15:54:06.628)
INFO reconciled GpuInventory cr=cluster ... (15:54:06.728)   ← 100ms 간격 폭주

원인: 리컨사일이 status에 observed_at(현재 시각)을 매번 새로 쓰는데, 그 status 쓰기가 CR을 변경시켜 watch 이벤트를 낳고, 그게 다시 리컨사일을 부르는 자기 참조 루프입니다. 타임스탬프가 매번 바뀌니 영원히 멈추지 않죠. 오퍼레이터 초심자가 가장 흔히 밟는 함정입니다. 교과서적 해법은 "의미 있는 데이터가 실제로 바뀌었을 때만 status를 쓴다"(현재 status와 비교 후 동일하면 skip)입니다. 이 감각은 쿠버네티스 놀이터에서 컨트롤러 패턴을 손으로 돌려 보면 빨리 붙습니다.

4부 — 오퍼레이터가 내린 진단: 내 GPU 함대는 죽어 있었다

두 함정을 고치자 오퍼레이터가 클러스터의 GPU 상태를 CR에 깔끔히 기록했습니다. 그런데 그 결과가 뜻밖의 사고 보고서였습니다.

$ kubectl get gpuinventory cluster -o yaml   (상태 발췌)
observed_at     : 2026-07-10T15:54:10Z
total_gpu_nodes : 4
ready_gpu_nodes : 0        ← GPU 노드 4개 중 Ready 0개
total_gpus      : 1
  nuc1   ready=false  NVIDIA-GeForce-RTX-4070-Laptop-GPU-SHARED  x1 mem=8188  vm-passthrough
  nuc2   ready=false  <unlabeled>                                x0           vm-passthrough
  omen   ready=false  <unlabeled>                                x0           vm-passthrough
  omen2  ready=false  <unlabeled>                                x0           container

읽히는 이야기가 세 겹입니다. 첫째, GPU 노드 4개가 전부 NotReady — 클러스터의 GPU 능력이 통째로 offline입니다. 둘째, NotReady 노드들은 NFD 라벨이 지워져 <unlabeled>로 나옵니다(살아 있는 nuc1만 RTX 4070으로 잡힘). 셋째, omen2container로, 나머지는 vm-passthrough로 워크로드 라벨이 갈립니다 — 이건 제가 GPU Operator × KubeVirt 편에서 설명한 그 라벨 스위치의 실물입니다.

즉, 내가 만든 오퍼레이터가 내 클러스터의 장애를 스스로 발견했습니다. 이게 오퍼레이터를 직접 짜 보는 진짜 가치입니다 — 남이 만든 대시보드가 "GPU 노드 이상"이라고 알려 주는 것과, 내가 짠 20줄짜리 리컨사일 루프가 그 사실을 status에 적어 내려가는 걸 보는 것은 이해의 깊이가 다릅니다. (원인은 별도 편에서 다룰 kubelet의 swap 문제였습니다.)

5부 — 빌드와 실행 환경

정직하게 남기는 실측 환경입니다.

크레이트      kube 0.96 (client+runtime+derive+rustls-tls), k8s-openapi 0.23 (v1_31)
빌드          cargo 1.97, 릴리스 빌드 최초 16.7초 (의존성 포함), 증분 2.6초
실행          out-of-cluster, ~/.kube/config로 API(192.168.219.111:6443)에 접속
대상          k8s v1.32.5, 8노드 (cri-dockerd), GPU Operator v25.3.0

TLS는 openssl 대신 rustls-tls를 골랐습니다 — 시스템 openssl 의존성 없이 어디서나 빌드되기 때문입니다(오퍼레이터를 여러 아키텍처로 배포할 때 특히 편합니다).

마치며

Rust로 오퍼레이터를 짜 보니, kube-rs는 놀랄 만큼 controller-runtime과 닮은 멘탈 모델을 줍니다 — CRD 파생, 리컨사일 함수, requeue. 다른 건 컴파일러가 타입을 강제한다는 점, 그리고 서버사이드 apply 같은 저수준을 직접 만져야 한다는 점입니다(함정 1). 그 대가로 얻는 건 "돌아가면 대체로 옳다"는 안심감이고, 잃는 건 몇 가지 편의입니다. 무엇보다 — 오퍼레이터를 직접 짜면, 그게 내 인프라의 진실을 내게 되돌려 줍니다. 내 GPU 함대가 죽어 있다는 것도, 내가 짠 리컨사일 루프가 처음 알려 줬으니까요. 다음 편에서는 그 죽음의 원인(KubeVirt·GPU passthrough·kubelet)을 실제 클러스터 상태로 파고듭니다.

참고 자료