TT Lab
요금제

한국어

시작하기
블로그

블로그

KubeVirt 소스 코드 읽기 3: virt-controller — VM 과 VMI 의 reconcile

상태: 초안 (2026-10-17), 소스 + 읽기 전용 실측. 기준은 KubeVirt v1.9.0(커밋 79d34c17)입니다. 실측은 v1.9.0 이 깔린 작은 클러스터의 로그·객체·파드 스펙을 읽은 것이고, VM 을 새로 만들거나 바꾸지 않았습니다.

KubeVirt 소스 코드 읽기 3: virt-controller

목차

1. 직관: 큐에는 이름만, 판단은 늘 처음부터

쿠버네티스 컨트롤러는 「무슨 일이 일어났나」 가 아니라 「지금 어떤 상태인가」 로 움직입니다. 이벤트 처리기는 바뀐 객체의 이름(네임스페이스/이름)만 작업 큐에 넣고, 일꾼이 그 이름을 꺼내 캐시에서 최신 객체를 읽은 뒤 「바라는 상태와 지금 상태의 차이」 를 처음부터 다시 계산합니다. 같은 이름이 열 번 들어와도 한 번만 처리되고, 실패하면 이름을 큐에 늦춰 다시 넣습니다.

virt-controller 의 VM 하나는 이 고리 둘을 거칩니다.

2. 그림

virt-controller — 두 컨트롤러가 이어 달린다 VM 컨트롤러가 VMI 를 만들고, VMI 컨트롤러가 파드를 만든다 VM 컨트롤러 pkg/virt-controller/watch/vm · 일꾼 3 인포머 VM·VMI·DV·PVC 작업 큐 키 = ns/이름 Execute() 실패 → 재시도 sync() — 종료자 · DataVolume 준비 · 리비전 syncRunStrategy() — Always · Halted · Manual … startVMI() → VMI 생성 stopVMI() → VMI 삭제 VMI 컨트롤러 watch/vmi · 일꾼 10 sync() 파드가 없으면 만든다 RenderLaunchManifest() services/template.go createPod() → virt-launcher 파드 updateStatus(): Pending → Scheduling → Scheduled 파드가 Ready 면 노드 이름을 적고 virt-handler 에 넘긴다 실측: 리스 virt-controller 는 한 파드가 쥐고, 다른 하나는 대기한다. 실측: Failed 로 끝난 VMI 를 Always 가 지우고(vm.go:1005) 0.19초 뒤 새 VMI 를 만들었다(vm.go:1341). 로그의 줄 번호가 v1.9.0 소스와 같다.
그림: VM 컨트롤러와 VMI 컨트롤러가 이어 달리는 reconcile

3. 코드 경로: 리더가 되어야 일한다

virt-controller 는 파드 둘이 떠 있어도 리더 하나만 컨트롤러를 돌립니다. application.go 의 리더 선출 콜백 onStartedLeading()(:609)에서 인포머를 시작하고 go vca.vmController.Run(...) 처럼 컨트롤러를 띄웁니다. 일꾼 수의 기본값은 VMI 컨트롤러 10 (:128), VM 컨트롤러를 포함한 대부분이 3(:125)입니다.

4. VM 컨트롤러: pkg/virt-controller/watch/vm/vm.go

4.1 Execute — 큐에서 하나 꺼내기

vm.go:336 의 Execute() 는 모든 컨트롤러가 같은 모양입니다.

if err := c.execute(key); err != nil {
	log.Log.Reason(err).Infof("re-enqueuing VirtualMachine %v", key)
	c.Queue.AddRateLimited(key)
} else {
	log.Log.V(4).Infof("processed VirtualMachine %v", key)
	c.Queue.Forget(key)
}

출처: kubevirt/kubevirt v1.9.0 vm.go 347–353행(Apache-2.0). 실패는 AddRateLimited 로 점점 늦게 다시 시도하고, 성공하면 Forget 으로 늦춤 기록을 지웁니다.

4.2 execute → sync

execute()(:357)는 캐시(인덱서)에서 VM 을 읽어 복사본을 만들고, 같은 이름의 VMI 를 찾아 소유권(ownerReference)을 확인하고(ClaimVirtualMachineInstanceByName), DataVolume 목록을 모은 뒤 sync() 와 updateStatus() 를 부릅니다. 캐시의 객체를 직접 고치면 다른 일꾼이 보는 캐시가 오염되므로 KubeVirt 코드는 거의 늘 DeepCopy() 부터 합니다. 리뷰에서 자주 보는 지적입니다.

sync()(:3215)의 순서는 이렇습니다.

  1. 기대값 확인. satisfiedExpectations() 가 거짓이면 아무것도 하지 않고 돌아갑니다. 바로 전에 VMI 를 만들라고 요청했는데 그 VMI 가 아직 인포머에 보이지 않는 상태이기 때문입니다. 이 장치가 없으면 캐시가 늦을 때 VMI 를 두 번 만듭니다.
  2. 지우는 중이면 VMI 를 멈추고 종료자(finalizer)를 뗍니다. 아니면 종료자 kubevirt.io/virtualMachineControllerFinalize 를 붙입니다.
  3. vm.RunStrategy()(2편)로 실행 전략을 하나로 접고, instancetype 동기화, DataVolume 생성·준비 확인을 합니다.
  4. syncRunStrategy() 로 VMI 를 만들거나 지웁니다.

