TT Lab
블로그 시작하기
배우기 러닝패스 코스

KubeVirt 라이브 마이그레이션 — 옮기고, 막히고, 끊기는 시간 재기

VM 셋 안에 KubeVirt 를 세우고 라이브 마이그레이션 — 옮기기, 막히기, 노드 비우기

TT Lab 에서 이어서 보기

목표

VM 세 대(컨트롤 플레인 하나, 워커 둘)로 된 쿠버네티스에 KubeVirt 와 CDI 를 설치하고, 워커 안에서 2단 VM 을 띄워 다른 워커로 멈추지 않고 옮깁니다. 옮길 수 있는 VM 과 없는 VM 이 무엇으로 갈리는지, 옮길 때 게스트가 실제로 몇 밀리초 멈추는지, 노드를 비울 때 VM 이 어떻게 되는지를 직접 봅니다.

왜 중요한가

쿠버네티스는 파드를 옮기지 않습니다 — 지우고 다른 곳에 새로 만듭니다. VM 은 다릅니다. 메모리에 든 상태(열린 연결, 캐시, 실행 중인 프로세스)를 잃지 않으려면 메모리를 통째로 다른 노드로 넘겨야 합니다. 그것이 라이브 마이그레이션이고, 노드 커널 패치·하드웨어 교체 때 VM 을 끄지 않고 노드를 비우는 유일한 길입니다. 조건은 까다롭습니다. 디스크를 두 노드가 동시에 열 수 있어야 하고(ReadWriteMany), 네트워크가 옮겨 갈 수 있는 방식이어야 하고(masquerade), 대상 노드의 CPU 가 게스트에게 보여 준 기능을 모두 가져야 합니다. 하나라도 어긋나면 VM 은 옮겨 가지 않고 — 노드 비우기에서는 꺼집니다.

단계

  1. Helm 저장소 csi-driver-nfs(https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts)의 차트 csi-driver-nfs 4.13.4 을 릴리스 이름 csi-driver-nfs, 네임스페이스 kube-system 에 설치하세요. 그리고 StorageClass nfs-csi 를 만드세요 — provisioner nfs.csi.k8s.io, parameters 는 server(controlplane 의 InternalIP), share: /srv/nfs, mountPermissions: "0777", 그리고 기본 클래스 표시(storageclass.kubernetes.io/is-default-class: "true"). 만든 매니페스트는 /root/kvmig/nfs-sc.yaml 에 둡니다.
  2. KubeVirt v1.9.0 의 오퍼레이터(kubevirt-operator.yaml, GitHub 릴리스)를 설치하고, 네임스페이스 kubevirt 에 KubeVirt CR kubevirt 를 만드세요. VM 이 워커에만 놓이도록 spec.workloads.nodePlacement.nodeSelector 를 labhub.io/vm-worker: "true" 로 두고(두 워커에 이미 붙어 있는 라벨), 제어부(virt-api·virt-controller)는 spec.infra.replicas: 1 로 하나씩만 둡니다. CR 은 /root/kvmig/kubevirt-cr.yaml 에 두고, KubeVirt 가 Available 이 될 때까지 기다리세요.
  3. CDI v1.66.0(cdi-operator.yaml·cdi-cr.yaml, GitHub 릴리스)를 설치하세요. 그리고 네임스페이스 mig 에 DataVolume cirros-base 를 만들어 docker://quay.io/kubevirt/cirros-container-disk-demo:v1.9.0 를 가져옵니다 — StorageClass nfs-csi, 접근 모드 ReadWriteMany, 크기 1Gi. DataVolume 이 Succeeded 여야 합니다.
  4. 두 워커의 KubeVirt 노드 라벨을 읽고, 두 워커가 모두 지원하는 이름 붙은 CPU 모델 하나를 골라 /root/kvmig/cpu-model.txt 에 그 이름만 한 줄로 적으세요(예: Haswell-noTSX). 그리고 각 워커의 host-model 이름과, 한쪽에만 있는 host-model 필수 기능을 /root/kvmig/host-model.txt 에 노드 모델 한쪽에만-있는-필수기능(쉼표) 꼴로 한 줄씩 적습니다.
  5. 네임스페이스 mig 에 VirtualMachine web 을 만드세요. 루트 디스크는 cirros-base 를 복제한 DataVolume web-root(StorageClass nfs-csi, ReadWriteMany, 1Gi), CPU 모델은 /root/kvmig/cpu-model.txt 의 모델, 메모리 128Mi, 인터페이스는 pod 네트워크에 masquerade, evictionStrategy: LiveMigrate, runStrategy: Always. 매니페스트는 /root/kvmig/web.yaml 에 둡니다. VMI 의 LiveMigratable 조건이 True 여야 합니다.
  6. VMI web 을 다른 워커로 라이브 마이그레이션하세요(virtctl migrate 또는 VirtualMachineInstanceMigration). 끝나면 대상 virt-launcher 에서 libvirt 의 완료 작업 정보를 읽어 /root/kvmig/migrate.txt 에 그대로 저장합니다: virsh -c 'qemu+unix:///session?socket=/var/run/libvirt/virtqemud-sock' domjobinfo --completed mig_web(컨테이너 compute).
  7. web 과 같되 루트 디스크가 ReadWriteOnce(pinned-root, nfs-csi)이고 evictionStrategy: None 인 VirtualMachine pinned 을 mig 에 만드세요(매니페스트 /root/kvmig/pinned.yaml). 그런 다음 옮겨 보고(virtctl migrate pinned) 거절 메시지를 /root/kvmig/blocked.txt 에 저장합니다.
  8. web 이 지금 있는 워커를 kubectl drain(--ignore-daemonsets --delete-emptydir-data)으로 비우세요. web 은 다른 워커로 라이브 마이그레이션되어야 하고(축출이 만든 마이그레이션이 Succeeded), pinned 는 축출 전략 None 이라 꺼졌다가 다른 워커에서 다시 뜹니다. 끝나면 그 노드를 uncordon 하고, drain 의 출력을 /root/kvmig/drain.txt 에 저장하세요.

