Skip to content

Split View: KubeVirt GPU 패스스루 VM은 왜 112일간 스케줄되지 못했나 — 실제 클러스터 부검

|

KubeVirt GPU 패스스루 VM은 왜 112일간 스케줄되지 못했나 — 실제 클러스터 부검

들어가며 — 부검을 시작하며

앞 편에서 Rust로 짠 GPU 오퍼레이터가 뜻밖의 진단을 내렸습니다: GPU 노드 4개가 전부 NotReady, 즉 클러스터의 GPU 능력이 통째로 죽어 있다. 이 글은 그 죽음의 원인을 실제 클러스터에서 끝까지 추적한 부검 기록입니다. 대상은 8노드 홈랩(GPU Operator v25.3.0, KubeVirt v1.7.0)이고, 증거는 전부 실측입니다. 이 글의 배경 개념이 필요하면 GPU Operator × KubeVirt 총정리 편을 먼저 읽으면 좋습니다.

1부 — 증상: 112일째 멈춰 있는 VM

먼저 KubeVirt VM들의 상태를 봤습니다. 진짜 클러스터의 VM 위상 분포입니다:

$ kubectl get vm -A
위상                개수   비고
────────────────  ────  ─────────────────────────────
Running              3
Paused               2
Stopped              1
ErrorUnschedulable   2    ← gpu-fedora, rhel9-gpu-vm  (둘 다 GPU VM!)

gpu-fedorarhel9-gpu-vmGPU를 요구하는 VM 둘만 정확히 ErrorUnschedulable입니다. 그리고 얼마나 오래? kubectl get vm의 AGE는 112일. 112일 동안 스케줄되지 못한 채 방치돼 있었습니다. GPU를 안 쓰는 VM들(Running·Paused)은 멀쩡합니다. 범인은 GPU 경로 어딘가입니다.

GPU Operator 오퍼랜드 파드들의 상태도 건강하지 않았습니다:

$ kubectl get pods -n gpu-operator  (상태 히스토그램)
Running       14
Error          6     ← sandbox-device-plugin, sandbox-validator 등
Terminating    4

2부 — GPU는 제대로 등록돼 있었다

흥미로운 반전: GPU 설정 자체는 흠잡을 데가 없었습니다. KubeVirt CR의 permittedHostDevices를 보면 5090과 3090이 PCI ID까지 정확히 등록돼 있습니다:

$ kubectl get kubevirt -n kubevirt -o yaml   (permittedHostDevices 발췌)
pciHostDevices:
  - pciVendorSelector: "10de:2b85"   # NVIDIA GB202 = RTX 5090
    resourceName: nvidia.com/GB202_GEFORCE_RTX_5090
    externalResourceProvider: true
  - pciVendorSelector: "10de:2204"   # NVIDIA GA102 = RTX 3090
    resourceName: nvidia.com/GA102_GEFORCE_RTX_3090
    externalResourceProvider: true

sandboxWorkloads.enabled: true, driver.enabled: false(호스트 드라이버 사용)까지, VM에 GPU를 넘기기 위한 판은 다 깔려 있었습니다. 그런데도 VM은 안 떴습니다. 설정이 옳아도 노드가 없으면 소용없다 — 이게 부검의 방향을 바꿨습니다.

3부 — 진짜 원인: kubelet 한 줄

GPU VM들이 스케줄될 수 있는 유일한 노드(omen2, RTX 5090 탑재)로 내려가 kubelet 로그를 봤습니다. 범인은 화려한 GPU 문제가 아니라 이 한 줄이었습니다:

$ journalctl -u kubelet -n 5   (omen2)
E0710 15:39:51 run.go:72] "command failed" err="failed to run Kubelet:
    running with swap on is not supported, please disable swap
    or set --fail-swap-on flag to false"
systemd[1]: kubelet.service: Main process exited, code=exited, status=1/FAILURE

systemctl is-active kubeletactivating(즉 crash-loop). 누군가 이 노드에 32GB swapfile을 켰고, 쿠버네티스 kubelet은 기본적으로 swap이 켜져 있으면 뜨기를 거부합니다. kubelet이 죽으니 노드가 NotReady가 되고, NFD가 GPU 라벨을 지우고, device-plugin이 nvidia.com/gpu를 광고하지 못하고 — 그 위에 얹힌 모든 게 무너진 겁니다.

인과 사슬을 한 줄로 그리면 이렇습니다:

swap on  →  kubelet 거부(crash-loop)  →  노드 NotReady
         →  NFD 라벨 소멸 + device-plugin 중단
         →  GPU VM 스케줄 불가(ErrorUnschedulable, 112일)

4부 — 패스스루의 물리 법칙: 왜 "다른 노드로" 못 가나