4.3 syncRunStrategy — Always 분기

vm.go:970 의 switch runStrategy 가 이 컨트롤러의 심장입니다. Always(:979) 분기를 말로 옮기면 이렇습니다.

RerunOnFailure 는 Failed 일 때만 다시 띄우고 Succeeded 면 두는 차이, Halted 는 VMI 가 있으면 지우기만, Manual 은 서브리소스가 넣은 요청(2편)만 따른다는 차이가 각 분기에 그대로 적혀 있습니다.

4.4 startVMI — VMI 를 만들기 전에 하는 일

startVMI()(:1257)는 VMI 를 바로 만들지 않고 다섯 가지를 먼저 합니다.

  1. SetupVMIFromVM()(:1868) — VM 의 템플릿을 복사하고, 이름·네임스페이스·소유자(VM)를 넣고, 템플릿에 펌웨어 UUID 가 없으면 VM 이름에서 계산해 넣습니다(CalculateLegacyUUID). VM 을 껐다 켜도 게스트가 보는 UUID 가 같은 이유입니다.
  2. createVMRevision() — 이 시작에 쓴 VM 스펙을 ControllerRevision 으로 저장합니다. 이름은 revision-start-vm-<VM UID>-<generation> (getVMRevisionName, :1698)입니다. 나중에 VM 스펙이 바뀌었을 때 「재시작이 필요하다」(RestartRequired) 조건을 계산하는 기준이 됩니다.
  3. VMI 에 종료자를 붙입니다. VMI 가 지워지기 전에 VM 컨트롤러가 반드시 한 번 보게 하려는 것입니다.
  4. instancetype·네트워크 기본 인터페이스를 적용하고 네트워크를 다시 검증합니다.
  5. c.expectations.ExpectCreations(vmKey, 1)(:1333) 으로 「하나 만들 것」 을 적어 두고 VMI 를 만듭니다. 인포머가 새 VMI 를 보면 addVirtualMachineInstance() 가 CreationObserved 로 기대값을 내립니다.

5. VMI 컨트롤러: 파드를 만들고 넘겨준다

5.1 lifecycle.go 의 sync

VMI 컨트롤러의 판단은 pkg/virt-controller/watch/vmi/lifecycle.go 의 sync() (:66)에 있습니다. VMI 가 지워지는 중이면 파드를 지우고, 끝났으면 핫플러그용 파드만 치웁니다. 파드가 아직 없고 VMI 가 처음 처리되는 중이면 몇 가지 조건(DataVolume 준비, 진행 중인 마이그레이션 없음, 백엔드 스토리지 준비)을 확인한 뒤 RenderLaunchManifest() (:156)로 파드를 그리고 createPod() (:1076)로 만듭니다. 파드 보안 기준에 걸려 거절되면 「privileged 네임스페이스가 필요하다」 는 친절한 오류로 바꿔 이벤트에 남깁니다.

5.2 updateStatus — 넘겨주는 순간

updateStatus()(:256)는 상(phase)을 옮깁니다. 파드를 만들면 Pending → Scheduling, 그리고 Scheduling 에서 파드가 Ready 가 되면 볼륨·네트워크 status 를 채우고 상을 Scheduled 로 바꾸면서 VMI 에 라벨 kubevirt.io/nodeName=<노드> 와 status.nodeName 을 적습니다 (:421–426). 이 라벨이 곧 4편의 virt-handler 가 자기 일을 고르는 기준입니다. 2편에서 본 Scheduled 의 주석 그대로, 여기서 바통이 넘어갑니다.

6. 파드 템플릿: pkg/virt-controller/services/template.go

template.go 가 그린 virt-launcher 파드 실측: 2 vCPU · 2Gi VM 하나(PVC 루트) — 괄호는 컨테이너 디스크 VM 에만 Pod virt-launcher-<vm>-xxxxx · restartPolicy Never terminationGracePeriodSeconds 60 = 30 + 15 + 15 initContainers guest-console-log (restartPolicy: Always) 네이티브 사이드카 · virt-tail · CPU 5m · 메모리 35M (container-disk-binary · volumeroot-init) 컨테이너 디스크를 미리 받아 두는 초기화 containers compute virt-launcher-monitor --name … --grace-period-seconds 45 요청: CPU 200m (= 2 vCPU ÷ 10) · 메모리 2328Mi = 게스트 2048Mi + 오버헤드 280Mi 장치: devices.kubevirt.io/kvm · tun · vhost-net (volumeroot — 디스크 이미지를 품은 컨테이너) volumes private public sockets libvirt-runtime ephemeral-disks container-disks hotplug-disks root ← PVC 오버헤드 280Mi 는 코드의 추정식과 정확히 같다: 고정 220Mi + 페이지 테이블 4Mi(2Gi ÷ 512) + vCPU 8Mi×2 + IO 스레드 8Mi + 비디오 32Mi (hypervisor/kvm GetMemoryOverhead) 8 vCPU · 8Gi 실습 VM 은 340Mi — 역시 식과 같다
그림: template.go 가 그린 virt-launcher 파드와 메모리 오버헤드