참고

controlplane 의 NFS 를 ReadWriteMany 스토리지 클래스로 잇는다

Helm 저장소 csi-driver-nfs(https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts)의 차트 csi-driver-nfs 4.13.4 을 릴리스 이름 csi-driver-nfs, 네임스페이스 kube-system 에 설치하세요. 그리고 StorageClass nfs-csi 를 만드세요 — provisioner nfs.csi.k8s.io, parameters 는 server(controlplane 의 InternalIP), share: /srv/nfs, mountPermissions: "0777", 그리고 기본 클래스 표시(storageclass.kubernetes.io/is-default-class: "true"). 만든 매니페스트는 /root/kvmig/nfs-sc.yaml 에 둡니다.

NFS 서버는 이미 controlplane 에서 돌고 있습니다(cat /etc/exports). 주소는 kubectl get node controlplane -o wide 의 INTERNAL-IP 입니다 — 첫 NIC(10.0.2.2)는 모든 VM 이 같아서 쓸 수 없습니다. mountPermissions 를 빼면 볼륨 디렉터리가 root 소유 0755 로 만들어지고, 3단계의 CDI 가져오기 파드(uid 107)가 mkdir /scratch/disk: permission denied 로 떨어집니다. NFS 는 fsGroup 으로 소유권을 바꿔 주지 않기 때문입니다.

KubeVirt 를 워커에만 VM 을 두도록 설치한다

KubeVirt v1.9.0 의 오퍼레이터(kubevirt-operator.yaml, GitHub 릴리스)를 설치하고, 네임스페이스 kubevirt 에 KubeVirt CR kubevirt 를 만드세요. VM 이 워커에만 놓이도록 spec.workloads.nodePlacement.nodeSelector 를 labhub.io/vm-worker: "true" 로 두고(두 워커에 이미 붙어 있는 라벨), 제어부(virt-api·virt-controller)는 spec.infra.replicas: 1 로 하나씩만 둡니다. CR 은 /root/kvmig/kubevirt-cr.yaml 에 두고, KubeVirt 가 Available 이 될 때까지 기다리세요.

