컴퓨터공학 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 파일을 직접 읽는지를 확인해야 스레드 풀과 힙 크기를 올바르게 잡는다.

확인 문제

  1. Type 1 과 Type 2 하이퍼바이저의 차이를 설명하라.
  2. 초기 x86 에서 순수한 트랩 후 에뮬레이션 방식의 가상화가 어려웠던 이유는?
  3. 2단계 페이지 테이블(EPT/NPT)이 해결하는 문제는?
  4. 컨테이너가 VM 보다 빠르게 시작하는 이유와, 대신 약해지는 점은?
  5. 컨테이너 안에서 uname -r 을 실행하면 누구의 커널 버전이 나오는가?

풀이

  1. Type 1 은 하드웨어 위에서 직접 실행되며 VM 들을 관리하고, Type 2 는 일반 호스트 운영체제 위의 애플리케이션으로 실행된다.
  2. 결과가 모드에 따라 달라지는 민감한 명령 중 일부가 사용자 모드에서 트랩을 일으키지 않고 조용히 다르게 동작했기 때문이다. 하이퍼바이저가 개입할 기회가 없었다.
  3. 게스트 가상 주소 → 게스트 물리 주소 → 호스트 물리 주소의 두 단계 변환을 하드웨어가 처리하게 해, 하이퍼바이저가 소프트웨어로 섀도 페이지 테이블을 유지하는 비용을 없앤다.
  4. 게스트 커널을 부팅할 필요 없이 호스트 커널 위에서 프로세스를 하나 시작하는 것뿐이기 때문이다. 대신 모든 컨테이너가 커널을 공유하므로 격리 경계가 커널 시스템 콜 인터페이스 전체로 넓어져 VM 보다 약하다.
  5. 호스트(노드)의 커널 버전이 나온다. 컨테이너는 자기 커널이 없다.

더 읽을거리 (References)