RenderLaunchManifest()(:355)는 먼저 메모리 오버헤드를 계산하고 실제 조립은 renderLaunchManifest() (:395)에 맡깁니다. 400줄 가까운 이 함수에서 볼 곳은 다섯입니다.

7. 실측

v1.9.0 이 깔린 작은 클러스터에서 읽기로 확인했습니다(2026-10-17).

리더. 리스 kubevirt/virt-controller 의 보유자는 두 파드 중 하나였고(리스 기간 15초), 다른 파드의 로그는 시작할 때의 「attempting to acquire leader lease」 한 줄에서 멈춰 있었습니다. 대기 파드는 리스만 노리고 아무 컨트롤러도 돌리지 않습니다.

재시작 한 번의 로그. 2026-10-13 에 한 VM(실행 전략 Always)의 VMI 가 Failed 로 끝났습니다(끝난 까닭은 이 글의 범위 밖입니다). 리더의 로그에서 그 VM 의 줄만 뽑으면 4.3절의 분기가 그대로 보입니다. pos 의 줄 번호가 모두 v1.9.0 소스와 같습니다.

시각(UTC) pos 메시지
00:42:32.011 vm.go:1005 Stopping VM with VMI in phase Failed and VM runStrategy: Always
00:42:32.042 vm.go:1685 Dispatching delete event for vmi … with phase Failed
00:42:32.179 vm.go:1032 Starting VM due to runStrategy: Always
00:42:32.196 vm.go:1341 Started VM by creating the new virtual machine instance …

첫 판단에서 새 VMI 생성까지 0.19초입니다. 지우기와 만들기가 다른 reconcile 에서 일어난 것도 보입니다(사이에 같은 VM 의 Stopping 줄이 한 번 더 있었습니다).

새 VMI 에 남은 흔적. 그 VMI 의 status.virtualMachineRevisionName 은 revision-start-vm-<VM UID>-1 이었고(4.4절의 2), metadata.finalizers 는 kubevirt.io/virtualMachineControllerFinalize 와 kubevirt.io/foregroundDeleteVirtualMachine, 주석 kubevirt.io/vm-generation: "1", 라벨 kubevirt.io/nodeName 이 붙어 있었습니다. 상 기록(phaseTransitionTimestamps)은 Pending·Scheduling 00:42:32 → Scheduled 00:42:35 → Running 00:42:36 이었습니다.

파드 스펙과 계산식. 같은 VMI 의 virt-launcher 파드(2 vCPU·게스트 2Gi, 루트는 PVC)와, 실습용 VM 하나(8 vCPU·8Gi, 컨테이너 디스크)를 읽었습니다.

항목 2 vCPU · 2Gi 8 vCPU · 8Gi 코드
CPU 요청 200m 800m vCPU ÷ 10
메모리 요청 2328Mi 8532Mi 게스트 + 오버헤드
오버헤드(주석 kubevirt.io/memory-overhead-bytes) 293,601,280 B = 280Mi 356,515,840 B = 340Mi 아래 계산
--grace-period-seconds / 파드 유예 45 / 60 45 / 60 30+15, +15
--qemu-timeout 283s 309s 240 + 0–119
사용자 runAsUser 107, runAsNonRoot 같음 비루트 기본

오버헤드를 식으로 다시 계산하면 2Gi VM 은 고정 220Mi + 페이지 테이블 4Mi + vCPU 16Mi + IO 스레드 8Mi + 그래픽 32Mi = 280Mi, 8Gi VM 은 220 + 16 + 64 + 8 + 32 = 340Mi 로 실측 주석과 바이트까지 같습니다. 「학생 VM 이 뜨기까지」 2편의 「약 280Mi」 가 어디서 왔는지가 이것입니다.

그 밖에 파드에는 readiness gate kubevirt.io/virtual-machine-unpaused(VM 을 일시 정지하면 파드가 Ready 에서 빠진다), 장치 자원 devices.kubevirt.io/kvm·tun·vhost-net 요청, 그리고 guest-console-log 초기화 컨테이너(restartPolicy: Always, CPU 5m·메모리 35M)가 있었습니다.

8. 한계

티티 한마디. 컨트롤러 코드를 읽을 땐 「이 함수가 실패하면 어떻게 되지?」 를 계속 물어보세요. 거의 늘 답은 「이름을 큐에 다시 넣고, 다음 번에 처음부터 다시 본다」 예요. 그래서 함수가 조금 틀려도 결국 맞는 상태로 수렴해요!

참고

모두 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다