workloads 는 virt-handler(노드마다 VM 을 돌리는 데몬셋)와 VM 파드의 자리입니다. 비워 두면 controlplane 에도 virt-handler 가 떠서 VM 이 그곳에 앉을 수 있습니다 — 이 실습은 두 워커 사이에서만 옮깁니다. 설치는 2분 남짓 걸립니다: kubectl -n kubevirt wait kv kubevirt --for condition=Available --timeout=200s. kubectl -n kubevirt get pods -o wide 로 virt-handler 가 node01·node02 에만 있는지 보세요.

CDI 를 설치하고 바탕 디스크를 RWX 로 가져온다

CDI v1.66.0(cdi-operator.yaml·cdi-cr.yaml, GitHub 릴리스)를 설치하세요. 그리고 네임스페이스 mig 에 DataVolume cirros-base 를 만들어 docker://quay.io/kubevirt/cirros-container-disk-demo:v1.9.0 를 가져옵니다 — StorageClass nfs-csi, 접근 모드 ReadWriteMany, 크기 1Gi. DataVolume 이 Succeeded 여야 합니다.

CDI 는 VM 디스크를 PVC 로 가져오고 복제하는 일을 맡습니다. source.registry.url 은 컨테이너 이미지 안의 디스크 파일을 꺼내 PVC 에 씁니다. DataVolume 의 storage: 절(pvc: 가 아닌)을 쓰면 빠진 값을 StorageProfile 이 채웁니다 — 그래도 접근 모드는 직접 적으세요. 운영에서 VM 이 옮겨 가지 못하는 가장 흔한 이유가 이 한 줄입니다. 가져오기가 ImportInProgress 에서 멈추면 kubectl -n mig logs -l app=containerized-data-importer 를 보세요.

두 워커가 함께 낼 수 있는 CPU 모델을 고른다

두 워커의 KubeVirt 노드 라벨을 읽고, 두 워커가 모두 지원하는 이름 붙은 CPU 모델 하나를 골라 /root/kvmig/cpu-model.txt 에 그 이름만 한 줄로 적으세요(예: Haswell-noTSX). 그리고 각 워커의 host-model 이름과, 한쪽에만 있는 host-model 필수 기능을 /root/kvmig/host-model.txt 에 노드 모델 한쪽에만-있는-필수기능(쉼표) 꼴로 한 줄씩 적습니다.

라벨은 cpu-model.node.kubevirt.io/<모델>(그 노드가 낼 수 있는 모델), host-model-cpu.node.kubevirt.io/<모델>(host-model 이 고르는 모델), host-model-required-features.node.kubevirt.io/<기능>(host-model VM 이 대상 노드에 요구하는 기능)입니다. kubectl get node node01 -o json | jq -r '.metadata.labels|keys[]' | grep cpu-model.node 처럼 두 노드의 목록을 뽑아 comm -12 로 겹치는 것을 찾으세요. 이 실습의 두 워커는 서로 다른 물리 호스트에 앉을 수 있어서 host-model 이름이 같아도 필수 기능이 다를 수 있습니다 — 그러면 host-model VM 은 옮겨 갈 자리가 없습니다.

RWX 디스크 VM 을 띄우고 LiveMigratable 을 읽는다

네임스페이스 mig 에 VirtualMachine web 을 만드세요. 루트 디스크는 cirros-base 를 복제한 DataVolume web-root(StorageClass nfs-csi, ReadWriteMany, 1Gi), CPU 모델은 /root/kvmig/cpu-model.txt 의 모델, 메모리 128Mi, 인터페이스는 pod 네트워크에 masquerade, evictionStrategy: LiveMigrate, runStrategy: Always. 매니페스트는 /root/kvmig/web.yaml 에 둡니다. VMI 의 LiveMigratable 조건이 True 여야 합니다.

VMI 가 뜨면 KubeVirt 가 status.conditions 에 LiveMigratable 을 계산해 둡니다: kubectl -n mig get vmi web -o jsonpath='{.status.conditions}' | jq. CPU 모델을 비워 두면 기본값 host-model 이 되고, 4단계에서 본 것처럼 두 워커의 필수 기능이 다르면 조건은 True 인데 실제로는 옮길 자리가 없습니다 — 조건은 CPU 호환을 계산하지 않습니다. masquerade 가 아닌 bridge 로 pod 네트워크에 붙이면 조건이 False(InterfaceNotLiveMigratable)가 됩니다.

