상태: 초안 (2026-10-17), 소스 + 읽기 전용 실측. 기준은 KubeVirt
v1.9.0(커밋79d34c17)입니다. 실측은 v1.9.0 이 깔린 작은 클러스터의 로그·객체·파드 스펙을 읽은 것이고, VM 을 새로 만들거나 바꾸지 않았습니다.
KubeVirt 소스 코드 읽기 3: virt-controller
목차
- 1. 직관: 큐에는 이름만, 판단은 늘 처음부터
- 2. 그림
- 3. 코드 경로: 리더가 되어야 일한다
- 4. VM 컨트롤러: pkg/virt-controller/watch/vm/vm.go
- 5. VMI 컨트롤러: 파드를 만들고 넘겨준다
- 6. 파드 템플릿: pkg/virt-controller/services/template.go
- 7. 실측
- 8. 한계
- 참고
1. 직관: 큐에는 이름만, 판단은 늘 처음부터
쿠버네티스 컨트롤러는 「무슨 일이 일어났나」 가 아니라 「지금 어떤 상태인가」 로 움직입니다. 이벤트 처리기는 바뀐 객체의
이름(네임스페이스/이름)만 작업 큐에 넣고, 일꾼이 그 이름을 꺼내 캐시에서 최신 객체를 읽은 뒤 「바라는 상태와 지금 상태의
차이」 를 처음부터 다시 계산합니다. 같은 이름이 열 번 들어와도 한 번만 처리되고, 실패하면 이름을 큐에 늦춰 다시 넣습니다.
virt-controller 의 VM 하나는 이 고리 둘을 거칩니다.
- VM 컨트롤러: VM 을 보고 VMI 를 만들거나 지운다(실행 전략).
- VMI 컨트롤러: VMI 를 보고 virt-launcher 파드를 만들고, 파드가 준비되면 VMI 를 노드에 넘긴다.
2. 그림
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)의 순서는 이렇습니다.
- 기대값 확인.
satisfiedExpectations()가 거짓이면 아무것도 하지 않고 돌아갑니다. 바로 전에 VMI 를 만들라고 요청했는데 그 VMI 가 아직 인포머에 보이지 않는 상태이기 때문입니다. 이 장치가 없으면 캐시가 늦을 때 VMI 를 두 번 만듭니다. - 지우는 중이면 VMI 를 멈추고 종료자(finalizer)를 뗍니다. 아니면 종료자
kubevirt.io/virtualMachineControllerFinalize를 붙입니다. vm.RunStrategy()(2편)로 실행 전략을 하나로 접고, instancetype 동기화, DataVolume 생성·준비 확인을 합니다.syncRunStrategy()로 VMI 를 만들거나 지웁니다.
4.3 syncRunStrategy — Always 분기
vm.go:970 의 switch runStrategy 가 이 컨트롤러의
심장입니다. Always(:979) 분기를 말로 옮기면
이렇습니다.
- VMI 가 있는데 멈춤 요청이 있거나 끝난 상태(Succeeded·Failed) 면
stopVMI()(:1658)로 지웁니다. 새로 만드는 일은 다음 reconcile 이 합니다(「지우고 돌아가서 다음 단계에서 시작하게 한다」 는 주석). - VMI 가 있고 살아 있으면 아무것도 하지 않습니다.
- VMI 가 없으면, 시작 실패가 반복된 VM 에는 늦춤 시간(backoff)을 지키고, 아니면
startVMI()를 부릅니다.
RerunOnFailure 는 Failed 일 때만 다시 띄우고 Succeeded 면 두는 차이, Halted 는 VMI 가 있으면 지우기만, Manual 은 서브리소스가 넣은
요청(2편)만 따른다는 차이가 각 분기에 그대로 적혀 있습니다.
4.4 startVMI — VMI 를 만들기 전에 하는 일
startVMI()(:1257)는 VMI 를 바로 만들지 않고
다섯 가지를 먼저 합니다.
SetupVMIFromVM()(:1868) — VM 의 템플릿을 복사하고, 이름·네임스페이스·소유자(VM)를 넣고, 템플릿에 펌웨어 UUID 가 없으면 VM 이름에서 계산해 넣습니다(CalculateLegacyUUID). VM 을 껐다 켜도 게스트가 보는 UUID 가 같은 이유입니다.createVMRevision()— 이 시작에 쓴 VM 스펙을ControllerRevision으로 저장합니다. 이름은revision-start-vm-<VM UID>-<generation>(getVMRevisionName, :1698)입니다. 나중에 VM 스펙이 바뀌었을 때 「재시작이 필요하다」(RestartRequired) 조건을 계산하는 기준이 됩니다.- VMI 에 종료자를 붙입니다. VMI 가 지워지기 전에 VM 컨트롤러가 반드시 한 번 보게 하려는 것입니다.
- instancetype·네트워크 기본 인터페이스를 적용하고 네트워크를 다시 검증합니다.
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
RenderLaunchManifest()(:355)는 먼저
메모리 오버헤드를 계산하고 실제 조립은 renderLaunchManifest()
(:395)에 맡깁니다. 400줄 가까운 이 함수에서
볼 곳은 다섯입니다.
- 사용자. 비루트 VMI 면 UID 107(
qemu)로 돕니다. 비루트인지는 VMI 의status.runtimeUser로 정해지고(vmitrait.IsNonRoot), 실측한 VMI 는 모두 107 이었습니다. - 유예 시간. VMI 의 유예 시간(기본 30초)에 15초를 더해 virt-launcher 에 넘기고, 파드에는 거기에 15초를 한 번 더 더합니다 (:411). 주석의 뜻은 「VM 을 내리는 일은 virt-handler 가 하게 하고, 쿠버네티스가 먼저 파드를 죽여 VM 을 강제로 끄지 않게」 입니다.
- 명령. 컨테이너
compute의 명령은virt-launcher-monitor와 플래그 묶음입니다 (:460).--qemu-timeout은 기본 240초 (application.go:115)에 0–119초의 무작위 값을 더합니다(qemuTimeoutJitterRange = 120, :101). 여러 VM 이 한꺼번에 시간 초과로 끝나지 않게 흩어 놓는 것입니다. - 사이드카. 시리얼 콘솔 로그를 흘리는
guest-console-log는 initContainers 에restartPolicy: Always로 들어갑니다 (serialconsolelog.go:45). 쿠버네티스의 네이티브 사이드카라서 주 컨테이너보다 먼저 뜨고 나중에 내려갑니다. 컨테이너 디스크를 쓰는 VM 에는 디스크 이미지를 미리 받아 두는 초기화 컨테이너가 더 붙습니다(5편). - 자원. CPU 요청은 vCPU 수 ÷
cpuAllocationRatio(기본 10), 메모리 요청은 게스트 메모리 + 오버헤드입니다. KVM 의 오버헤드 추정식은pkg/hypervisor/kvm/hypervisorbackend.go의GetMemoryOverhead()(:69)에 있습니다. 고정값 다섯 (virt-launcher-monitor 25Mi, virt-launcher 100Mi, virtlogd 25Mi, virtqemud 40Mi, QEMU 30Mi — :38)에 게스트 메모리의 1/512(페이지 테이블), vCPU 당 8Mi, IO 스레드 8Mi, 그래픽 장치 32Mi 를 더하고, VFIO 장치가 있으면 1Gi 를 더 붙입니다(7편).
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. 한계
- 오버헤드 식은 요청값입니다. 실제로 쓰는 메모리는 5편에서 따로 쟀습니다(프로세스별 RSS). 식의 고정값 220Mi 가 실제와 얼마나 다른지는 그쪽을 보세요.
- 재시작 로그는 한 번의 사건입니다. 기대값이 막아 준 「두 번 만들기」 를 직접 재현해 보지는 않았습니다(미실측).
- 마이그레이션, 핫플러그, instancetype 동기화는 같은 파일에 있지만 이 글의 길에서 뺐습니다.
티티 한마디. 컨트롤러 코드를 읽을 땐 「이 함수가 실패하면 어떻게 되지?」 를 계속 물어보세요. 거의 늘 답은 「이름을 큐에 다시 넣고, 다음 번에 처음부터 다시 본다」 예요. 그래서 함수가 조금 틀려도 결국 맞는 상태로 수렴해요!
참고
모두 2026-10-17 에 열람했습니다.
vm.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/watch/vm/vm.golifecycle.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/watch/vmi/lifecycle.gotemplate.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/services/template.go- KubeVirt 사용자 안내서, 실행 전략: https://kubevirt.io/user-guide/compute/run_strategies/
- 쿠버네티스 문서, 네이티브 사이드카 컨테이너: https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/
- 자매 시리즈 「학생 VM 이 뜨기까지」 2편: KubeVirt 안쪽, 5편: 수명과 회수