상태: 초안 (2026-10-17), 소스 + 읽기 전용 실측. 기준은 KubeVirt
v1.9.0(커밋79d34c17)입니다. 실측은 작은 클러스터의 virt-handler·virt-launcher 로그와 VMI status 를 읽은 것입니다.
KubeVirt 소스 코드 읽기 4: virt-handler
목차
1. 직관: 원하는 것과 실제의 차이를 노드에서 메운다
3편의 VMI 컨트롤러는 파드가 준비되면 VMI 에 노드 이름을 적고 손을 뗍니다. 그 뒤로 VM 을 실제로 켜고 끄는 일은 그 노드의 virt-handler 몫입니다. virt-handler 도 컨트롤러이지만 비교 대상이 특이합니다.
- 바라는 것: 클러스터의 VMI 객체(스펙).
- 실제: virt-launcher 파드 안 libvirt 가 들고 있는 도메인(상태).
둘을 견주어 「끈다 · 지운다 · 맞춘다」 중 하나를 고르고, 결과를 다시 VMI 의 status 에 적습니다. 그리고 파드 밖에서만 할 수 있는 일(다른 컨테이너의 디스크를 붙이기, 파드의 네트워크 네임스페이스에 장치 만들기)을 맡습니다. virt-launcher 의 특권을 작게 두려는 설계이고, 리뷰 안내서에도 「노드 수준의 특권 작업은 virt-launcher 가 아니라 virt-handler 에」 라는 원칙이 있습니다(8편).
2. 그림
3. 코드 경로
3.1 두 인포머
cmd/virt-handler/virt-handler.go 의 Run()(:209)이
조립합니다.
- VMI 인포머.
factory.VMISourceHost(app.HostOverride)(:268)는 라벨 선택자kubevirt.io/nodeName in (<내 노드>)로 VMI 를 봅니다 (virtinformers.go:492). 노드 1,000대 클러스터에서 virt-handler 1,000개가 VMI 전체를 보면 API 서버가 버티지 못합니다. 3편 끝에서 VMI 컨트롤러가 붙인 그 라벨이 여기서 쓰입니다. - 도메인 인포머.
virtcache.NewSharedInformer(...)(:274)는 쿠버네티스 객체가 아니라 이 노드의 virt-launcher 들이 보내오는 도메인 이벤트를 인포머처럼 감쌉니다. 이벤트가 빠질 때를 대비해worker()(domain-watcher.go:73)가 일정 주기마다handleResync()(:142)로 각 launcher 의 소켓에GetDomain을 물어 다시 채웁니다. 주기는 플래그--domain-resync-period-seconds의 기본 300초 (virt-handler.go:118)입니다.
두 인포머가 같은 키(네임스페이스/이름)를 큐에 넣으므로, VMI 가 바뀌어도 도메인이 바뀌어도 같은 execute() 가 돕니다.
3.2 이벤트는 어떻게 파드 밖으로 나오나
virt-launcher 안의 notify 클라이언트는 자기 파일시스템의 /var/run/kubevirt/domain-notify-pipe.sock 에 붙습니다. 이 소켓은 launcher 가
만든 것이 아니라 virt-handler 가 파드 안에 심어 둔 것입니다. pipe.InjectNotify()
(notify-server/pipe/pipe.go:54)가 파드의
마운트 안에 듣는 소켓을 만들고, 들어온 연결을 handler 자신의 notify 서버로 잇습니다(Proxy(), :104).
notify 서비스는 HandleDomainEvent 와 HandleK8sEvent 두 RPC 뿐입니다
(notify.proto:6).
launcher 는 handler 의 소켓 경로를 알 필요도, 볼 권한도 없습니다.
반대 방향(handler → launcher)은 launcher 가 여는 cmd 서버입니다. handler 는 kubelet 의 파드 폴더를 /pods 로 마운트해 두고
/pods/<파드 UID>/volumes/kubernetes.io~empty-dir/sockets/launcher-sock
(cmd-client/client.go:191)에 gRPC 로 붙습니다.
Cmd 서비스(cmd.proto:6)에는
SyncVirtualMachine·ShutdownVirtualMachine·KillVirtualMachine·GetDomain·Ping 등 36개 RPC 가 있습니다.
handler 와 launcher 의 판이 다를 수 있어(업그레이드 중) 이 proto 를 고치는 PR 은 옛 launcher 와도 말이 통하는지를 꼭 봅니다.
3.3 execute — 넘겨받을지 말지
execute()(vm.go:321)는 대부분 「처리하지 않을 이유」 를
고릅니다.
- VMI 가 로컬 인포머에 없으면 전역 인포머에서 한 번 더 찾습니다. 도메인 인포머가 VMI 인포머보다 빠를 수 있어서입니다.
- VMI 는 그대로인데 도메인의 UID 가 다르면(같은 이름으로 다시 만든 VM), 옛 도메인을 먼저 치우고 5초 뒤 다시 봅니다.
- 마이그레이션 중이거나, 이 노드 소유가 아니거나(
isVMIOwnedByNode, :1829), 마이그레이션의 출발지면 손대지 않습니다. 출발지·도착지는migration-source.go·migration-target.go의 별도 컨트롤러가 맡습니다.
남은 경우만 sync() 로 갑니다.
3.4 sync — 세 갈래
sync()(vm.go:1433)는 먼저 VMI 와 도메인의 상태를 한 줄로
남깁니다(:1459).
c.logger.Object(vmi).Infof("VMI is in phase: %v | Domain status: %v, reason: %v", vmi.Status.Phase, domain.Status.Status, domain.Status.Reason)
출처: kubevirt/kubevirt v1.9.0 pkg/virt-handler/vm.go 1459행(Apache-2.0). 그다음 shouldShutdown·shouldDelete·shouldUpdate 를
계산하고, 주석이 밝힌 순서대로 하나를 고릅니다. 지우기 때문에 끄는 일 → 이미 꺼진 것 정리 → 스펙 변경과 첫 시작입니다
(:1571 부근의 switch). 마지막에 늘 updateVMIStatus()
(:1097)로 도메인에서 읽은 것을 VMI status 에 적습니다.
상(phase), 인터페이스 목록, 게스트 OS 정보, 볼륨 상태, AgentConnected 같은 조건이 여기서 채워집니다.
3.5 맞추기 — 파드 밖에서만 할 수 있는 준비
shouldUpdate 갈래는 processVmUpdate()(:2256) →
vmUpdateHelperDefault()(:1933)로 갑니다. 아직 Running
이 아닌 VMI 면 handleStartingVMI()(:2043)가 시작 전 준비를
합니다. 하나라도 준비가 안 되면 키를 1초 뒤로 미루고 돌아갑니다.
- 컨테이너 디스크. 디스크 이미지를 품은 컨테이너(
volumeroot)가 준비됐는지 보고(ContainerDisksReady), 그 컨테이너의 파일을 launcher 의container-disks폴더에 붙입니다(MountAndVerify, container-disk/mount.go:246). 서로 다른 컨테이너의 파일시스템을 잇는 일이라 파드 안에서는 할 수 없습니다. - 핫플러그 볼륨을 붙이고 준비를 기다립니다.
- 네트워크.
setupNetwork()(controller.go:363)가 파드의 네트워크 네임스페이스에 들어가 브리지와 tap 장치를 만듭니다. 바라는 모양을 nmstate 형식으로 그린 뒤 적용합니다(5편 그림). - 장치 소유자.
setupDevicesOwnerships()(controller.go:320)가/dev/kvm등의 소유자를 비루트 QEMU(107)가 쓸 수 있게 맞춥니다.
준비가 끝나면 syncVirtualMachine()(:2123)이 SMBIOS·메모리
풍선 주기 같은 클러스터 설정을 옵션에 담아 client.SyncVirtualMachine(vmi, options)
(cmd-client/client.go:332)를 부릅니다. VMI 는
JSON 으로 통째로 넘어가고, 그 뒤는 5편의 virt-launcher 몫입니다.
4. 실측: 넘겨받아 Running 이 되기까지
2026-10-13 에 다시 만들어진 VMI 하나(3편의 그 VM)의 시작을 virt-handler 와 virt-launcher 로그로 맞춰 보았습니다. 시각은 로그의 UTC 이고 「+」 는 virt-handler 가 처음 넘겨받은 줄에서부터 잰 시간입니다.
| +초 | 누가 | pos | 내용 |
|---|---|---|---|
| −0.337 | launcher | virt-launcher.go:85 |
Marked as ready(cmd 서버가 뜸) |
| 0.000 | handler | vm.go:1461 |
VMI is in phase: Scheduled | Domain does not exist |
| +0.006 | handler | netpod.go:187·244 |
현재·바라는 파드 네트워크(브리지 k6t-eth0) |
| +0.863 | handler | pipe.go:116 |
Accepted new notify pipe connection |
| +0.864 | launcher | manager.go:1651 |
Domain defined. |
| +1.057 | launcher | manager.go:1612 |
Domain started. |
| +1.060 | launcher | server.go:269 |
Synced vmi |
| +1.103 | handler | vm.go:1459 |
VMI is in phase: Scheduled | Domain status: Running |
| +1.136 | handler | vm.go:1459 |
VMI is in phase: Running | Domain status: Running |
handler 가 넘겨받은 뒤 1.1초 만에 VMI 가 Running 이 됐습니다. 네트워크 준비에서 notify 파이프 연결까지 0.86초가 가장 깁니다. launcher 쪽의 도메인 정의와 시작은 합쳐 0.2초입니다(5편).
5분 resync. 같은 VM 의 handler 로그에서 그 뒤 줄들은 …:03:25, …:08:25, …:13:25 처럼 정확히 300초마다 찍혀 있었습니다
(2026-10-16 하루치를 읽음). 3.1절의 defaultDomainResyncPeriodSeconds = 300 이 이벤트가 없어도 도메인을 다시 묻는 것입니다.
status 에 남은 것. 그 VMI 의 status 에는 interfaces[0].infoSource: domain, guest-agent, guestOSInfo(게스트가 보고한 OS 이름과
커널), 조건 AgentConnected(시작 8초 뒤)가 있었습니다. 앞의 둘은 handler 가 도메인과 게스트 에이전트에서 읽어 적은 것입니다.
5. 한계
- 노드 하나, VM 시작 한 번의 타임라인입니다. 노드와 VM 크기, 컨테이너 디스크 유무에 따른 분포는 미실측입니다. 이 VM 은 루트가 이미 채워진 PVC 라 컨테이너 디스크 단계가 없었습니다.
- 끄기와 지우기 갈래(
processVmShutdown·processVmDelete)는 이번에 로그로 확인하지 않았습니다. - 마이그레이션 컨트롤러(
migration-source.go·migration-target.go)와 장치 관리자(7편)는 같은 프로세스에 있지만 이 글의 길에서 뺐습니다.
티티 한마디. virt-handler 를 읽을 땐 「이 일은 왜 launcher 가 아니라 handler 가 하지?」 를 물어보세요. 거의 늘 「파드 밖을 만져야 해서」 예요. 특권은 노드 데몬 하나에 모으고, VM 마다 뜨는 파드는 가볍게 두는 거죠!
참고
모두 2026-10-17 에 열람했습니다.
pkg/virt-handler/vm.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-handler/vm.gocmd.proto·notify.proto: https://github.com/kubevirt/kubevirt/tree/v1.9.0/pkg/handler-launcher-com- 리뷰 안내서(노드 구성요소의 인포머, 특권 작업의 자리): https://github.com/kubevirt/kubevirt/blob/v1.9.0/docs/reviewer-guide.md
- 자매 시리즈 「학생 VM 이 뜨기까지」 1편: 네 층으로 본 전체 그림