web 을 다른 워커로 옮기고 중단 시간을 읽는다

VMI web 을 다른 워커로 라이브 마이그레이션하세요(virtctl migrate 또는 VirtualMachineInstanceMigration). 끝나면 대상 virt-launcher 에서 libvirt 의 완료 작업 정보를 읽어 /root/kvmig/migrate.txt 에 그대로 저장합니다: virsh -c 'qemu+unix:///session?socket=/var/run/libvirt/virtqemud-sock' domjobinfo --completed mig_web(컨테이너 compute).

마이그레이션은 새 virt-launcher 파드(대상)를 만들고, 원본 QEMU 가 메모리를 넘긴 뒤 대상에서 이어 돌립니다. kubectl -n mig get vmim -w 로 Scheduling → TargetReady → Running → Succeeded 를 보세요. 도메인 이름은 <네임스페이스>_<VMI 이름> 입니다. 원본 파드는 끝나면 Completed 가 되지만 끝난 직후 몇 초는 Running 으로 남아 있고 그 안의 libvirt 는 이미 내려가 있습니다 — status.migrationState.targetNode 의 파드(--field-selector spec.nodeName=<노드>)에서 읽으세요. Total downtime 이 게스트가 실제로 멈춘 시간이고, Data processed·Memory bandwidth 가 보낸 양과 속도입니다. 실측(1Gi Fedora, 부하 없음)은 6–7초에 456MiB, 중단 338–490ms 였습니다.

RWO 디스크 VM 이 왜 못 옮기는지 읽는다

web 과 같되 루트 디스크가 ReadWriteOnce(pinned-root, nfs-csi)이고 evictionStrategy: None 인 VirtualMachine pinned 을 mig 에 만드세요(매니페스트 /root/kvmig/pinned.yaml). 그런 다음 옮겨 보고(virtctl migrate pinned) 거절 메시지를 /root/kvmig/blocked.txt 에 저장합니다.

운영 클러스터의 VM 이 정확히 이 상태입니다 — 같은 NFS 저장소인데 PVC 를 ReadWriteOnce 로 만들었습니다. 저장소가 공유를 지원해도 PVC 가 RWO 이면 KubeVirt 는 두 노드가 동시에 열 수 없다고 보고 LiveMigratable=False(DisksNotLiveMigratable)로 둡니다. 마이그레이션 요청은 만들어지지도 않고 승인 웹훅에서 거절됩니다. 축출 전략을 None 으로 두는 이유는 다음 단계입니다 — 옮길 수 없는 VM 에 LiveMigrate 를 두면 노드 비우기가 끝나지 않습니다.

web 이 있는 노드를 비우고, 끝나면 되돌린다

web 이 지금 있는 워커를 kubectl drain(--ignore-daemonsets --delete-emptydir-data)으로 비우세요. web 은 다른 워커로 라이브 마이그레이션되어야 하고(축출이 만든 마이그레이션이 Succeeded), pinned 는 축출 전략 None 이라 꺼졌다가 다른 워커에서 다시 뜹니다. 끝나면 그 노드를 uncordon 하고, drain 의 출력을 /root/kvmig/drain.txt 에 저장하세요.

drain 은 파드를 Eviction API 로 내보냅니다. KubeVirt 의 승인 웹훅(virt-launcher-eviction-interceptor.kubevirt.io)이 LiveMigrate VM 의 virt-launcher 축출을 거절하고 대신 마이그레이션을 만들며, drain 은 파드가 사라질 때까지 5초마다 다시 시도합니다. 그래서 출력에 Evacuation in progress: Eviction triggered evacuation of VMI "mig/web" 같은 줄이 몇 번 찍히는 것이 정상입니다(PodDisruptionBudget 은 만들어지지 않습니다 — kubectl get pdb -A). 축출이 만든 마이그레이션은 이름이 kubevirt-evacuation- 으로 시작합니다: kubectl -n mig get vmim. 옮겨 가지 못한 VM 이 남은 채로 노드를 되돌리면 VMI 에 남은 status.evacuationNodeName 때문에 축출 마이그레이션이 계속 다시 만들어집니다 — virtctl evacuate-cancel vmi <이름> 으로 끊습니다.