일반 파드라면 노드 하나가 죽어도 스케줄러가 다른 노드로 옮깁니다. GPU 패스스루 VM은 왜 못 옮길까요? 물리 장치에 묶여 있기 때문입니다. gpu-fedoranvidia.com/GB202_GEFORCE_RTX_5090이라는 리소스를 요구하는데, 그 물리 5090은 오직 omen2에만 꽂혀 있습니다. 그 노드가 죽으면 VM이 갈 곳이 없습니다.

이건 버그가 아니라 설계입니다 — 제가 GPU Operator × KubeVirt 편에서 "GPU 패스스루된 VM은 라이브 마이그레이션이 안 된다, 이건 버전과 무관한 물리 법칙"이라고 썼던 그 문장의 실물입니다. 컨테이너 GPU(MIG로 쪼개 여러 파드가 공유)와 달리, VM 패스스루는 GPU 한 장 = 노드 한 대에 강하게 결합됩니다. 밀도를 포기하는 대신 강한 격리를 얻는 트레이드오프의 그림자입니다.

5부 — 복구: 라벨 먼저, 그 다음 swap

복구에는 함정이 하나 더 있었습니다. omen2의 워크로드 라벨은 nvidia.com/gpu.workload.config=vm-passthrough였습니다. 이 상태로 그냥 kubelet을 살리면 GPU Operator가 vfio-manager를 띄워 5090을 vfio-pci 드라이버로 재바인딩합니다 — 그러면 그 노드에서 돌던 모든 CUDA/torch 작업이 즉시 죽습니다(이 GPU는 작은 모델 실험 편에서 쓰던 바로 그 카드입니다).

그래서 복구 순서가 중요합니다:

① (kubelet 살리기 전에) 라벨을 container로 바꿔 vfio 재바인딩을 막는다
     kubectl label node omen2 nvidia.com/gpu.workload.config=container --overwrite
② swap을 끄고 영구화
     sudo swapoff -a
     sudo sed -i '/swap/s/^/#/' /etc/fstab
③ kubelet 재시작 → 노드 Ready, 5090이 nvidia.com/gpu로 파드에 노출
     sudo systemctl restart kubelet

①을 먼저 하는 이유가 핵심입니다 — driver.enabled: false라 GPU Operator는 호스트 드라이버를 그대로 쓰므로, container 라벨이면 호스트의 nvidia 드라이버를 유지한 채 GPU를 파드에 노출합니다. 즉 torch도 살고 k8s도 GPU를 봅니다. (①은 이미 적용해 뒀고, ②③은 sudo가 필요해 노드 소유자의 손이 필요합니다.)

마치며 — 부검의 교훈

가장 큰 교훈은 진부하지만 매번 새롭습니다: 화려한 레이어의 장애가 대개 지루한 레이어에서 온다. GPU 패스스루·vfio·permittedHostDevices·PCI ID까지 완벽했지만, 무너뜨린 건 swap on 한 줄이었습니다. 두 번째 교훈은 패스스루의 물리학 — VM에 GPU를 통째로 주면 그 VM은 그 노드에 못 박힙니다. 밀도가 필요하면 컨테이너+MIG, 격리가 필요하면 VM 패스스루, 그 선택의 대가는 "노드가 죽으면 같이 죽는다"입니다. 그리고 이 모든 진단의 출발점이 내가 Rust로 짠 20줄짜리 오퍼레이터였다는 것 — 인프라는 직접 만져 본 만큼만 보입니다.

참고 자료

Why KubeVirt GPU Passthrough VMs Could Not Be Scheduled for 112 Days — A Real Cluster Postmortem

Introduction — Beginning the Postmortem

In the previous post, the GPU operator I wrote in Rust delivered an unexpected diagnosis: all 4 GPU nodes are NotReady, meaning the entire GPU capability of the cluster is dead. This post is the postmortem record of tracing the cause of that death all the way to the end on a real cluster. The subject is an 8-node homelab (GPU Operator v25.3.0, KubeVirt v1.7.0), and every piece of evidence is measured. If you need the background concepts for this post, it is worth reading the GPU Operator × KubeVirt overview first.

Part 1 — Symptom: A VM Stuck for 112 Days

First, I looked at the state of the KubeVirt VMs. Here is the phase distribution of the VMs on the real cluster:

$ kubectl get vm -A
phase             count note
────────────────  ────  ─────────────────────────────
Running              3
Paused               2
Stopped              1
ErrorUnschedulable   2    ← gpu-fedora, rhel9-gpu-vm  (both are GPU VMs!)

gpu-fedora and rhel9-gpu-vmexactly the two VMs that require a GPU are ErrorUnschedulable. And for how long? The AGE from kubectl get vm is 112 days. They had been left unscheduled for 112 days. The VMs that do not use a GPU (Running·Paused) are fine. The culprit is somewhere in the GPU path.

The state of the GPU Operator operand pods was not healthy either:

