가상화의 개념
하나의 물리 컴퓨터에서 여러 운영체제를 실행하는 가상화를 하이퍼바이저의 두 유형과 KVM으로 정리합니다. 특권 명령을 가로채 흉내 내는 방식과 x86의 popf 문제를 모델로 확인하고, 반가상화와 VirtIO, VT-x·EPT·IOMMU 같은 하드웨어 지원과 두 단계 주소 변환을 살펴봅니다.
클라우드에서 서버를 만들면 몇 분 만에 쓸 수 있습니다. 대부분의 경우 그 서버는 데이터센터의 물리 서버 한 대를 여러 고객이 나누어 쓰는 가상 머신(virtual machine, VM)입니다. 하나의 물리 컴퓨터 위에서 여러 운영체제가 각자 자기만의 컴퓨터를 가진 것처럼 실행되게 하는 기술이 가상화(virtualization)입니다.
가상화는 지금까지 배운 운영체제의 개념이 한 단계 아래에서 다시 등장하는 곳입니다. 운영체제가 프로세스에게 CPU와 메모리를 나누어 주듯, 가상화 소프트웨어는 운영체제들에게 CPU와 메모리, 장치를 나누어 줍니다. 이 글에서는 가상화를 맡는 소프트웨어의 종류와, CPU, 메모리, I/O를 가상화하는 방법을 살펴봅니다.
가상화가 해 주는 일
가상화는 물리 하드웨어 위에 소프트웨어로 가상의 하드웨어를 만들어, 그 위에서 운영체제를 수정 없이 실행합니다. 이렇게 실행되는 운영체제를 게스트(guest), 실제 하드웨어를 다루는 쪽을 호스트(host)라고 합니다.
가상화를 쓰는 이유는 여러 가지입니다. CPU를 10%만 쓰는 서버 열 대를 한 대의 물리 서버에 모으면 하드웨어와 전력을 크게 아낄 수 있습니다. 각 VM은 자기만의 운영체제를 가지므로 한 VM의 운영체제가 멈추어도 다른 VM은 계속 돕니다. VM은 몇 분 만에 만들고 지울 수 있고, 통째로 스냅샷을 떠 두거나 실행 중에 다른 물리 서버로 옮기는 라이브 마이그레이션(live migration)도 할 수 있습니다. 다만 하이퍼바이저나 호스트 하드웨어가 고장 나거나, 여러 VM이 같은 자원을 두고 다투면 여러 VM이 함께 영향을 받습니다.
하이퍼바이저
VM을 만들고 실행하는 소프트웨어를 하이퍼바이저(hypervisor) 또는 가상 머신 모니터(virtual machine monitor, VMM)라고 합니다. 하이퍼바이저는 설치되는 위치에 따라 두 유형으로 나눕니다.
| 유형 | 놓이는 곳 | 예 | 주된 쓰임 |
|---|---|---|---|
| Type 1(베어메탈) | 하드웨어 위에 바로 설치된다 | VMware ESXi, Microsoft Hyper-V, Xen | 데이터센터, 클라우드 |
| Type 2(호스트형) | 일반 운영체제 위에서 응용 프로그램처럼 돈다 | VirtualBox, VMware Workstation, Parallels Desktop | 개발과 테스트 |
Type 2는 장치를 다룰 때 호스트 운영체제를 한 번 더 거치므로 비용이 더 들 수 있지만, 유형만으로 성능의 순위가 정해지지는 않습니다. 요즘의 하이퍼바이저는 대부분 뒤에서 볼 CPU의 가상화 기능을 쓰기 때문에, 실제 성능은 장치를 어떻게 처리하느냐에 더 크게 좌우됩니다.
KVM: 커널이 하이퍼바이저가 되다
KVM(kernel-based virtual machine)은 Linux 커널의 모듈입니다. 이 모듈을 넣으면 Linux 커널 자체가 하이퍼바이저가 되어, 각 VM은 Linux 입장에서 보통의 프로세스이고 VM의 가상 CPU는 그 프로세스의 스레드가 됩니다. 그래서 VM도 5장의 스케줄러가 CPU를 나누어 주고, 8장의 메모리 관리가 메모리를 나누어 줍니다. 디스크나 네트워크 카드 같은 가상 장치는 보통 QEMU라는 사용자 공간 프로그램이 흉내 냅니다.
KVM은 하드웨어 위에 바로 놓인 커널의 일부이면서 일반 운영체제이기도 해서, Type 1과 Type 2 어느 한쪽에 딱 들어맞지 않습니다. Linux 커널이 하이퍼바이저의 핵심을 맡고, QEMU 같은 사용자 공간 도구가 장치와 관리 기능을 보탠다고 이해하면 됩니다. Linux에서 가상화를 쓸 수 있는지는 다음 명령으로 확인합니다.
grep -Ec '(vmx|svm)' /proc/cpuinfo # 0이 아니면 CPU가 Intel VT-x(vmx)나 AMD-V(svm)를 드러낸다
lsmod | grep kvm # kvm, kvm_intel 또는 kvm_amd 모듈이 올라와 있는지
ls -l /dev/kvm # QEMU가 KVM을 쓸 때 여는 장치 파일
systemd-detect-virt # 지금 이 시스템이 VM 안에서 돌고 있는지더 깊이: 게스트에게서 빼앗긴 시간
가상 CPU가 호스트의 스레드라는 것은, 호스트가 바쁘면 게스트의 가상 CPU도 실행되지 못하고 기다린다는 뜻입니다. 게스트는 그동안 자기 코드가 실행되지 않았다는 것을 스스로 알기 어려우므로, KVM은 이를 알려 주는 방법을 둡니다. KVM의 MSR 문서에 따르면 게스트가 가상 CPU마다 메모리 영역 하나를 MSR_KVM_STEAL_TIME에 등록해 두면, 하이퍼바이저가 그 가상 CPU가 실행되지 못한 시간을 나노초 단위로 그 영역에 채웁니다. 가상 CPU가 할 일이 없어 쉬던 시간은 여기에 들어가지 않습니다.
게스트 Linux는 이 값을 CPU 시간 통계의 steal 항목으로 보여 줍니다. /proc/stat의 CPU 줄에서 여덟째 값과 vmstat의 st 열이 그것이며, vmstat 매뉴얼은 이를 가상 머신에게서 빼앗긴 시간이라고 설명합니다. 반대로 호스트에서는 게스트의 가상 CPU를 실행한 시간이 /proc/stat의 guest 항목과 vmstat의 gu 열로 잡힙니다. 그래서 클라우드의 VM에서 CPU 사용률은 낮은데 프로그램이 느리고 st가 높다면, 같은 물리 서버의 다른 부하에 CPU를 빼앗기고 있다고 짐작할 수 있습니다.
특권 명령을 가로채 흉내 내기
게스트 운영체제는 자기가 컴퓨터를 독차지하고 있다고 믿고 인터럽트를 끄거나, 장치에 직접 쓰거나, 페이지 테이블을 바꾸는 특권 명령을 실행합니다. 이를 그대로 실행하게 두면 게스트가 진짜 하드웨어를 건드려 다른 VM과 호스트를 망가뜨립니다. 그래서 고전적인 방법은 게스트 커널을 실제로는 낮은 권한에서 실행하는 것입니다. 게스트가 특권 명령을 실행하면 CPU가 트랩을 일으켜 하이퍼바이저로 제어가 넘어오고, 하이퍼바이저는 그 명령이 하려던 일을 그 VM의 가상 상태에 대해 대신 해 줍니다. 이것을 트랩 후 에뮬레이션(trap-and-emulate)이라고 합니다.
PRIVILEGED = {"cli", "sti", "out"} # 낮은 권한에서 실행하면 트랩이 나는 명령
class VM:
def __init__(self):
self.interrupts = True # 게스트가 자기 것이라고 믿는 인터럽트 허용 플래그
self.serial = "" # 가상 직렬 포트로 내보낸 글자
def emulate(vm, op, arg): # 트랩을 받은 하이퍼바이저가 VM의 가상 상태를 바꾼다
if op in ("cli", "sti"):
vm.interrupts = op == "sti"
elif op == "out":
vm.serial += arg
def run(vm, program):
exits = 0
for op, arg in program:
if op in PRIVILEGED: # 게스트 커널도 실제로는 낮은 권한에서 돈다
exits += 1 # CPU가 하이퍼바이저로 제어를 넘긴다
emulate(vm, op, arg)
# add 같은 보통 명령은 CPU가 그대로 실행한다.
# popf는 낮은 권한에서 인터럽트 플래그를 바꾸려 하면 트랩 없이 조용히 무시된다.
return exits
vm = VM()
exits = run(vm, [("add", 1), ("cli", None), ("out", "O"), ("add", 2), ("out", "K"), ("sti", None)])
print(f"명령 6개 중 하이퍼바이저로 넘어간 것: {exits}개, 직렬 포트: {vm.serial}")
vm = VM()
exits = run(vm, [("popf", "인터럽트 끄기")])
print(f"popf로 인터럽트를 끄려 했을 때: 넘어간 것 {exits}개, 인터럽트 허용 {vm.interrupts}")명령 6개 중 하이퍼바이저로 넘어간 것: 4개, 직렬 포트: OK
popf로 인터럽트를 끄려 했을 때: 넘어간 것 0개, 인터럽트 허용 True보통 명령은 CPU에서 그대로 실행되고, 특권 명령 네 개만 하이퍼바이저를 거쳤습니다. 가상화의 비용은 대부분 이렇게 하이퍼바이저로 넘어가는 횟수에서 나옵니다. 그런데 두 번째 줄을 보면 문제가 있습니다. 1974년 Popek과 Goldberg는 이 방식이 통하려면 시스템 상태를 바꾸거나 읽는 민감한 명령이 모두 낮은 권한에서 트랩을 일으켜야 한다는 조건을 정리했는데, 옛 x86에는 이를 어기는 명령이 여럿 있었습니다. popf가 대표적으로, 낮은 권한에서 인터럽트 플래그를 바꾸려 하면 오류 없이 조용히 무시됩니다. 게스트는 인터럽트를 껐다고 믿지만 하이퍼바이저는 그 사실을 알 길이 없습니다.
전가상화와 반가상화
이 문제를 피하는 방법이 몇 가지 나왔습니다. VMware는 1999년 게스트 커널의 코드를 실행하기 직전에 훑어보고, 문제가 되는 명령을 하이퍼바이저를 부르는 코드로 바꿔 치는 이진 변환(binary translation)으로 게스트를 고치지 않고 실행했습니다. 이렇게 게스트가 자기가 가상 환경에 있다는 것을 몰라도 실행되는 방식을 전가상화(full virtualization)라고 합니다.
Xen은 2003년 반대로 게스트 운영체제를 고치는 길을 택했습니다. 게스트 커널이 문제가 되는 명령을 쓰는 대신, 시스템 콜처럼 하이퍼바이저를 직접 부르는 하이퍼콜(hypercall)을 쓰도록 수정한 것입니다. 이처럼 게스트가 가상 환경임을 알고 협력하는 방식을 반가상화(paravirtualization)라고 합니다. 성능은 좋았지만 운영체제의 소스를 고쳐야 해서, 소스를 공개하지 않는 Windows 같은 운영체제에는 적용하기 어려웠습니다.
VirtIO: 장치만 반가상화하기
오늘날 반가상화는 주로 장치에 남아 있습니다. 하이퍼바이저가 실제 네트워크 카드인 Intel e1000이나 IDE 디스크 컨트롤러를 흉내 내면, 게스트는 원래 있던 드라이버를 그대로 쓸 수 있어 호환성이 좋습니다. 하지만 게스트 드라이버가 가짜 장치의 레지스터에 접근할 때마다 트랩이 일어나 하이퍼바이저가 개입해야 하므로, 패킷 하나를 보내는 데도 여러 번 하이퍼바이저를 오갑니다.
VirtIO는 가상 환경을 위해 처음부터 새로 설계한 장치 규격입니다. 게스트 드라이버와 하이퍼바이저는 virtqueue라는 공유 메모리의 링에 요청을 쌓아 두고, 여러 요청을 쌓은 뒤 한 번만 알립니다. 장치 레지스터를 하나하나 흉내 낼 필요가 없어 하이퍼바이저로 넘어가는 횟수가 크게 줄어듭니다. 게스트에는 VirtIO 드라이버가 필요하지만 Linux에는 기본으로 들어 있고, 네트워크에는 virtio-net, 디스크에는 virtio-blk나 virtio-scsi를 씁니다.
하드웨어 지원 가상화
결국 CPU 제조사가 문제를 하드웨어로 해결했습니다. Intel은 2005년 VT-x를, AMD는 2006년 AMD-V를 내놓았습니다. 이 기능을 켜면 CPU에 하이퍼바이저가 도는 모드와 게스트가 도는 모드가 따로 생기고(Intel은 1장 3편에서 본 이름대로 VMX root 모드와 VMX non-root 모드라고 부릅니다), 게스트 커널은 게스트 모드에서 자기 권한 그대로 실행됩니다. 하이퍼바이저는 어떤 명령이나 사건이 일어났을 때 제어를 넘겨받을지 미리 정해 두며, 그런 일이 일어나면 CPU가 게스트 실행을 멈추고 하이퍼바이저로 넘어갑니다. 이를 VM 종료(VM exit)라고 하고, 다시 게스트로 돌아가는 것을 VM 진입(VM entry)이라고 합니다. 이제 popf 같은 명령도 하드웨어가 가상 상태에 맞게 처리하므로 이진 변환이나 게스트 수정이 필요 없습니다.
KVM에서 실행과 제어가 나뉘는 지점
설정된 가로채기 조건이나 이벤트가 발생한다. VMM이 KVM_RUN으로 다시 들어간다. 각 단계의 값과 전환 이유를 비교합니다.
가상화 · 제어가 돌아오는 경계
VM Exit와 사용자 공간 반환은 서로 다른 사건입니다
KVM은 커널에서 exit를 처리한 뒤 게스트를 바로 재개할 수 있습니다. 사용자 공간 VMM의 처리가 필요할 때 KVM_RUN이 반환됩니다.
| 단계 | 처리 주체 | 다음 동작 |
|---|---|---|
| 게스트 실행 | CPU가 게스트 코드를 실행하고 주소를 변환 | 설정된 가로채기 조건이나 이벤트에서 VM Exit 발생 |
| 커널의 exit 처리 | KVM이 exit 원인에 맞게 처리 | 커널에서 처리하면 게스트를 재개할 수 있음 |
| 사용자 공간 처리 | 필요한 경우 KVM_RUN이 VMM에 반환 | VMM이 I/O 등을 처리하고 KVM_RUN으로 다시 진입 |
- 게스트 실행
- 처리 주체: CPU가 게스트 코드를 실행하고 주소를 변환다음 동작: 설정된 가로채기 조건이나 이벤트에서 VM Exit 발생
- 커널의 exit 처리
- 처리 주체: KVM이 exit 원인에 맞게 처리다음 동작: 커널에서 처리하면 게스트를 재개할 수 있음
- 사용자 공간 처리
- 처리 주체: 필요한 경우 KVM_RUN이 VMM에 반환다음 동작: VMM이 I/O 등을 처리하고 KVM_RUN으로 다시 진입
일반 메모리 접근마다 VMM으로 돌아오지는 않습니다. 하드웨어 exit 횟수와 KVM_RUN의 사용자 공간 반환 횟수도 같은 지표가 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 게스트 실행부터 게스트 재개까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 표의 값 | 게스트 실행. 처리 주체: CPU가 게스트 코드를 실행하고 주소를 변환. 다음 동작: 설정된 가로채기 조건이나 이벤트에서 VM Exit 발생. 커널의 exit 처리. 처리 주체: KVM이 exit 원인에 맞게 처리. 다음 동작: 커널에서 처리하면 게스트를 재개할 수 있음. 사용자 공간 처리. 처리 주체: 필요한 경우 KVM_RUN이 VMM에 반환. 다음 동작: VMM이 I/O 등을 처리하고 KVM_RUN으로 다시 진입. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 게스트 실행 설정된 가로채기 조건이나 이벤트가 발생한다. |
| |
| 커널 판단 KVM이 exit 원인을 판단한다. |
|
|
| VMM 처리 I/O 등 사용자 공간 처리를 수행한다. |
|
|
| 게스트 재개 VMM이 KVM_RUN으로 다시 들어간다. |
|
|
KVM에서 실행과 제어가 나뉘는 지점
설정된 가로채기 조건이나 이벤트가 발생한다. VMM이 KVM_RUN으로 다시 들어간다. 각 단계의 값과 전환 이유를 비교합니다.
가상화 · 제어가 돌아오는 경계
VM Exit와 사용자 공간 반환은 서로 다른 사건입니다
KVM은 커널에서 exit를 처리한 뒤 게스트를 바로 재개할 수 있습니다. 사용자 공간 VMM의 처리가 필요할 때 KVM_RUN이 반환됩니다.
| 단계 | 처리 주체 | 다음 동작 |
|---|---|---|
| 게스트 실행 | CPU가 게스트 코드를 실행하고 주소를 변환 | 설정된 가로채기 조건이나 이벤트에서 VM Exit 발생 |
| 커널의 exit 처리 | KVM이 exit 원인에 맞게 처리 | 커널에서 처리하면 게스트를 재개할 수 있음 |
| 사용자 공간 처리 | 필요한 경우 KVM_RUN이 VMM에 반환 | VMM이 I/O 등을 처리하고 KVM_RUN으로 다시 진입 |
- 게스트 실행
- 처리 주체: CPU가 게스트 코드를 실행하고 주소를 변환다음 동작: 설정된 가로채기 조건이나 이벤트에서 VM Exit 발생
- 커널의 exit 처리
- 처리 주체: KVM이 exit 원인에 맞게 처리다음 동작: 커널에서 처리하면 게스트를 재개할 수 있음
- 사용자 공간 처리
- 처리 주체: 필요한 경우 KVM_RUN이 VMM에 반환다음 동작: VMM이 I/O 등을 처리하고 KVM_RUN으로 다시 진입
일반 메모리 접근마다 VMM으로 돌아오지는 않습니다. 하드웨어 exit 횟수와 KVM_RUN의 사용자 공간 반환 횟수도 같은 지표가 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 게스트 실행부터 게스트 재개까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 표의 값 | 게스트 실행. 처리 주체: CPU가 게스트 코드를 실행하고 주소를 변환. 다음 동작: 설정된 가로채기 조건이나 이벤트에서 VM Exit 발생. 커널의 exit 처리. 처리 주체: KVM이 exit 원인에 맞게 처리. 다음 동작: 커널에서 처리하면 게스트를 재개할 수 있음. 사용자 공간 처리. 처리 주체: 필요한 경우 KVM_RUN이 VMM에 반환. 다음 동작: VMM이 I/O 등을 처리하고 KVM_RUN으로 다시 진입. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 게스트 실행 설정된 가로채기 조건이나 이벤트가 발생한다. |
| |
| 커널 판단 KVM이 exit 원인을 판단한다. |
|
|
| VMM 처리 I/O 등 사용자 공간 처리를 수행한다. |
|
|
| 게스트 재개 VMM이 KVM_RUN으로 다시 들어간다. |
|
|
KVM에서는 QEMU가 /dev/kvm을 열어 VM과 가상 CPU를 만들고, 가상 CPU마다 둔 스레드가 그 가상 CPU의 파일 디스크립터에 KVM_RUN 요청을 보내 게스트를 실행합니다. 그림처럼 게스트가 실행되다 VM 종료가 일어나면 KVM이 원인을 살피는데, 대부분은 커널 안에서 처리하고 곧바로 게스트로 돌아갑니다. QEMU가 흉내 내는 장치에 접근한 경우처럼 사용자 공간의 처리가 필요할 때만 KVM_RUN이 QEMU로 돌아오고, QEMU는 처리를 마친 뒤 다시 KVM_RUN을 부릅니다. 그러니 VM 종료의 횟수와 QEMU로 돌아오는 횟수는 같지 않습니다. 이 인터페이스는 KVM API 문서에 정리되어 있습니다.
메모리: 두 단계의 주소 변환
게스트 운영체제는 자기 페이지 테이블로 가상 주소를 물리 주소로 바꾸지만, 그 물리 주소는 진짜 물리 주소가 아니라 하이퍼바이저가 VM에 나누어 준 가짜 물리 주소입니다. 그래서 주소 변환이 두 단계가 됩니다. 게스트 가상 주소를 게스트 물리 주소로 바꾸는 것은 게스트의 페이지 테이블이, 게스트 물리 주소를 호스트 물리 주소로 바꾸는 것은 하이퍼바이저가 관리하는 표가 맡습니다.
PAGE = 4096
guest_page_table = {2: 5, 3: 7} # 게스트 가상 페이지 -> 게스트 물리 프레임(게스트 OS가 관리)
ept = {5: 9, 7: 12} # 게스트 물리 프레임 -> 호스트 물리 프레임(하이퍼바이저가 관리)
def translate(gva):
page, offset = divmod(gva, PAGE)
gpa = guest_page_table[page] * PAGE + offset
hpa = ept[gpa // PAGE] * PAGE + offset
return gpa, hpa
for gva in (0x2345, 0x3ABC):
gpa, hpa = translate(gva)
print(f"게스트 가상 {gva:#06x} -> 게스트 물리 {gpa:#06x} -> 호스트 물리 {hpa:#06x}")게스트 가상 0x2345 -> 게스트 물리 0x5345 -> 호스트 물리 0x9345
게스트 가상 0x3abc -> 게스트 물리 0x7abc -> 호스트 물리 0xcabc두 단계를 거쳐도 페이지 안의 오프셋은 그대로이고, 페이지 번호만 두 번 바뀝니다. 하드웨어 지원이 없던 시절에는 하이퍼바이저가 두 표를 합친 섀도 페이지 테이블(shadow page table)을 따로 만들어, 게스트가 페이지 테이블을 고칠 때마다 트랩을 받아 맞춰 주어야 했습니다. 지금은 Intel의 EPT(extended page tables)와 AMD의 NPT(nested page tables)가 두 번째 단계를 하드웨어로 처리해, 게스트가 자기 페이지 테이블을 고쳐도 VM 종료가 일어나지 않습니다.
대신 TLB 실패가 나면 비용이 커집니다. 게스트의 4단계 페이지 테이블을 따라가는 동안, 각 단계의 표가 놓인 게스트 물리 주소마다 다시 호스트의 4단계 표를 따라가야 하므로, 최악의 경우 메모리를 24번 읽어야 합니다. 그래서 가상화 환경에서는 TLB 실패를 줄이는 큰 페이지(huge page)가 특히 효과가 있습니다.
I/O: 장치를 VM에 직접 주기
가장 빠른 I/O는 하이퍼바이저를 거치지 않는 것입니다. 장치를 VM에 통째로 넘겨주는 장치 직접 할당(device passthrough)을 하면 게스트의 드라이버가 실제 장치를 직접 다룹니다. 이때 문제가 되는 것은 11장 2편에서 본 DMA입니다. 게스트 드라이버는 장치에 게스트 물리 주소를 알려 주는데, 장치가 그 주소로 바로 DMA를 하면 엉뚱한 호스트 메모리를 덮어쓰게 됩니다.
IOMMU(Intel VT-d, AMD-Vi)는 장치의 DMA 주소를 페이지 테이블처럼 변환해, 장치가 그 VM에 속한 메모리에만 접근하게 합니다. Linux는 VFIO로 장치를 VM이나 사용자 프로그램에 안전하게 넘겨줍니다. 장치 하나를 여러 VM이 나누어 쓰도록, 하나의 장치가 여러 개의 가상 기능(virtual function)을 드러내는 SR-IOV도 있습니다. AWS의 Nitro 시스템처럼 클라우드는 네트워크와 스토리지 처리를 전용 하드웨어로 옮겨, VM이 하이퍼바이저를 거의 거치지 않고 I/O를 하게 만들기도 합니다.
cat /sys/module/kvm_intel/parameters/ept # Intel CPU에서 EPT를 쓰는지(Y/N)
dmesg | grep -i -e DMAR -e IOMMU # IOMMU가 켜졌는지 부팅 로그에서 확인정리
- 가상화는 하나의 물리 컴퓨터에서 여러 운영체제를 각자 독립된 컴퓨터처럼 실행하게 하며, 이를 맡는 하이퍼바이저는 하드웨어 위에 바로 놓이는 Type 1과 운영체제 위에서 도는 Type 2로 나뉩니다.
- KVM은 Linux 커널을 하이퍼바이저로 만들며, VM은 프로세스로, 가상 CPU는 스레드로 다루어집니다.
- 고전적인 가상화는 특권 명령을 트랩으로 가로채 흉내 내지만, 옛 x86의
popf처럼 트랩 없이 무시되는 민감한 명령 때문에 이진 변환과 반가상화가 필요했습니다. - VT-x와 AMD-V는 게스트 모드와 VM 종료를 하드웨어로 제공하고, EPT와 NPT는 두 단계 주소 변환을, IOMMU는 장치 DMA의 격리를 하드웨어로 처리합니다.
- 가상화의 비용은 주로 하이퍼바이저로 넘어가는 횟수에서 나오므로, VirtIO처럼 넘어가는 횟수를 줄이는 설계가 성능을 좌우합니다.
연습 문제
1. 두 단계 주소 변환 계산하기
페이지 크기가 4KiB이고, 게스트의 페이지 테이블은 게스트 가상 페이지 1을 게스트 물리 프레임 4에, 하이퍼바이저의 EPT는 게스트 물리 프레임 4를 호스트 물리 프레임 10에 대응시킵니다. 게스트 가상 주소 0x1F00의 게스트 물리 주소와 호스트 물리 주소를 구하세요. 게스트가 이 주소에 접근할 때마다 VM 종료가 일어나나요?
해설 보기
0x1F00은 페이지 1의 오프셋 0xF00입니다. 게스트 페이지 테이블로 프레임 4를 얻어 게스트 물리 주소는 0x4F00이고, EPT로 프레임 10을 얻어 호스트 물리 주소는 0xAF00입니다. 오프셋 0xF00은 두 단계 내내 그대로입니다. 두 단계의 변환은 모두 CPU의 하드웨어가 처리하고 결과는 TLB에 담기므로, 접근할 때마다 VM 종료가 일어나지는 않습니다. VM 종료는 EPT에 대응이 없는 주소에 접근했을 때처럼 하이퍼바이저가 개입해야 하는 경우에만 일어납니다.
2. 옛 x86의 문제
VT-x가 나오기 전의 x86에서는 왜 게스트 커널을 낮은 권한에서 실행하고 트랩만 받는 방식으로 가상화할 수 없었나요? 이를 해결한 세 가지 방법을 비교하세요.
해설 보기
트랩 후 에뮬레이션이 통하려면 시스템 상태에 영향을 주는 민감한 명령이 모두 낮은 권한에서 트랩을 일으켜야 하는데, 옛 x86에는 popf처럼 낮은 권한에서 조용히 다르게 동작하는 명령이 있었습니다. 게스트가 인터럽트를 끄려 해도 트랩이 없으니 하이퍼바이저는 이를 알 수 없고, 게스트의 동작이 틀어집니다. 이진 변환은 게스트 커널의 코드를 실행 직전에 훑어 문제 명령을 바꿔 치므로 게스트를 고칠 필요가 없지만 하이퍼바이저가 복잡해집니다. 반가상화는 게스트 커널을 고쳐 하이퍼콜을 쓰게 하므로 효율적이지만 소스를 고칠 수 있는 운영체제에만 쓸 수 있습니다. 하드웨어 지원 가상화는 CPU에 게스트 모드를 두어 게스트를 고치지 않고도 문제를 해결하며, 오늘날의 표준이 되었습니다.
3. e1000과 virtio-net
게스트가 패킷 1,000개를 보냅니다. 흉내 낸 e1000 네트워크 카드에서는 패킷마다 장치 레지스터에 다섯 번 접근하고 접근마다 VM 종료가 일어난다고 가정합니다. virtio-net에서는 패킷 64개를 링에 쌓을 때마다 한 번 알린다고 가정합니다. 각각 VM 종료가 몇 번 일어나나요? 실제 차이를 볼 때 무엇을 함께 확인해야 하나요?
해설 보기
e1000은 1,000 × 5 = 5,000번입니다. virtio-net은 1,000을 64로 나누어 올림한 16번입니다. 이 가정 아래에서는 VM 종료가 300분의 1 정도로 줄어, 하이퍼바이저를 오가는 비용이 크게 줄어듭니다. 실제로는 장치를 흉내 내는 쪽이 커널 안에 있는지 QEMU에 있는지, 패킷을 몇 개씩 묶는지, 완료를 인터럽트로 알리는지에 따라 숫자가 달라지므로, 측정한 VM 종료 횟수와 처리량, 지연을 함께 보아야 합니다.
다음 글 컨테이너와 Docker에서는 운영체제를 통째로 가상화하는 대신, 하나의 커널을 나누어 쓰며 프로세스를 격리하는 더 가벼운 방법인 컨테이너를 살펴봅니다.