soobook
KUBERNETES

KubeVirt

VM을 Kubernetes object로 다루는 KubeVirt

KubeVirt는 VM을 Kubernetes의 Pod처럼 다룬다. VM 한 대가 Pod 안에서 QEMU/KVM 프로세스로 돌고, 스케줄과 복구와 관측은 Kubernetes가 맡는다.

Intro

리눅스에서 VM 한 대를 어떻게 만드는지는 KVM과 QEMU가 답한다.

그런데 GPU 클러스터에서는 VM이 한 대로 끝나지 않는다. 수십, 수백 대를 만들고, 노드에 배치하고, 죽으면 되살리고, 상태를 지켜봐야 한다.

Kubernetes는 컨테이너를 이미 그렇게 다루고 있다. KubeVirt는 같은 Kubernetes에게 VM도 똑같이 다루게 한다.

VM을 Pod 안에 넣고, VirtualMachine이라는 Kubernetes object로 선언한다. 그러면 컨테이너를 굴리던 control plane이 VM도 굴린다.

Why VM on Kubernetes

컨테이너부터 보자. 컨테이너는 host kernel을 공유한다. namespace와 cgroup으로 프로세스, 파일시스템, CPU와 메모리는 갈라놓지만 커널은 하나다.

GPU 서버를 통째로 운영하려는 워크로드에는 이 공유가 벽이 된다. 특정 GPU 드라이버 버전 고정, 커널 모듈 교체, 커스텀 커널 같은 요구는 전부 커널을 건드리는 일이라, 공유 커널 위에서는 내줄 수 없다.

그 제어권을 주려면 격리 단위를 커널까지 내려야 하고, 그 대안은 VM이다.

VM은 guest kernel을 따로 가지므로, 그 안에서는 OS도 드라이버도 사용자가 직접 정한다.

이 격리를 CPU와 메모리 수준에서 어떻게 만드는지는 KVM과 QEMU 글에서 다뤘다.

문제는 운영이다. VM을 Kubernetes 밖에서 관리하면 스케줄러, 복구 로직, 관측 파이프라인을 VM용으로 또 세워야 한다.

컨테이너용 하나, VM용 하나.

KubeVirt는 이 이중화를 없앤다. VM을 Kubernetes object로 올려, 컨테이너와 같은 control plane 아래 둔다.

VM Inside a Pod

