[CS300 #120] 가상화 — 하이퍼바이저와 컨테이너
컴퓨터공학 300 주제 시리즈의 120번째 글이다. 전체 지도는 여기.
한 줄 요약
하이퍼바이저는 하드웨어를 가상화해 각 가상 머신이 자기만의 커널을 돌리게 하고, 컨테이너는 운영체제를 가상화해 하나의 커널 위에서 격리된 프로세스 묶음을 돌리게 하며, 둘은 격리 강도와 비용이 다른 같은 계보의 기술이다.
왜 필요한가
Part 6 의 첫 글에서 운영체제의 핵심이 “가상화”라고 했다. 프로세스는 CPU 와 메모리를 가상화한 것이었다. 이 글은 그 생각을 한 단계 위로 올린다. 운영체제 자체를 여러 벌 돌리거나, 운영체제 하나를 여러 벌인 것처럼 나누는 것이다.
이것이 필요한 이유는 현실적이다.
- 서버 한 대에 서로 다른 OS(리눅스 배포판, 윈도우)를 함께 올려 하드웨어 활용률을 높인다.
- 고객마다 강하게 격리된 환경을 준다(퍼블릭 클라우드).
- 애플리케이션과 의존성을 한 덩어리로 묶어 어디서나 같게 실행한다(컨테이너 이미지).
- 장애·보안 사고의 피해 범위를 한 상자 안에 가둔다.
클라우드 VM 위의 쿠버네티스 노드에서 컨테이너가 돌아가는 오늘날의 구조는 두 가상화가 겹쳐 있는 모습이다. 둘의 차이를 알아야 성능 문제와 보안 경계를 제대로 판단할 수 있다.
핵심 개념
하이퍼바이저의 두 유형
Type 1 (베어메탈) Type 2 (호스트형)
+------+------+------+ +------+------+
| VM1 | VM2 | VM3 | | VM1 | VM2 |
| 커널 | 커널 | 커널 | | 커널 | 커널 |
+------+------+------+ +------+------+
| 하이퍼바이저 | | 하이퍼바이저 앱 | 다른 앱들
+--------------------+ +-----------------+-----------+
| 하드웨어 | | 호스트 운영체제 |
+--------------------+ +-----------------------------+
| 하드웨어 |
+-----------------------------+
예: Xen, VMware ESXi, Hyper-V 예: VirtualBox, VMware Workstation
리눅스의 KVM 은 이 구분에서 흥미로운 위치에 있다. 커널 모듈로 리눅스 커널 자체를 하이퍼바이저로 만들고, 각 VM 은 호스트에서 보면 평범한 프로세스(보통 QEMU)다. VM 의 vCPU 는 그 프로세스의 스레드로 스케줄링된다. KVM 은 /dev/kvm 장치 파일과 ioctl 기반 API 로 사용자 공간에 기능을 제공한다(커널 문서 참고).
CPU 가상화: 어떻게 커널을 커널 위에서 돌리나
게스트 커널도 자신이 링 0 에서 특권 명령을 실행한다고 믿는다. 하이퍼바이저는 그것을 그대로 허용할 수 없다. 방법은 역사적으로 세 단계로 발전했다.
| 방식 | 원리 | 예 |
|---|---|---|
| 트랩 후 에뮬레이션 | 게스트를 비특권 모드로 돌리고, 특권 명령이 트랩되면 하이퍼바이저가 흉내 낸다 | 고전 메인프레임 |
| 이진 변환 / 반가상화 | 트랩되지 않는 문제 명령을 실행 전에 고쳐 쓰거나, 게스트 커널을 수정해 하이퍼바이저를 직접 호출(hypercall)하게 한다 | 초기 VMware, 초기 Xen |
| 하드웨어 지원 | CPU 에 게스트 전용 모드를 추가해 특권 명령 대부분을 하드웨어가 처리하고 필요할 때만 하이퍼바이저로 빠져나온다(VM exit) | Intel VT-x, AMD-V |
포페크(Popek)와 골드버그(Goldberg)는 1974년 논문에서 “민감한 명령이 모두 특권 명령이면 트랩 후 에뮬레이션으로 효율적인 가상화가 가능하다”는 조건을 정리했다. 초기 x86 은 이 조건을 만족하지 않아 이진 변환 같은 우회가 필요했고, VT-x 와 AMD-V 가 하드웨어로 이 틈을 메웠다.
메모리와 I/O 가상화
- 메모리: 게스트는 “게스트 가상 주소 → 게스트 물리 주소”로 변환하지만, 그 게스트 물리 주소도 실제로는 호스트의 가상 메모리다. 하드웨어의 2단계 페이지 테이블(Intel EPT, AMD NPT)이 두 번의 변환을 하드웨어로 처리한다. 대신 TLB 미스 비용이 커지므로 거대 페이지가 특히 효과적이다.
- I/O: 실제 장치를 흉내 내는 완전 에뮬레이션은 느리다. 그래서 게스트가 “가상 환경임을 아는” 효율적 장치 인터페이스인 virtio 를 쓰거나, SR-IOV 같은 기술로 물리 장치를 VM 에 직접 할당한다.
컨테이너: 운영체제 수준 가상화
119번 글에서 본 것처럼 컨테이너는 네임스페이스와 cgroup 으로 격리·제한한 프로세스다. 하드웨어를 흉내 내지 않고, 게스트 커널도 없다.
| 가상 머신 | 컨테이너 | |
|---|---|---|
| 가상화 대상 | 하드웨어 | 운영체제 커널 인터페이스 |
| 커널 | VM 마다 따로 | 호스트 커널 공유 |
| 시작 시간 | 커널 부팅이 필요해 상대적으로 느림 | 프로세스 시작 수준으로 빠름 |
| 메모리 오버헤드 | 게스트 커널과 OS 서비스만큼 | 애플리케이션 자체 |
| 격리 경계 | 하이퍼바이저(작은 공격 면) | 커널 시스템 콜 인터페이스(넓은 공격 면) |
| 다른 OS 실행 | 가능(리눅스 위 윈도우) | 같은 커널 계열만 |
컨테이너 이미지와 실행 방식은 OCI(Open Container Initiative) 명세로 표준화되어 있어, 한 도구로 만든 이미지를 다른 런타임에서 돌릴 수 있다.
둘 사이: 샌드박스 런타임
격리는 VM 처럼, 사용법은 컨테이너처럼 하려는 시도도 있다. 컨테이너마다 경량 VM 을 띄우는 방식(Kata Containers, Firecracker 기반 마이크로 VM), 사용자 공간에서 시스템 콜을 가로채 커널을 흉내 내는 방식(gVisor)이다. 쿠버네티스는 RuntimeClass 로 파드마다 이런 런타임을 골라 쓰게 한다.
직접 해 보기
지금 내가 베어메탈인지, VM 안인지, 컨테이너 안인지 리눅스에서 확인해 본다.
import os
flags = set()
with open("/proc/cpuinfo") as f:
for line in f:
if line.startswith("flags"):
flags = set(line.split(":", 1)[1].split()); break
print("CPU 하드웨어 가상화 지원 :", "Intel VT-x (vmx)" if "vmx" in flags else
"AMD-V (svm)" if "svm" in flags else "없음 또는 숨겨짐")
print("하이퍼바이저 위에서 실행 중:", "예 (hypervisor 플래그)" if "hypervisor" in flags else "아니오(베어메탈로 보임)")
print("/dev/kvm 존재 :", os.path.exists("/dev/kvm"))
def in_container():
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return "예 (런타임 표식 파일)"
if os.environ.get("KUBERNETES_SERVICE_HOST"):
return "예 (쿠버네티스 파드 환경 변수)"
try:
if os.readlink("/proc/self/ns/pid") != os.readlink("/proc/1/ns/pid"):
return "PID 1 과 다른 PID 네임스페이스"
except OSError:
pass
return "표식 없음(호스트로 보임)"
print("컨테이너 안인가 :", in_container())
print("커널 (컨테이너면 호스트 것):", os.uname().release)
인텔 CPU 노트북(베어메탈)에서의 결과다.
CPU 하드웨어 가상화 지원 : Intel VT-x (vmx)
하이퍼바이저 위에서 실행 중: 아니오(베어메탈로 보임)
/dev/kvm 존재 : True
컨테이너 안인가 : 표식 없음(호스트로 보임)
커널 (컨테이너면 호스트 것): 6.8.0-139-generic
같은 코드를 클라우드 VM 에서 돌리면 hypervisor 플래그가 켜져 “예”가 나오고, 중첩 가상화를 허용하지 않은 VM 이라면 vmx/svm 이 보이지 않는다. 쿠버네티스 파드 안에서 돌리면 KUBERNETES_SERVICE_HOST 환경 변수가 잡히고, 커널 버전은 노드의 것과 똑같이 나온다. 컨테이너가 커널을 공유한다는 사실을 한 줄로 확인하는 방법이다. 판별 휴리스틱은 완벽하지 않으므로(런타임마다 표식이 다르다) systemd 가 있는 시스템이라면 systemd-detect-virt 가 더 많은 경우를 알아본다.
현업에서는
- VM 안의 쿠버네티스 노드: 클라우드 노드에서는 VM 의 CPU 가 다른 테넌트와 물리 코어를 나누므로 “steal” 시간(
top의st)이 생길 수 있다. 노드 CPU 가 이유 없이 느리면st를 확인한다. - 홈랩: VM 이냐 컨테이너냐: 커널 모듈이나 다른 커널 버전이 필요한 실험(eBPF 프로그램 시험, 커널 업그레이드 리허설)은 VM 에서, 서비스 배포는 컨테이너로 하는 것이 일반적인 분업이다. k3s 노드 위에 KVM VM 을 함께 돌린다면 두 쪽의 메모리 예약이 겹치지 않도록 kubelet 의 시스템 예약량을 설정해 둔다.
- 보안 경계 판단: 신뢰할 수 없는 코드(사용자가 올린 스크립트 실행 등)를 일반 컨테이너에서 돌리는 것은 커널을 공유한다는 점에서 위험하다. 이런 워크로드는 RuntimeClass 로 샌드박스 런타임을 지정하거나 별도 VM 노드로 분리한다.
- 컨테이너 안에서 보이는 자원 수치: 컨테이너 안의
/proc/meminfo와nproc은 기본적으로 cgroup 제한이 아니라 노드 전체를 보여 줄 수 있다. 런타임이 컨테이너 환경을 인식하는지(JVM 의 컨테이너 지원 등)와 cgroup 파일을 직접 읽는지를 확인해야 스레드 풀과 힙 크기를 올바르게 잡는다.
확인 문제
- Type 1 과 Type 2 하이퍼바이저의 차이를 설명하라.
- 초기 x86 에서 순수한 트랩 후 에뮬레이션 방식의 가상화가 어려웠던 이유는?
- 2단계 페이지 테이블(EPT/NPT)이 해결하는 문제는?
- 컨테이너가 VM 보다 빠르게 시작하는 이유와, 대신 약해지는 점은?
- 컨테이너 안에서
uname -r을 실행하면 누구의 커널 버전이 나오는가?
풀이
- Type 1 은 하드웨어 위에서 직접 실행되며 VM 들을 관리하고, Type 2 는 일반 호스트 운영체제 위의 애플리케이션으로 실행된다.
- 결과가 모드에 따라 달라지는 민감한 명령 중 일부가 사용자 모드에서 트랩을 일으키지 않고 조용히 다르게 동작했기 때문이다. 하이퍼바이저가 개입할 기회가 없었다.
- 게스트 가상 주소 → 게스트 물리 주소 → 호스트 물리 주소의 두 단계 변환을 하드웨어가 처리하게 해, 하이퍼바이저가 소프트웨어로 섀도 페이지 테이블을 유지하는 비용을 없앤다.
- 게스트 커널을 부팅할 필요 없이 호스트 커널 위에서 프로세스를 하나 시작하는 것뿐이기 때문이다. 대신 모든 컨테이너가 커널을 공유하므로 격리 경계가 커널 시스템 콜 인터페이스 전체로 넓어져 VM 보다 약하다.
- 호스트(노드)의 커널 버전이 나온다. 컨테이너는 자기 커널이 없다.
더 읽을거리 (References)
- OSTEP, Appendix: Virtual Machine Monitors (PDF)
- G. J. Popek, R. P. Goldberg, “Formal requirements for virtualizable third generation architectures”, Communications of the ACM 17(7), 1974.
- Linux kernel documentation, The Definitive KVM API Documentation
- Open Container Initiative, Runtime Specification; Kubernetes 문서, Runtime Class