Split View: Rust로 쿠버네티스 GPU 오퍼레이터 만들기 — kube-rs로 실제 클러스터를 진단하다
Rust로 쿠버네티스 GPU 오퍼레이터 만들기 — kube-rs로 실제 클러스터를 진단하다
- 들어가며 — 오퍼레이터를 Go가 아니라 Rust로
- 1부 — CRD를 Rust 구조체로
- 2부 — 리컨사일 루프: 노드를 스캔해 상태를 쓴다
- 3부 — 실제로 밟은 함정 두 개
- 4부 — 오퍼레이터가 내린 진단: 내 GPU 함대는 죽어 있었다
- 5부 — 빌드와 실행 환경
- 마치며
- 참고 자료
들어가며 — 오퍼레이터를 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 서브리소스를 쓸 때, 패치 본문에 apiVersion과 kind가 반드시 있어야 합니다. 저는 { "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으로 잡힘). 셋째, omen2만 container로, 나머지는 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)을 실제 클러스터 상태로 파고듭니다.
참고 자료
Building a Kubernetes GPU Operator in Rust — Diagnosing a Real Cluster with kube-rs
- Introduction — An Operator in Rust, Not Go
- Part 1 — The CRD as a Rust Struct
- Part 2 — The Reconcile Loop: Scan Nodes and Write Status
- Part 3 — Two Traps I Actually Stepped On
- Part 4 — The Operator's Diagnosis: My GPU Fleet Was Dead
- Part 5 — Build and Runtime Environment
- Closing
- References
Introduction — An Operator in Rust, Not Go
Kubernetes operators are usually built in Go (controller-runtime). But as kube-rs has matured, you can now build them seriously in Rust too — type safety, a small binary, and above all the peace of mind the compiler gives you. This post is not theory but measurement: against a live 8-node homelab cluster (k8s v1.32.5), I wrote and launched an operator in Rust that investigates GPU status, and I record the real results the operator reported and the real traps I stepped on along the way.
One key design choice: the operator runs outside the cluster (out-of-cluster). Because kube::Client::try_default() reads ~/.kube/config, during development and debugging I attach to the API server with a local binary instead of baking a container image — this is the standard kube-rs development workflow.
Part 1 — The CRD as a Rust Struct
The heart of an operator is its custom resource. kube-rs turns a struct into a CRD with a single #[derive(CustomResource)].
#[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>,
}
This derive generates a method called GpuInventory::crd(), and at startup the operator applies it to the API server to install its own CRD by itself. No separate YAML is needed.
Part 2 — The Reconcile Loop: Scan Nodes and Write Status
The body of the controller is the reconcile function. Here it sweeps through every node, extracts the NVIDIA NFD labels (nvidia.com/gpu.product, gpu.count, gpu.memory) and the Ready status, and records them in the CR's status.
async fn reconcile_inventory(obj: Arc<GpuInventory>, ctx: Arc<Ctx>) -> Result<Action, Error> {
let (status, _cm) = scan_cluster(&ctx.client).await?; // iterate over all nodes
let api: Api<GpuInventory> = Api::all(ctx.client.clone());
let patch = serde_json::json!({
"apiVersion": "homelab.youngju.dev/v1", // ← these two lines were the trap (Part 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)))
}
The operator launches two controllers in a single binary — (1) the loop above that reconciles the GpuInventory CR, and (2) a second loop that updates a ConfigMap named gpu-node-status on every node event. This is the archetype of an operator where "multiple controllers coexist in one process".
Part 3 — Two Traps I Actually Stepped On
This is the real meat of the post. At first it did not work.
Trap 1 — server-side apply's "invalid object type"
On the first run the reconcile scanned the nodes fine (the log shows gpu_nodes=4), but the moment it wrote the status, this error blew up:
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)
The cause: when writing the status subresource via server-side apply (Patch::Apply), the patch body must include apiVersion and kind. I had sent only { "status": status }, and the server threw a 400 asking "what type is this?" (/, Kind=). Adding those two lines (apiVersion, kind) from the Part 2 code fixed it right away. Go's controller-runtime fills these in automatically, but when you apply raw JSON in kube-rs you have to add them yourself.
Trap 2 — Writing Status Calls Itself Again
Once I fixed that, this time the reconcile ran away a dozen-plus times per second.
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-interval runaway
The cause: the reconcile writes observed_at (the current time) fresh into the status every time, and that status write mutates the CR, which produces a watch event, which in turn calls reconcile again — a self-referential loop. Since the timestamp changes every time, it never stops. It is the trap operator beginners hit most often. The textbook solution is to "write the status only when meaningful data has actually changed" (compare against the current status and skip if identical). You will pick up this intuition quickly by running controller patterns by hand in the Kubernetes Playground.
Part 4 — The Operator's Diagnosis: My GPU Fleet Was Dead
Once I fixed both traps, the operator cleanly recorded the cluster's GPU status into the CR. But that result turned out to be an unexpected incident report.
$ kubectl get gpuinventory cluster -o yaml (status excerpt)
observed_at : 2026-07-10T15:54:10Z
total_gpu_nodes : 4
ready_gpu_nodes : 0 ← 0 Ready out of 4 GPU nodes
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
The story reads in three layers. First, all four GPU nodes are NotReady — the cluster's entire GPU capability is offline. Second, the NotReady nodes have had their NFD labels wiped, so they show up as <unlabeled> (only the living nuc1 is caught as an RTX 4070). Third, only omen2 is container while the rest are vm-passthrough, splitting the workload label — this is the real-world instance of that label switch I described in the GPU Operator × KubeVirt post.
In other words, the operator I built discovered my cluster's failure on its own. This is the real value of writing an operator yourself — being told "GPU nodes abnormal" by someone else's dashboard versus watching a 20-line reconcile loop you wrote record that fact into the status are two different depths of understanding. (The cause was a kubelet swap problem, which I will cover in a separate post.)
Part 5 — Build and Runtime Environment
Here is the measured environment, recorded honestly.
Crates kube 0.96 (client+runtime+derive+rustls-tls), k8s-openapi 0.23 (v1_31)
Build cargo 1.97, first release build 16.7s (with dependencies), incremental 2.6s
Run out-of-cluster, connect to the API (192.168.219.111:6443) via ~/.kube/config
Target k8s v1.32.5, 8 nodes (cri-dockerd), GPU Operator v25.3.0
For TLS I chose rustls-tls over openssl — because it builds anywhere without a system openssl dependency (which is especially convenient when deploying the operator across multiple architectures).
Closing
Having written an operator in Rust, I found that kube-rs gives you a mental model strikingly similar to controller-runtime — CRD derivation, a reconcile function, requeue. What differs is that the compiler enforces the types, and that you have to handle low-level things like server-side apply yourself (Trap 1). What you get in exchange is the peace of mind that "if it runs, it is mostly correct," and what you give up is a few conveniences. Above all — when you write an operator yourself, it hands the truth of your infrastructure back to you. That my GPU fleet was dead, too, was first revealed to me by the reconcile loop I wrote. In the next post I will dig into the cause of that death (KubeVirt, GPU passthrough, kubelet) through the actual cluster state.