KubeVirt의 핵심 장치는 단순하다. VM을 Pod 안에서 돌린다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "At a glance: a VM lives inside a Pod",
  "width": 960,
  "height": 645,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "container-side",
      "label": "Container path\\n일반 Pod",
      "x": 36,
      "y": 146,
      "width": 430,
      "height": 443
    },
    {
      "id": "vm-side",
      "label": "VM path\\nvirt-launcher Pod",
      "x": 494,
      "y": 146,
      "width": 430,
      "height": 443
    }
  ],
  "nodes": [
    {
      "id": "k8s",
      "kind": "switch",
      "label": "Kubernetes",
      "caption": "둘 다 Pod으로 스케줄",
      "x": 480,
      "y": 86,
      "width": 320,
      "height": 60
    },
    {
      "id": "c-pod",
      "kind": "note",
      "label": "일반 Pod",
      "x": 251,
      "y": 284,
      "width": 240,
      "height": 56
    },
    {
      "id": "container",
      "kind": "switch",
      "label": "Container",
      "caption": "cgroup, namespace",
      "x": 251,
      "y": 398,
      "width": 240,
      "height": 66
    },
    {
      "id": "host-kernel",
      "kind": "memory",
      "label": "host kernel",
      "caption": "모든 Pod이 공유",
      "x": 251,
      "y": 512,
      "width": 240,
      "height": 66
    },
    {
      "id": "v-pod",
      "kind": "note",
      "label": "virt-launcher Pod",
      "x": 709,
      "y": 284,
      "width": 240,
      "height": 56
    },
    {
      "id": "qemu",
      "kind": "switch",
      "label": "QEMU + KVM",
      "caption": "VM 프로세스",
      "x": 709,
      "y": 398,
      "width": 240,
      "height": 66
    },
    {
      "id": "guest-kernel",
      "kind": "gpu",
      "label": "guest kernel",
      "caption": "VM마다 독립",
      "x": 709,
      "y": 512,
      "width": 240,
      "height": 66
    }
  ],
  "links": [
    {
      "id": "k-c",
      "points": [[420, 116], [251, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "k-v",
      "points": [[540, 116], [709, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "c1",
      "points": [[251, 312], [251, 365]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "c2",
      "points": [[251, 431], [251, 479]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "v1",
      "points": [[709, 312], [709, 365]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "v2",
      "points": [[709, 431], [709, 479]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    }
  ],
  "labels": [
    {
      "text": "커널 공유",
      "x": 251,
      "y": 567
    },
    {
      "text": "커널 분리, 드라이버 제어",
      "x": 709,
      "y": 567
    }
  ]
}

일반 Pod은 컨테이너를 담는다. 컨테이너는 host kernel을 공유하며 실행된다.

KubeVirt가 만드는 Pod은 virt-launcher라 부른다.

이 Pod 안에는 컨테이너 대신 QEMU 프로세스가 들어 있고, 그 QEMU가 KVM 위에서 VM을 띄운다. VM은 자기 guest kernel을 갖는다.

Kubernetes 입장에서는 둘 다 그냥 Pod이다.

같은 스케줄러가 노드에 배치하고, 같은 방식으로 상태를 지켜본다.

다만 한쪽 Pod 안에는 컨테이너가, 다른 쪽 Pod 안에는 VM 한 대가 들어 있을 뿐이다.

Pod 에서 VM 을 실행한다는게 어색하게 들릴 수 있는데, Pod 도 결국 컨테이너고, cgroup+namespace 로 구현된 것이다.

그 안에서 Python 을 굴리건 VM 을 굴리건 상관이 없다.

그 Pod이 어디에서 도는지 층으로 세워 보면 이렇다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "Pod이 도는 층",
  "width": 760,
  "height": 548,
  "mobileWidth": 720,
  "regions": [
    {
      "id": "stack",
      "label": "물리 머신부터 VM까지",
      "x": 40,
      "y": 50,
      "width": 680,
      "height": 448
    }
  ],
  "nodes": [
    {
      "id": "vm",
      "kind": "gpu",
      "label": "VM = virt-launcher Pod",
      "caption": "guest OS, 독립 커널",
      "x": 380,
      "y": 139,
      "width": 560,
      "height": 62
    },
    {
      "id": "kubevirt",
      "kind": "switch",
      "label": "KubeVirt",
      "caption": "CRD + 컴포넌트, VM을 관리하는 add-on",
      "x": 380,
      "y": 215,
      "width": 560,
      "height": 62
    },
    {
      "id": "k8s",
      "kind": "switch",
      "label": "Kubernetes (k3s)",
      "caption": "클러스터 런타임",
      "x": 380,
      "y": 291,
      "width": 560,
      "height": 62
    },
    {
      "id": "os",
      "kind": "host",
      "label": "Host OS (Linux)",
      "caption": "커널 + KVM 모듈",
      "x": 380,
      "y": 367,
      "width": 560,
      "height": 62
    },
    {
      "id": "hw",
      "kind": "memory",
      "label": "Hardware",
      "caption": "CPU, GPU, NIC",
      "x": 380,
      "y": 443,
      "width": 560,
      "height": 62
    }
  ],
  "links": []
}

맨 아래는 물리 머신이다. 그 위에 Host OS(리눅스)가 있고, 커널 안에 KVM 모듈이 들어 있다.

그 위에서 Kubernetes가 도는데, GPU 노드에서는 무거운 배포판 대신 k3s 같은 가벼운 배포판을 얹기도 한다.

KubeVirt는 이 Kubernetes를 고쳐 만든 것이 아니라, 위에 얹는 add-on이다.

설치하면 CRD 몇 개와 컴포넌트 몇 개가 붙고, 그때부터 클러스터가 VM을 다룰 수 있게 된다.

맨 위의 VM은 그 KubeVirt가 띄운 virt-launcher Pod 안에서 돈다.

Pod은 k3s가, VM은 KubeVirt가, 커널 가상화는 KVM이 맡는 식으로 층마다 주인이 다르다.

QEMU, KVM, and the Host Kernel

KVM은 CRD를 배포할 때 켜지는 게 아니다. kvm 모듈은 host가 부팅할 때 이미 커널에 올라와 있다.

VM 하나가 시작될 때, 그 VM의 virt-launcher Pod 안 QEMU가 /dev/kvm을 열고 KVM_RUN을 호출하는 순간부터 그 VM에 대해 하드웨어 가속이 돈다.

Pod은 결국 cgroup과 namespace일 뿐이고, KubeVirt는 이 Pod에 host의 /dev/kvm을 device로 넣어 준다.

QEMU는 그 창구로 host 커널의 KVM에 손을 뻗는 것이다.

만약 노드에 VT-x가 없거나 kvm 모듈이 없으면, QEMU는 순수 emulation으로 떨어진다.

KubeVirt에도 이 software emulation mode가 있다. 돌긴 하지만 느리다.

그럼 k3s도 guest 안에서 도는 걸까? 아니다. /dev/kvm은 host 커널의 장치다.

k3s도, kubelet도, virt-launcher의 QEMU도 전부 같은 host 위 user space에서 돈다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "실제로 VM이 도는 모습",
  "width": 900,
  "height": 500,
  "mobileWidth": 880,
  "regions": [
    {
      "id": "host",
      "label": "Machine",
      "x": 40,
      "y": 50,
      "width": 820,
      "height": 400
    }
  ],
  "nodes": [
    {
      "id": "k3s",
      "kind": "host",
      "label": "k3s / kubelet",
      "caption": "host user space",
      "x": 250,
      "y": 150,
      "width": 340,
      "height": 76
    },
    {
      "id": "qemu",
      "kind": "switch",
      "label": "virt-launcher Pod — QEMU",
      "caption": "host user space, emulation + 장치 모델",
      "x": 650,
      "y": 150,
      "width": 340,
      "height": 76
    },
    {
      "id": "kvm",
      "kind": "memory",
      "label": "kvm 모듈, /dev/kvm",
      "caption": "host kernel — 유일한 가상화 지점",
      "x": 450,
      "y": 288,
      "width": 760,
      "height": 64
    },
    {
      "id": "cpu",
      "kind": "gpu",
      "label": "CPU (VT-x / AMD-V)",
      "caption": "guest vCPU를 non-root로 직접 실행",
      "x": 450,
      "y": 392,
      "width": 760,
      "height": 64
    }
  ],
  "links": [
    {
      "id": "start",
      "points": [[420, 150], [480, 150]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "qemu-kvm",
      "points": [[650, 188], [650, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "kvm-cpu",
      "points": [[450, 320], [450, 360]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    }
  ],
  "labels": [
    {
      "text": "KVM_RUN (ioctl)",
      "x": 738,
      "y": 222
    }
  ]
}

KubeVirt Components

KubeVirt를 설치하면 클러스터에 무엇이 생기는지 보자.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "설치하면 클러스터에 붙는 것",
  "width": 960,
  "height": 510,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "cp",
      "label": "Control plane",
      "x": 36,
      "y": 150,
      "width": 420,
      "height": 316
    },
    {
      "id": "node",
      "label": "Worker node",
      "x": 504,
      "y": 150,
      "width": 420,
      "height": 316
    }
  ],
  "nodes": [
    {
      "id": "api",
      "kind": "switch",
      "label": "Kubernetes API server",
      "caption": "확장은 등록으로 안다 (CRD, APIService, webhook)",
      "x": 480,
      "y": 72,
      "width": 320,
      "height": 56
    },
    {
      "id": "virt-api",
      "kind": "host",
      "label": "virt-api",
      "caption": "apiserver가 호출, subresource proxy, webhook",
      "x": 246,
      "y": 270,
      "width": 290,
      "height": 76
    },
    {
      "id": "virt-controller",
      "kind": "switch",
      "label": "virt-controller",
      "caption": "apiserver를 watch, launcher Pod 생성",
      "x": 246,
      "y": 390,
      "width": 290,
      "height": 76
    },
    {
      "id": "virt-handler",
      "kind": "host",
      "label": "virt-handler",
      "caption": "apiserver를 watch, libvirt로 기동",
      "x": 714,
      "y": 270,
      "width": 290,
      "height": 76
    },
    {
      "id": "virt-launcher",
      "kind": "gpu",
      "label": "virt-launcher Pod",
      "caption": "VM 1대 = Pod 1개, QEMU + KVM",
      "x": 714,
      "y": 390,
      "width": 290,
      "height": 76
    }
  ],
  "links": [
    {
      "id": "api-va",
      "points": [[360, 100], [246, 232]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "handler-api",
      "points": [[714, 232], [600, 100]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "ctrl-launcher",
      "points": [[391, 390], [569, 390]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "handler-launcher",
      "points": [[714, 308], [714, 352]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    }
  ],
  "labels": [
    {
      "text": "클러스터에 한 벌",
      "x": 246,
      "y": 448
    },
    {
      "text": "노드마다 하나씩",
      "x": 714,
      "y": 448
    }
  ]
}

컴포넌트는 사는 곳으로 나뉜다.

virt-apivirt-controller는 클러스터에 한 벌만 도는 control plane 쪽이다. virt-api는 VM을 켜고 끄거나 콘솔에 붙는 API 확장을 처리하고, virt-controllerVMI를 감시하다가 그것을 담을 virt-launcher Pod을 만든다.

virt-handler는 노드마다 하나씩 도는 DaemonSet이다. 컨테이너로 치면 kubelet과 같은 자리로, 자기 노드에 배정된 VM을 libvirt를 통해 실제로 띄우고 관리한다.

virt-launcher는 VM 한 대당 하나씩 뜨는 Pod이다. 그 안에서 QEMU와 KVM이 VM을 실행한다.

정리하면 명령을 받고 결정을 내리는 쪽(virt-api, virt-controller)은 클러스터에 한 벌, 실제로 VM을 굴리는 쪽(virt-handler, virt-launcher)은 노드마다 흩어져 있다.

여기서 방향을 짚어 두면 헷갈림이 준다. kube-apiserver가 능동적으로 요청을 거는 KubeVirt 컴포넌트는 virt-api 하나뿐이다. virt-controllervirt-handler는 반대로 apiserver를 watch하는 client다. apiserver가 이들을 부르는 게 아니라, 이들이 apiserver에 붙어 변화를 받아간다.

그런데 apiserver는 KubeVirt를 원래부터 아는 게 아니다. k3s는 표준 Kubernetes control plane을 담고 있을 뿐, KubeVirt를 내장하지 않는다. 설치할 때 클러스터에 등록이 올라올 뿐이다. 새 타입을 정의하는 CustomResourceDefinition, subresource 요청을 virt-api로 넘기라는 APIService(aggregation layer), 그리고 admission webhook 설정이다.

kube-apiserver는 이 등록을 읽고 그대로 따른다. subresource 요청이 오면 virt-api로 proxy하고, VM object가 들어오면 virt-api의 webhook을 부른다. 즉 apiserver가 아는 건 일반적인 확장 지점(CRD, aggregation, webhook)뿐이고 ‘KubeVirt’라는 이름은 모른다. virt-api를 가리키는 건 코드가 아니라 설치가 남긴 데이터다. KubeVirt가 Kubernetes를 고치지 않고 얹는 add-on이라는 말의 실체가 이것이다.

Declaration to Execution

사용자가 virt-launcher Pod을 직접 만들지는 않는다. VirtualMachine object를 선언한다. “이런 스펙의 VM이 켜져 있어야 한다”는 원하는 상태(desired state)다.

그다음은 Kubernetes의 reconcile 패턴을 그대로 탄다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "선언에서 실행까지",
  "width": 960,
  "height": 420,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "flow",
      "label": "VirtualMachine → VMI → Pod",
      "x": 40,
      "y": 48,
      "width": 880,
      "height": 324
    }
  ],
  "nodes": [
    {
      "id": "vm",
      "kind": "host",
      "label": "VirtualMachine",
      "caption": "원하는 상태 선언 (CR)",
      "x": 190,
      "y": 178,
      "width": 224,
      "height": 84
    },
    {
      "id": "vmi",
      "kind": "note",
      "label": "VMI",
      "caption": "실행 중 인스턴스",
      "x": 480,
      "y": 178,
      "width": 176,
      "height": 84
    },
    {
      "id": "launcher",
      "kind": "switch",
      "label": "virt-launcher Pod",
      "caption": "QEMU + KVM",
      "x": 770,
      "y": 178,
      "width": 224,
      "height": 84
    },
    {
      "id": "controller",
      "kind": "switch",
      "label": "virt-controller",
      "caption": "VMI, Pod 생성",
      "x": 190,
      "y": 296,
      "width": 224,
      "height": 64
    },
    {
      "id": "handler",
      "kind": "switch",
      "label": "virt-handler",
      "caption": "노드에서 QEMU 기동",
      "x": 770,
      "y": 296,
      "width": 224,
      "height": 64
    }
  ],
  "links": [
    {
      "id": "vm-vmi",
      "points": [[302, 178], [390, 178]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "vmi-launcher",
      "points": [[568, 178], [658, 178]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "ctrl-vmi",
      "points": [[250, 264], [432, 220]],
      "tone": "muted",
      "dashed": true,
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "handler-launcher",
      "points": [[770, 264], [770, 220]],
      "tone": "muted",
      "dashed": true,
      "flow": { "speed": "slow", "emphasis": "muted" }
    }
  ],
  "labels": [
    {
      "text": "controller와 handler가 선언을 실제 VM으로 만든다",
      "x": 480,
      "y": 350
    }
  ]
}

순서를 따라가면 이렇다.

virt-controllerVirtualMachine을 보고, 실행 단위인 VMI(VirtualMachineInstance)와 그것을 담을 virt-launcher Pod을 만든다.

스케줄러가 그 Pod을 노드에 배치한다. 컨테이너 Pod을 배치하는 그 스케줄러다.

배치된 노드에서 도는 daemon인 virt-handler가 virt-launcher 안의 QEMU를 띄우고, GPU 같은 host device를 VM에 연결한다.

선언은 하나지만, 그 뒤로는 컨테이너 워크로드와 똑같은 경로를 밟는다. VM이 죽으면 controller가 원하는 상태에 맞춰 다시 만든다. 이것이 VM을 Kubernetes object로 다룬다는 말의 실제 의미다.

Passing a GPU to the VM

GPU 클러스터에서 VM은 결국 GPU를 받아야 쓸모가 있다.

VM에 물리 GPU를 통째로 넘기는 방식이 passthrough다. host에서 GPU를 원래 드라이버(nvidia)에서 떼어 vfio-pci에 바인딩하고, IOMMU로 DMA 접근 범위를 격리한 뒤, VM에 직접 붙인다. VFIO와 IOMMU가 이 일을 어떻게 하는지는 KVM과 QEMU 글에서 다뤘다.

KubeVirt에서 이 과정은 두 조각으로 나뉜다.

  • Kubernetes device plugin이 노드의 passthrough 가능한 GPU를 자원으로 광고한다. VM spec은 이 GPU를 hostDevices로 요청한다.
  • virt-handler가 Pod을 기동하는 시점에 그 GPU를 VM에 연결한다.

여기서 앞선 GPU Virtualization 글의 vGPU, MIG와 갈린다. vGPU와 MIG는 GPU 한 장을 여러 몫으로 나눠 주는 방식이다. passthrough는 반대로, 물리 GPU를 나누지 않고 통째로 VM 하나에 준다.

Whole Node, Not a Slice

왜 나누지 않고 통째로 주는가.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "1 VM = GPU 노드 한 대",
  "width": 900,
  "height": 490,
  "mobileWidth": 880,
  "regions": [
    {
      "id": "node",
      "label": "한 VM이 GPU 노드를 통째로 받는다",
      "x": 40,
      "y": 50,
      "width": 820,
      "height": 390
    }
  ],
  "nodes": [
    {
      "id": "vm",
      "kind": "host",
      "label": "virt-launcher Pod = VM",
      "caption": "guest가 8 GPU를 직접 인식",
      "x": 450,
      "y": 150,
      "width": 420,
      "height": 60
    },
    {
      "id": "dp",
      "kind": "switch",
      "label": "device plugin, VFIO passthrough",
      "caption": "GPU를 VM에 통째로 바인딩",
      "x": 450,
      "y": 234,
      "width": 420,
      "height": 56
    },
    {
      "id": "nvswitch",
      "kind": "memory",
      "label": "NVSwitch, NVLink full-mesh",
      "x": 450,
      "y": 312,
      "width": 720,
      "height": 48
    },
    { "id": "g0", "kind": "gpu", "label": "GPU0", "x": 135, "y": 388, "width": 76, "height": 52 },
    { "id": "g1", "kind": "gpu", "label": "GPU1", "x": 225, "y": 388, "width": 76, "height": 52 },
    { "id": "g2", "kind": "gpu", "label": "GPU2", "x": 315, "y": 388, "width": 76, "height": 52 },
    { "id": "g3", "kind": "gpu", "label": "GPU3", "x": 405, "y": 388, "width": 76, "height": 52 },
    { "id": "g4", "kind": "gpu", "label": "GPU4", "x": 495, "y": 388, "width": 76, "height": 52 },
    { "id": "g5", "kind": "gpu", "label": "GPU5", "x": 585, "y": 388, "width": 76, "height": 52 },
    { "id": "g6", "kind": "gpu", "label": "GPU6", "x": 675, "y": 388, "width": 76, "height": 52 },
    { "id": "g7", "kind": "gpu", "label": "GPU7", "x": 765, "y": 388, "width": 76, "height": 52 }
  ],
  "links": [
    {
      "id": "vm-dp",
      "points": [[450, 180], [450, 206]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "dp-sw",
      "points": [[450, 262], [450, 288]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    { "id": "sw-g0", "points": [[135, 336], [135, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g1", "points": [[225, 336], [225, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g2", "points": [[315, 336], [315, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g3", "points": [[405, 336], [405, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g4", "points": [[495, 336], [495, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g5", "points": [[585, 336], [585, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g6", "points": [[675, 336], [675, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } },
    { "id": "sw-g7", "points": [[765, 336], [765, 362]], "tone": "primary", "flow": { "speed": "normal", "emphasis": "soft" } }
  ]
}

멀티-GPU 학습에서 GPU들은 NVLink로 서로 직접 데이터를 주고받는다. 한 노드의 8 GPU는 NVSwitch를 거쳐 full-mesh로 묶여, 서로의 메모리를 고대역폭으로 오간다.

이 구조를 쪼개면 깨진다. MIG로 GPU를 나누면 instance 간 NVLink P2P가 꺼진다. vGPU도 NVLink를 주지 않는다. 8장을 4+4로 갈라 두 VM에 줘도, NVSwitch와 NVLink의 ownership이 한쪽 VM에 몰려 다른 쪽은 느린 PCIe 경로로 밀린다.

그래서 topology를 온전히 보존하는 제공 단위는 “8 GPU와 NVSwitch를 통째로 한 VM에”가 된다. GPU 노드 한 대가 곧 VM 한 대다.

Guest PCIe Topology

통째로 넘겨도 함정이 하나 남는다.

NCCL 같은 통신 라이브러리는 GPU와 NIC 사이의 거리를 PCIe topology로 판단한다. 같은 PCIe switch 아래 붙어 있으면 가깝다고 보고(PIX), CPU의 root complex를 건너가야 하면 멀다고 본다(PHB). 이 거리로 어떤 GPU가 어떤 NIC를 통해 나갈지, 통신 채널을 몇 개 열지를 정한다.

NCCL은 두 장치 사이의 PCIe 거리를 등급으로 나눠 부른다. 가까운 쪽부터 나열하면, NV는 NVLink로 직접 연결된 경우, PIX는 같은 PCIe switch 하나만 건너는 경우, PXB는 스위치 여러 개를 건너지만 CPU까지는 안 올라가는 경우, PHB는 CPU의 PCIe host bridge(root complex)를 거쳐야 하는 경우, SYS는 CPU 소켓 사이(NUMA)까지 건너는 가장 먼 경우다. 등급이 멀수록 NCCL은 그 경로를 덜 쓰려 한다.

host의 실제 배치에서는 GPU와 NIC가 같은 스위치 아래 페어로 묶여 있다. 그런데 passthrough로 장치만 그대로 넘기면, VM 안에서 보이는 PCIe 배치는 host와 달라진다. 장치들이 flat하게, 전부 가상 root 아래 나열되기 쉽다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "같은 GPU-NIC 쌍, 다르게 보인다",
  "width": 960,
  "height": 560,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "host-side",
      "label": "host 실제 — 같은 switch 아래",
      "x": 36,
      "y": 58,
      "width": 420,
      "height": 444
    },
    {
      "id": "guest-side",
      "label": "guest flat — 공통 부모가 CPU root",
      "x": 504,
      "y": 58,
      "width": 420,
      "height": 444
    }
  ],
  "nodes": [
    {
      "id": "h-switch",
      "kind": "switch",
      "label": "PCIe switch",
      "caption": "GPU, NIC의 로컬 부모",
      "x": 246,
      "y": 300,
      "width": 220,
      "height": 62
    },
    {
      "id": "h-gpu",
      "kind": "gpu",
      "label": "GPU",
      "x": 172,
      "y": 412,
      "width": 120,
      "height": 60
    },
    {
      "id": "h-nic",
      "kind": "nic",
      "label": "NIC",
      "x": 320,
      "y": 412,
      "width": 120,
      "height": 60
    },
    {
      "id": "g-root",
      "kind": "host",
      "label": "root complex (CPU)",
      "caption": "모든 장치의 공통 부모",
      "x": 714,
      "y": 158,
      "width": 260,
      "height": 62
    },
    {
      "id": "g-gpu",
      "kind": "gpu",
      "label": "GPU",
      "x": 640,
      "y": 412,
      "width": 120,
      "height": 60
    },
    {
      "id": "g-nic",
      "kind": "nic",
      "label": "NIC",
      "x": 788,
      "y": 412,
      "width": 120,
      "height": 60
    }
  ],
  "links": [
    {
      "id": "h1",
      "points": [[172, 382], [214, 331]],
      "tone": "primary",
      "flow": { "speed": "fast", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "h2",
      "points": [[278, 331], [320, 382]],
      "tone": "primary",
      "flow": { "speed": "fast", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "g1",
      "points": [[640, 382], [688, 189]],
      "tone": "warning",
      "flow": { "speed": "slow", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "g2",
      "points": [[740, 189], [788, 382]],
      "tone": "warning",
      "flow": { "speed": "slow", "count": 2, "emphasis": "strong" }
    }
  ],
  "labels": [
    {
      "text": "CPU 안 거침 — PIX (가깝다)",
      "x": 246,
      "y": 472
    },
    {
      "text": "CPU root까지 우회 — PHB (멀다)",
      "x": 714,
      "y": 472
    }
  ]
}

그러면 NCCL은 원래 가까웠던 GPU-NIC 쌍도 멀다고 판단한다. GPU-NIC affinity를 놓치고 채널 수를 잘못 잡아, 노드 간 대역폭을 제대로 못 쓴다. 링크 자체는 멀쩡한데 통신 경로 선택이 어긋나는 것이다.

해결은 host의 topology를 guest에 맞게 비춰 주는 것이다. VM 정의에 가상 PCIe switch를 만들어 원래 페어였던 GPU와 NIC를 같은 스위치 아래 묶으면, guest 안의 NCCL이 그 쌍을 다시 PIX로 인식한다. 장치를 통째로 넘기는 것과, 그 장치들이 어떻게 배치돼 보이는지는 별개의 문제다. 둘 다 맞춰야 near-native 성능이 나온다.

Lifecycle

컨테이너와 다른 점도 있다. 대표적인 것이 live migration이다.

KubeVirt VM은 원칙적으로 live migration을 지원한다. 실행 중인 VM의 상태를 산 채로 다른 노드로 넘길 수 있다.

단, passthrough가 걸리면 이야기가 달라진다. GPU를 passthrough로 붙인 VM은 그 물리 GPU에 묶여 있다. 장치 상태를 산 채로 복제할 수 없으니, 다른 노드로 live migration도 안 된다.

그래서 이런 VM은 옮길 일이 생기면 껐다가 다른 노드에서 다시 켜는 cold restart로 처리한다. near-native GPU 성능을 얻는 대가로 이동성을 내주는 셈이다.

Summary

KubeVirt는 VM을 virt-launcher Pod 안에 넣고 VirtualMachine object로 선언하게 해, 컨테이너와 같은 control plane으로 VM을 스케줄하고 복구하고 관측한다.

선언은 VirtualMachineVMI → virt-launcher Pod으로 풀리고, virt-controller와 virt-handler가 이 상태를 reconcile한다.

GPU는 device plugin과 VFIO passthrough로 VM에 통째로 붙는다. NVLink topology를 보존하려면 GPU 노드를 나누지 않고 8장을 한 VM에 준다.

passthrough VM은 물리 장치에 묶이므로 live migration이 안 되고, 옮길 때는 cold restart로 처리한다.

References