$ kubectl get pods -n gpu-operator  (status histogram)
Running       14
Error          6     ← sandbox-device-plugin, sandbox-validator etc.
Terminating    4

Part 2 — The GPU Was Registered Correctly

An interesting twist: the GPU configuration itself was flawless. Looking at the permittedHostDevices of the KubeVirt CR, the 5090 and the 3090 are registered accurately, down to the PCI ID:

$ kubectl get kubevirt -n kubevirt -o yaml   (permittedHostDevices excerpt)
pciHostDevices:
  - pciVendorSelector: "10de:2b85"   # NVIDIA GB202 = RTX 5090
    resourceName: nvidia.com/GB202_GEFORCE_RTX_5090
    externalResourceProvider: true
  - pciVendorSelector: "10de:2204"   # NVIDIA GA102 = RTX 3090
    resourceName: nvidia.com/GA102_GEFORCE_RTX_3090
    externalResourceProvider: true

Right down to sandboxWorkloads.enabled: true and driver.enabled: false (use the host driver), the stage for handing a GPU to a VM was fully set. And yet the VMs did not come up. Even if the configuration is correct, it is useless without a node — this is what changed the direction of the postmortem.

Part 3 — The Real Cause: A Single Line of kubelet

I went down to the only node where the GPU VMs could be scheduled (omen2, equipped with an RTX 5090) and looked at the kubelet log. The culprit was not a fancy GPU problem but this single line:

$ journalctl -u kubelet -n 5   (omen2)
E0710 15:39:51 run.go:72] "command failed" err="failed to run Kubelet:
    running with swap on is not supported, please disable swap
    or set --fail-swap-on flag to false"
systemd[1]: kubelet.service: Main process exited, code=exited, status=1/FAILURE

systemctl is-active kubeletactivating (that is, a crash-loop). Someone had turned on a 32GB swapfile on this node, and by default the Kubernetes kubelet refuses to start when swap is on. Once kubelet dies, the node becomes NotReady, NFD erases the GPU labels, the device-plugin cannot advertise nvidia.com/gpu — and everything stacked on top of it collapsed.

Drawn as a single causal chain, it looks like this:

swap on  →  kubelet refused(crash-loop)  →  node NotReady
         →  NFD labels gone + device-plugin stopped
         →  GPU VM cannot be scheduled(ErrorUnschedulable, 112 days)

Part 4 — The Physics of Passthrough: Why It Cannot Go "to Another Node"

With an ordinary pod, even if one node dies the scheduler moves it to another node. Why can a GPU passthrough VM not be moved? Because it is bound to a physical device. gpu-fedora requires the resource nvidia.com/GB202_GEFORCE_RTX_5090, and that physical 5090 is plugged in only on omen2. If that node dies, the VM has nowhere to go.

This is not a bug but a design — it is the physical embodiment of the sentence I wrote in the GPU Operator × KubeVirt post: "a GPU-passthrough VM cannot be live-migrated; this is a physical law independent of the version." Unlike container GPUs (split with MIG and shared by multiple pods), VM passthrough is tightly coupled to one GPU = one node. It is the shadow of the trade-off that gives up density in exchange for strong isolation.

Part 5 — Recovery: Label First, Then swap

There was one more trap in the recovery. The workload label on omen2 was nvidia.com/gpu.workload.config=vm-passthrough. If you simply revive kubelet in this state, the GPU Operator brings up vfio-manager and rebinds the 5090 to the vfio-pci driver — and then every CUDA/torch job that was running on that node dies instantly (this GPU is the very card I used in the small-models experiment post).

That is why the recovery order matters:

① (before reviving kubelet) switch the label to container to block vfio rebinding
     kubectl label node omen2 nvidia.com/gpu.workload.config=container --overwrite
② turn off swap and make it permanent
     sudo swapoff -a
     sudo sed -i '/swap/s/^/#/' /etc/fstab
③ restart kubelet → node Ready, 5090 exposed to pods as nvidia.com/gpu
     sudo systemctl restart kubelet

The reason for doing ① first is the crux — because driver.enabled: false means the GPU Operator uses the host driver as-is, so with the container label it exposes the GPU to pods while keeping the host's nvidia driver. In other words, torch survives and k8s also sees the GPU. (① has already been applied, while ②③ need sudo and therefore the hand of the node owner.)

Closing — Lessons from the Postmortem

The biggest lesson is clichéd but new every time: a failure in the fancy layer usually comes from the boring layer. GPU passthrough, vfio, permittedHostDevices, PCI IDs were all perfect, but what brought it down was the single line swap on. The second lesson is the physics of passthrough — give a VM a whole GPU and that VM is nailed to that node. If you need density, containers + MIG; if you need isolation, VM passthrough; the price of that choice is "when the node dies, it dies with it." And the starting point of this entire diagnosis was the 20-line operator I wrote in Rust — you see infrastructure only as far as you have touched it yourself.

References