Skip to content

12.1 가상화와 컨테이너 — 격리를 어느 계층에서 사는가 ​

하이퍼바이저는 하드웨어 전체를 게스트 커널 수만큼 다시 만들어 내고, 컨테이너는 커널을 하나만 둔 채 프로세스마다 다른 시야와 다른 자원 한도를 준다 — 같은 "격리"라는 말이 실제로는 전혀 다른 위치에서 이뤄진다. 이 문서는 8.1 프로세스와 스케줄링의 태스크·컨텍스트 스위치와 8.2 가상 메모리의 페이지 테이블·TLB 위에 하이퍼바이저와 컨테이너가 각각 무엇을 더 쌓는지 추적하고, 두 격리 모델을 커널 공유 여부·공격 표면·오버헤드로 정면 비교한다.

학습 목표 ​

  • 하이퍼바이저의 CPU·메모리·I/O 가상화 기법이 각각 무엇을 얻고 무엇을 포기하는지 설명한다.
  • Linux namespace 7종과 cgroup이 "무엇을 보여줄 것인가"와 "얼마나 쓰게 할 것인가"라는 서로 다른 문제를 어떻게 나눠 푸는지 구분한다.
  • OCI runtime-spec, 저수준·고수준 컨테이너 런타임, 이미지 layer·overlayfs의 역할 분리를 설명한다.
  • 하이퍼바이저와 컨테이너의 격리를 커널 공유·공격 표면·오버헤드 관점에서 비교해 워크로드에 맞는 격리 계층을 판단한다.

배경: 왜 이것이 존재하는가 ​

8.1과 8.2가 세운 모델은 "커널 하나가 여러 프로세스를 격리한다"는 전제 위에 있다. 이 전제 자체가 부족해지는 두 상황이 있고, 그 두 상황에 대한 답이 이 문서의 두 절이다.

첫째, 서로 다른 커널을 한 물리 머신에서 동시에 돌려야 하는 경우다. 한 팀은 특정 커널 버전에 고정된 레거시 워크로드를 돌리고, 다른 팀은 최신 커널이 필요하다. 또는 클라우드 사업자처럼 서로 신뢰하지 않는 여러 고객에게 "당신만의 컴퓨터"를 팔아야 한다. 프로세스 격리는 커널을 공유하는 한 커널 자체를 나눠주지 못한다. 답은 커널 아래에 한 겹을 더 넣어 하드웨어 자체를 게스트 수만큼 복제하는 것 — 하이퍼바이저(hypervisor)다.

둘째, 커널은 하나로 충분하지만 격리 단위가 프로세스보다 조금 더 필요한 경우다. 웹 서버 프로세스 하나, 배치 작업 하나를 격리하자고 매번 커널 전체를 새로 부팅하는 것(자체 메모리 관리자, 자체 디바이스 드라이버 스택, 수 초의 부팅 시간)은 목적에 비해 과하다. 답은 반대 방향이다. 커널은 하나만 두고, 그 위에서 프로세스마다 보는 것(namespace)과 쓸 수 있는 양(cgroup)만 나눈다 — 컨테이너(container)다.

두 답은 같은 문제("한 머신을 여러 워크로드가 안전하게 나눠 쓰게 하기")를 정반대 위치에서 푼다. 하이퍼바이저는 8.1이 말한 "제한적 직접 실행"의 경계를 하드웨어 명령어 수준까지 밀어 올려 하드웨어 전체를 게스트 커널 개수만큼 복제하고, 컨테이너는 그 경계를 그대로 둔 채(운영체제는 하나) 커널 자료구조 안에 "이 태스크 집합에게는 다른 것을 보여준다"는 필터를 추가한다.

text
하이퍼바이저 방식                     컨테이너 방식
   App                                  App A     App B     App C
   Guest OS A   (다른 커널일 수 있음)      └────┬────┘  ← 그냥 프로세스, 커널이 아니다
 ─────────────────────────           ─────────────────────────
      하이퍼바이저 (VMM)                namespace(시야) + cgroup(한도)
 ─────────────────────────           ─────────────────────────
        물리 하드웨어                       호스트 커널 (단 하나)
                                     ─────────────────────────
                                            물리 하드웨어

이 위치 차이 — 격리를 하드웨어 재현으로 만드는가, 커널 공유 위의 필터로 만드는가 — 가 이 문서 전체를 관통하는 축이다.

핵심 개념 ​

하이퍼바이저 — 하드웨어를 게스트 수만큼 복제한다 ​

Type 1과 Type 2 — 하이퍼바이저는 무엇 위에서 도는가 ​

전통적인 구분(Goldberg, 1974)은 하이퍼바이저가 물리 하드웨어 위에서 직접 도는지(Type 1, native/bare-metal — Xen, VMware ESXi, Microsoft Hyper-V), 아니면 범용 호스트 운영체제 위의 응용 프로그램으로 도는지(Type 2, hosted — VirtualBox, VMware Workstation, macOS의 Parallels·UTM)를 가른다. Type 1은 호스트 OS의 스케줄링·자원 관리를 거치지 않고 게스트에 자원을 직접 배분하므로 오버헤드가 낮아 데이터센터의 표준이고, Type 2는 설치와 사용이 쉬워 데스크톱 가상화에 흔하지만 게스트의 모든 자원 요청이 호스트 OS라는 한 겹을 더 거친다.

이 구분이 항상 깔끔하지는 않다. Linux의 KVM(Kernel-based Virtual Machine)은 커널 모듈로 적재되어 Linux 커널 자체를 하이퍼바이저로 바꾸고, 각 게스트는 QEMU라는 평범한 Linux 프로세스로 보이면서 그 프로세스가 VT-x/AMD-V 하드웨어 모드에 진입한 구간만 "게스트를 실행 중"이 된다. 범용 호스트 OS(Linux)가 있다는 점에서는 Type 2처럼 보이지만, 실제 특권 연산을 처리하는 커널 구성 요소는 Type 1처럼 동작한다 — 이 taxonomy는 1970년대 메인프레임을 기준으로 만들어졌고, KVM 같은 설계는 그 경계를 흐린다는 점만 정확히 알아 두면 된다.

CPU 가상화 — 특권 명령을 누가 가로채는가 ​

게스트 커널은 자신이 링 0(커널 모드)에서 도는 줄 알고 페이지 테이블 교체, 인터럽트 마스크 조작 같은 특권 명령을 실행한다. 그런데 실제 하드웨어의 링 0은 하이퍼바이저가 차지해야 한다 — 그렇지 않으면 게스트가 다른 게스트나 호스트의 메모리를 건드릴 수 있다.

고전적인 해법이 **트랩-앤-에뮬레이트(trap-and-emulate)**다. 게스트를 낮은 특권 수준에서 실행시키고, 특권 명령을 실행하려는 시도를 하드웨어가 자동으로 트랩해 하이퍼바이저로 넘기면, 하이퍼바이저가 그 효과만 소프트웨어로 흉내 낸 뒤 게스트를 재개한다. Popek과 Goldberg(1974)는 이 방식이 성립하려면 "게스트의 실제 물리 자원 상태에 영향을 주거나 그 상태에 따라 동작이 달라지는(sensitive) 모든 명령이 반드시 트랩한다"는 조건이 필요함을 형식화했다.

문제는 2005년 이전의 x86이 이 조건을 만족하지 못했다는 것이다. Robin과 Irvine(2000)의 분석에 따르면 SGDT, POPF처럼 특권 없이 실행 가능하면서도 게스트가 진짜 링 0에 있는지 아닌지에 따라 다르게 동작하는 명령이 다수 있었다 — 이들은 트랩하지 않고 조용히 틀린 결과를 낸다. 순수 트랩-앤-에뮬레이트가 x86에서 불가능했던 이유가 이것이다. 두 우회로가 하드웨어 지원 이전에 등장했다.

  • 바이너리 변환(binary translation): VMware가 채택한 방식. 게스트 커널 코드를 실행 직전에 스캔해 트랩하지 않는 민감한 명령을 트랩하는 안전한 동등 코드로 동적으로 바꿔치기한다. 게스트 OS를 수정할 필요가 없다는 것이 장점이고, 번역·캐싱 비용이 대가다.
  • 반가상화(paravirtualization): Xen이 채택한 방식. 게스트 OS의 소스 코드 자체를 수정해 민감한 연산을 하이퍼콜(hypercall)로 하이퍼바이저에 직접 요청하게 만든다. 트랩이 필요 없어 오버헤드가 낮지만, 게스트가 자신이 가상화되고 있음을 알아야 하므로 손대지 않은 상용 OS 이미지를 그대로 쓸 수 없다.

2005~2006년 Intel VT-x·AMD-V가 이 문제를 하드웨어에서 근본적으로 풀었다. CPU에 새 실행 모드(VMX root/non-root)를 추가해, 게스트는 자신이 기대하는 특권 수준(링 0 포함)에서 non-root 모드로 그대로 돌고, 미리 정의한 이벤트(I/O 포트 접근, 특정 명령 실행, 인터럽트 등)만 자동으로 VM exit을 일으켜 root 모드의 하이퍼바이저로 제어를 넘긴다. 게스트 상태 전체(VMCS, Virtual Machine Control Structure)를 저장·복원하는 world switch가 있어 exit·entry 자체에 고정 비용이 들지만, 바이너리 변환의 번역 비용도 반가상화의 게스트 수정 요구도 없이 손대지 않은 OS를 트랩-앤-에뮬레이트로 온전히 돌릴 수 있게 됐다.

기법얻는 것포기하는 것
바이너리 변환손대지 않은 게스트 OS커널 코드 번역·캐싱 오버헤드
반가상화트랩 없는 낮은 오버헤드게스트 OS 수정 필요(임의 이미지 불가)
하드웨어 지원(VT-x/AMD-V)손대지 않은 게스트 OS + 트랩 기반 정확성VM exit/entry의 world switch 비용(exit이 잦으면 누적)

메모리 가상화 — 주소 변환이 한 겹 더 늘어난다 ​

8.2에서 프로세스의 가상 주소는 페이지 테이블을 거쳐 물리 주소가 된다고 했다. 가상화 환경에서는 게스트 OS도 자신이 "물리 주소"를 관리한다고 믿지만, 그 주소는 실제 물리 메모리가 아니라 하이퍼바이저가 배정한 또 하나의 가상 계층이다. 그래서 변환이 두 단계가 된다.

text
guest 가상 주소
   │  guest 페이지 테이블 (게스트 OS가 관리)
   ▼
guest "물리" 주소     ← 게스트에게는 물리 주소처럼 보이지만 실제로는 아니다
   │  EPT/NPT 또는 shadow page table (하이퍼바이저가 관리)
   ▼
host 물리 주소

초기 해법은 shadow page table이다. 하이퍼바이저가 게스트 가상 주소를 호스트 물리 주소로 직접 연결하는 그림자 테이블을 별도로 유지하고, 하드웨어 MMU는 이 그림자 테이블만 걷는다(변환은 한 단계로 빠르다). 문제는 동기화다. 게스트가 자신의 페이지 테이블을 수정할 때마다 그림자 테이블도 갱신해야 하므로, 하이퍼바이저는 게스트 페이지 테이블 페이지를 쓰기 금지로 표시해 두고 게스트가 거기 쓰려 할 때마다 폴트를 받아 그림자를 갱신한 뒤 재개한다. 프로세스 생성이 잦은 워크로드(빌드 서버, 컴파일 파이프라인)처럼 페이지 테이블 변경이 잦으면 이 트랩이 성능을 크게 깎는다.

EPT(Intel, Extended Page Tables)와 NPT(AMD, Nested Page Tables)는 이 동기화 트랩을 하드웨어로 없앤다. MMU가 게스트 페이지 테이블(guest 가상 → guest "물리")과 하이퍼바이저의 EPT/NPT(guest "물리" → host 물리)를 둘 다 하드웨어에서 직접 걷는다 — 게스트가 자기 페이지 테이블을 바꿔도 트랩이 필요 없다. 대가는 TLB 미스 시 걸어야 할 경로가 깊어진다는 것이다. 4단계 게스트 테이블의 각 단계마다 그 항목의 위치 자체를 다시 4단계 EPT/NPT로 변환해야 하므로, 이론상 최악의 경우 페이지 워크 한 번이 20회 안팎의 메모리 접근으로 늘어날 수 있다(중첩된 4단계×4단계 워크). 최신 CPU는 8.2의 PCID와 같은 원리로 TLB 항목에 VPID(Intel)·ASID(AMD) 태그를 붙여, 게스트를 전환해도 TLB 전체를 무효화하지 않게 해 이 비용의 일부를 흡수한다.

정리하면 shadow page table은 변환 깊이는 얕지만 동기화 트랩이 잦고, EPT/NPT는 트랩이 없지만 미스 시 워크가 깊다. 프로세스 생성이 잦은 오늘날의 일반 워크로드에서는 EPT/NPT가 대체로 우세해 현재 주류다.

I/O 가상화 — 장치를 공유하는 세 가지 정도 ​

게스트는 디스크와 NIC을 혼자 쓰는 것처럼 기대하지만 실제 장치는 여러 게스트가 나눠 써야 한다. 세 가지 접근이 컴패트빌리티와 처리량을 다르게 교환한다.

  • 완전 에뮬레이션: 하이퍼바이저가 실제 장치(예: Intel e1000 NIC, IDE 컨트롤러)의 레지스터 동작을 소프트웨어로 그대로 흉내 낸다. 게스트는 그 실제 장치용 기존 드라이버를 손대지 않고 그대로 쓴다 — 호환성은 최고다. 대가는 레지스터 접근 하나하나가 VM exit이 될 수 있다는 것이고, I/O가 잦은 워크로드에서는 이 트랩이 누적되어 가장 느리다.
  • 반가상화 I/O(virtio): 게스트에 하이퍼바이저를 인지하는 전용 드라이버(virtio-net, virtio-blk)를 설치하고, 공유 메모리 링 버퍼(virtqueue)로 여러 요청을 묶어 알린다. 레지스터 접근마다 트랩하는 대신 배치로 통지하므로 오버헤드가 훨씬 낮지만, 실제 하드웨어를 흉내 내는 것이 아니라서 게스트가 virtio 드라이버를 갖추고 있어야 한다(현재 주요 Linux·Windows는 기본 포함).
  • SR-IOV(Single Root I/O Virtualization): 물리 장치 자체가 여러 개의 가벼운 가상 기능(VF, virtual function)을 노출하고, 이를 IOMMU(Intel VT-d·AMD-Vi)의 DMA·인터럽트 재매핑을 거쳐 게스트에 직접 배정(passthrough)한다. 데이터 경로가 하이퍼바이저를 완전히 우회하므로 거의 물리 하드웨어 수준의 처리량을 얻지만, 게스트 드라이버가 특정 물리 장치 벤더에 종속되고, VF의 하드웨어 상태를 다른 물리 호스트로 옮기기 어려워 라이브 마이그레이션이 깨지거나 복잡해진다.
기법얻는 것포기하는 것
완전 에뮬레이션손대지 않은 게스트 드라이버레지스터 접근마다 트랩 가능 — 가장 느림
virtio트랩 배치로 낮은 오버헤드게스트에 virtio 드라이버 필요
SR-IOV거의 네이티브 처리량하드웨어 종속, 마이그레이션 제약

컨테이너 — 커널 하나를 여럿으로 보이게 한다 ​

컨테이너는 새로운 실행 단위가 아니다. 8.1이 세운 태스크(task) 그대로이고, 커널 스케줄러는 그 태스크가 "컨테이너 안"에 있는지 전혀 모른다. 컨테이너가 추가하는 것은 정확히 두 가지다. namespace는 그 태스크가 커널 자원을 어떤 시야로 보는지를 바꾸고, cgroup은 그 태스크(들)가 얼마나 쓸 수 있는지를 제한·계량한다. 이 두 문제는 독립적이다 — namespace 없이 cgroup만 걸 수도(전통적인 systemd 자원 제어), cgroup 없이 namespace만 걸 수도(초기 chroot 계열 격리) 있고, 컨테이너 런타임은 이 둘을 함께 걸어 쓴다.

이 저장소는 macOS에서 집필되며, 이 환경에는 Docker나 Linux VM이 없어(사전 확인됨) 아래 unshare·nsenter·/sys/fs/cgroup 명령을 이 문서를 쓰는 과정에서 직접 실행해 검증하지 못했다. 아래 명령과 출력 예시는 Linux man-pages·kernel.org 문서에 서술된 동작을 근거로 한 예상값이며, 실측이 아니다. Linux 환경(실기 또는 VM)에서 실행하면 이 서술과 같은 결과를 기대할 수 있다.

namespace — 커널 자원마다 다른 시야를 준다 ​

clone(2)(또는 unshare(2))에 CLONE_NEW* 플래그를 주면 호출한 태스크가 해당 자원의 새 namespace로 옮겨진다. 8.1에서 이미 본 것처럼 clone은 프로세스와 스레드 생성 둘 다에 쓰는 같은 시스템 콜이다 — namespace는 그 공유 플래그 목록에 항목이 몇 개 더 늘어난 것뿐이다.

namespace격리 대상컨테이너에게 주는 것
pid프로세스 ID 공간자기만의 PID 1, 호스트 프로세스 목록은 보이지 않음(호스트에서는 전체 계층이 그대로 보임)
net네트워크 인터페이스·라우팅 테이블·포트독립된 IP·포트 공간. 보통 veth 가상 인터페이스 쌍으로 호스트 브리지에 연결
mnt마운트 테이블독립된 루트 파일시스템 시야. 컨테이너 이미지를 루트처럼 보여주는 기반
uts호스트명·도메인명컨테이너마다 다른 hostname
ipcSystem V IPC·POSIX 메시지 큐서로 다른 컨테이너의 IPC 키·세마포어 충돌 방지
userUID/GID 매핑컨테이너 안의 root(UID 0)를 호스트의 비특권 UID로 매핑
cgroupcgroup 계층 자체의 시야컨테이너 안에서 자신이 속한 cgroup을 계층의 루트처럼 보이게 함

readlink /proc/self/ns/pid, readlink /proc/self/ns/net 같은 링크는 태스크가 속한 각 namespace의 식별자(inode 번호)를 보여준다. 서로 다른 컨테이너의 두 태스크가 같은 pid namespace 링크를 가리키면 같은 pid namespace를 공유한다는 뜻이고(같은 컨테이너 또는 명시적으로 네트워크를 공유하는 sidecar 패턴), 값이 다르면 격리돼 있다는 뜻이다.

namespace가 격리하지 않는 것이 이 절의 핵심이다. 커널 자체 — 시스템 콜 테이블, 커널 버전, 대부분의 /sys 계층, 커널 내부 자료구조 — 는 모든 컨테이너가 그대로 공유한다. user namespace만이 유일하게 권한의 의미 자체를 바꾼다. 컨테이너 안의 UID 0("root")를 호스트의 비특권 UID(예: 100000)에 매핑하면, 설령 컨테이너 안에서 root 권한으로 무언가를 탈출시키는 데 성공하더라도 호스트에서는 비특권 사용자일 뿐이다 — Podman의 기본 동작이자 Docker의 rootless 모드가 기대는 보안 경계가 이것이다. 그러나 이 매핑을 켜지 않았거나(전통적인 Docker 기본 설정은 컨테이너 root가 호스트 UID 0에 그대로 매핑된다) 컨테이너 런타임·커널 자체에 버그가 있다면, namespace의 시야 분리는 공격자가 그 뒤에 있는 공유 커널에 도달하는 것을 막지 못한다.

cgroup — 시야가 아니라 사용량을 통제한다 ​

cgroup(control group)은 namespace와 직교하는 문제를 푼다. 태스크 집합이 CPU·메모리·I/O를 얼마나 쓰는지 제한하고 계량한다.

v1은 컨트롤러(cpu, memory, blkio 등)마다 독립된 계층을 마운트한다. 한 태스크가 "cpu 트리"에서는 그룹 A에, "memory 트리"에서는 그룹 B에 속할 수 있어 유연하지만, "이 컨테이너의 cgroup은 무엇인가"라는 질문에 컨트롤러마다 다른 답이 나올 수 있어 도구(초기 systemd, Docker)가 일관되게 다루기 어려웠다.

v2(커널 4.5+로 안정화, 현재 대부분의 배포판 기본값)는 계층을 하나로 통일한다. 모든 컨트롤러가 같은 트리 위에서 켜고 끄며(cgroup.subtree_control), "컨트롤러가 켜진 자식이 있는 cgroup은 직접 프로세스를 담을 수 없다"는 no-internal-process 규칙으로 자원 분배 지점을 트리의 정해진 위치로 좁힌다. 8.1이 예고했던 "컨테이너에서 코어 수는 거짓말을 한다"와 8.2가 예고했던 "컨테이너의 메모리 제한과 OOM"이 정확히 v2의 다음 두 컨트롤러 메커니즘이다.

  • CPU (cpu.max): "$MAX $PERIOD"(마이크로초) 형식으로, 예를 들어 "50000 100000"은 100ms 주기마다 이 cgroup의 모든 태스크를 합쳐 최대 50ms만 실행을 허용한다는 뜻이다(코어 0.5개 분량). 태스크들이 주기 안에서 할당량을 다 쓰면 남은 시간 동안 runnable이어도 스케줄러가 실행시키지 않는다 — 8.1의 nonvoluntary 스위치와 겉보기 증상은 비슷하지만 원인은 다르다(코어 경합이 아니라 quota 소진). cpu.stat의 nr_throttled(스로틀된 횟수)와 throttled_usec(스로틀된 누적 시간)로 구분할 수 있다. 호스트 CPU가 한가한데도 컨테이너 안 요청이 주기적으로 멈칫한다면 이 카운터부터 확인해야 한다.
  • 메모리 (memory.max, memory.high): memory.max는 이 cgroup이 넘을 수 없는 하드 리밋이고, 넘기면 cgroup 범위의 OOM killer가 발동해 이 cgroup 내부에서만 희생자를 고른다 — 8.2에서 예고한 "호스트는 여유로운데 컨테이너만 OOM kill되는" 현상이 이것이다. memory.high는 하드 리밋보다 낮은 소프트 한계로, 넘으면 즉시 죽이지 않고 그 cgroup에 회수 압박을 걸어 먼저 줄어들 기회를 준다.
  • I/O (io.max): 장치별로 읽기·쓰기 대역폭(rbps/wbps)과 IOPS(riops/wiops) 상한을 건다.

v2는 시스템 전체 PSI(/proc/pressure/*, 8.1·8.2에서 다룬)를 cgroup 단위로도 노출한다(<cgroup>/cpu.pressure, memory.pressure, io.pressure). 시스템 전체 PSI가 정상이어도 특정 컨테이너의 PSI만 높을 수 있고, 이것이 "이 컨테이너만 왜 느린가"를 시스템 전체 지표보다 정확하게 좁혀 준다.

cgroup은 격리를 만들지 않는다. namespace로 시야를 나눈 컨테이너라도 cgroup 한도가 없으면 호스트의 다른 프로세스를 굶길 수 있고(noisy neighbor), 반대로 cgroup으로 자원만 나눈 프로세스는 namespace가 없으면 여전히 호스트의 전체 프로세스 목록·네트워크를 볼 수 있다. 컨테이너는 이 둘을 항상 함께 걸어서 얻는 조합이다.

컨테이너 런타임 — 역할이 나뉜 두 계층 ​

"컨테이너를 실행한다"는 하나의 동작처럼 보이지만 표준화된 책임 분리가 있다.

OCI(Open Container Initiative)는 두 스펙을 정의한다. image-spec은 이미지의 형식(레이어 목록, manifest, config)을 정의하고, runtime-spec은 "이 번들(config.json + rootfs 디렉터리)을 컨테이너로 실행하라"는 상태 기계(create → start → kill → delete)를 정의한다.

  • 저수준 런타임: runc(runtime-spec의 참조 구현)가 대표적이다. clone으로 namespace를 걸고, cgroup에 태스크를 등록하고, pivot_root로 루트 파일시스템을 바꾼 뒤 목표 프로세스를 exec한다 — 이 문서의 앞 두 절에서 다룬 커널 메커니즘을 실제로 호출하는 얇은 계층이다. crun(C로 구현해 더 가볍다), gVisor의 runsc, Kata Containers의 런타임처럼 같은 runtime-spec 인터페이스를 다른 격리 메커니즘으로 구현한 대안도 있다(뒤의 비교 절에서 다룬다).
  • 고수준 런타임: containerd, CRI-O가 대표적이다. 이미지 pull·압축 해제·저장, 네트워크 설정, 생명 주기 관리를 맡고 Kubernetes의 CRI(Container Runtime Interface) 같은 API를 노출하며, 실제 "네임스페이스를 걸고 프로세스를 실행하는" 단계는 저수준 런타임에 위임한다. Docker Engine도 내부적으로 containerd 위에 얹혀 있다.

이 분리의 근거는 관심사 분리다. 보안에 결정적인 부분(실제 격리 syscall 호출)은 작고 감사하기 쉬운 저수준 런타임에 두고, 복잡하지만 상대적으로 덜 민감한 부분(이미지·스토리지·네트워크 관리)은 고수준 데몬에 둔다. 저수준 런타임을 gVisor·Kata 같은 다른 구현으로 바꿔 끼워도 고수준 런타임과 오케스트레이터(Kubernetes)는 그대로 쓸 수 있는 것이 이 계층 분리의 실질적 이점이다.

이미지 layer와 overlayfs — 파일시스템도 겹쳐 쌓는다 ​

OCI 이미지는 순서가 있는 레이어(layer) 스택과 그 레이어들의 다이제스트 목록을 담은 manifest, 실행 설정(entrypoint, 환경 변수 등)을 담은 config로 구성된다. 각 레이어는 내용 기반 주소(콘텐츠의 해시값)로 식별되므로, 여러 이미지가 같은 베이스 레이어를 공유하면 그 레이어는 저장소에 한 번만 저장되고 한 번만 내려받으면 된다.

런타임은 이 레이어들을 실제로 복사해 합치지 않고 overlayfs(Linux 커널의 union 파일시스템)로 겹쳐서 하나의 마운트 지점으로 보여준다. 읽기 전용인 이미지 레이어들(lowerdir, 여러 개를 순서대로 쌓을 수 있다)과 이 컨테이너 전용의 쓰기 가능한 계층(upperdir) 하나를 겹쳐 merged 지점을 만든다. 읽기는 위에서부터 아래로 순서대로 찾아 먼저 발견된 레이어의 파일을 반환하고, 쓰기는 copy-up을 거친다 — 하위 레이어에만 있는 파일을 수정하려 하면 먼저 그 파일 전체를 upperdir로 복사한 뒤 그 복사본을 고친다. 삭제는 실제로 하위 레이어의 파일을 지우는 대신 "whiteout" 항목을 상위 레이어에 남겨 하위 항목을 가린다.

여기서 8.2의 copy-on-write와의 대비가 정확하다. fork의 CoW는 4KiB 페이지 단위로 지연 복사가 일어나 쓴 만큼만 비용을 낸다. overlayfs의 copy-up은 파일 전체 단위다. 베이스 이미지 레이어에 있는 수 GiB짜리 파일의 단 1바이트를 컨테이너가 고치려 해도, 그 파일 전체가 upperdir로 복사된 뒤에야 수정이 일어난다. 큰 로그 파일이나 데이터 파일을 베이스 이미지 안에 두고 컨테이너에서 append하는 패턴이 예상보다 훨씬 느리고 디스크를 많이 쓰는 이유가 여기 있다 — 해법은 그런 파일을 이미지 레이어가 아니라 볼륨(호스트 마운트)에 두는 것이다.

두 격리 모델을 정면으로 비교한다 ​

축하이퍼바이저(VM)컨테이너
격리 단위게스트마다 독립된 커널호스트 커널 하나를 공유, namespace로 시야만 분리
호스트에 대한 공격 표면하이퍼바이저 코드 + 에뮬레이트된 가상 장치(비교적 작고 검증된 코드)호스트 커널의 전체 syscall 표면(namespace·seccomp·LSM으로 일부만 축소)
시작 비용초 단위(커널 부팅)밀리초 단위(exec 하나)
자원 오버헤드게스트마다 커널·드라이버 스택 중복(GiB 단위)namespace·cgroup 메타데이터뿐(MiB 이하)
커널 버전·종류게스트마다 다른 OS·커널 가능호스트와 같은 커널 버전을 강제로 공유
마이그레이션라이브 마이그레이션이 성숙한 기술(VM 상태 전체 이전)checkpoint/restore(CRIU 등)는 존재하나 상대적으로 덜 성숙

이 표의 첫 두 행이 실무적으로 가장 중요하다. 컨테이너의 격리는 namespace·cgroup·seccomp·Linux Security Module(AppArmor, SELinux)을 아무리 겹쳐도 결국 하나의 커널에 기댄다. 그 커널 자체(namespace 처리 코드를 포함해서)에 있는 권한 상승 버그는 이론적 가능성이 아니라 실제로 반복 보고돼 온 취약점 계열이다 — 컨테이너 런타임이나 커널의 구현 결함이 namespace 경계를 우회해 호스트나 다른 컨테이너에 닿은 사례들이 실제로 있었다. 반면 하이퍼바이저의 공격 표면은 훨씬 작다. 게스트 커널의 버그는 그 게스트 안에 갇히고, 탈출하려면 하이퍼바이저 자체나 에뮬레이트된 장치의 버그를 뚫어야 한다 — 여전히 발생 가능하지만(에뮬레이트된 장치 코드의 버그로 인한 VM escape도 보고된 바 있다), 공유하는 코드의 양 자체가 훨씬 적다.

"컨테이너는 VM보다 안전하지 않다"와 "컨테이너도 격리는 격리다"는 둘 다 불완전한 결론이다. 정확한 문장은 **"컨테이너는 커널 하나를 신뢰 경계로 삼고, VM은 하이퍼바이저 하나를 신뢰 경계로 삼는다. 신뢰 경계가 작을수록(공유하는 코드가 적을수록) 그 경계를 뚫을 방법도 적어진다"**는 것이다.

이 스펙트럼의 중간 지점을 노리는 설계도 있다. gVisor는 컨테이너처럼 빠르게 시작하지만, 컨테이너의 프로세스가 호스트 커널에 syscall을 직접 던지는 대신 사용자 공간에 구현된 커널(Sentry)이 그 syscall을 가로채 처리한다 — 호스트 syscall 표면을 그만큼 줄이는 대가로 syscall 프록시 오버헤드를 지불한다. Kata Containers·Firecracker 기반 microVM은 반대 방향에서 접근한다. 컨테이너 이미지·오케스트레이션 인터페이스는 그대로 쓰되, 그 뒤에서 최소한의 장치 모델만 가진 가벼운 VM을 하이퍼바이저 수준 격리로 띄워 컨테이너에 가까운 시작 속도를 낸다. 둘 다 "컨테이너의 속도와 VM의 격리를 동시에 원한다"는 요구가 이분법이 아니라 스펙트럼임을 보여주는 실제 답이다.

실무 관점 ​

"cgroup 스로틀링"과 "OOM kill"은 컨테이너 고유의 진단 대상이다 ​

8.1의 스레드 풀 논리(CPU 바운드는 코어 수 기준)를 컨테이너에 그대로 적용하면 틀린다. 컨테이너 안의 nproc이나 런타임의 "가용 프로세서 수"는 cgroup CPU quota를 무시하고 호스트 코어 수를 반환할 수 있다. 2코어 quota 컨테이너가 호스트의 32코어 기준으로 스레드 풀을 잡으면, CPU 사용률 지표는 낮아 보여도 cpu.stat의 nr_throttled가 계속 오르며 주기적인 지연 스파이크가 난다 — 코어를 늘려도 소용없고, quota를 늘리거나 애플리케이션의 동시성 설정을 quota에 맞춰야 한다. 메모리도 마찬가지로, 호스트의 free가 여유로워도 memory.max에 닿은 cgroup은 그 안에서만 OOM killer가 발동한다.

격리 계층 선택은 신뢰 경계의 크기로 판단한다 ​

같은 커널을 신뢰할 수 있는 자기 팀의 여러 서비스라면 컨테이너의 낮은 오버헤드가 이긴다. 서로 신뢰하지 않는 테넌트의 임의 코드를 실행해야 한다면(멀티테넌트 SaaS, 서드파티 플러그인 실행) 신뢰 경계를 하이퍼바이저 수준으로 끌어올리거나(전통적 VM), 컨테이너 인터페이스를 유지하면서 실제 격리는 강화하는 gVisor·Kata 계열을 검토할 근거가 생긴다. "컨테이너가 더 가볍다"는 이유만으로 신뢰하지 않는 코드를 일반 컨테이너에 태우는 것은 오버헤드를 아끼는 대신 공격 표면을 호스트 커널 전체로 넓히는 거래다.

더 깊이: VM 라이브 마이그레이션과 컨테이너 checkpoint/restore ​

VM의 라이브 마이그레이션은 CPU 레지스터, 메모리 페이지, 장치 상태를 포함한 게스트 전체를 하이퍼바이저가 캡처해 다른 물리 호스트로 옮기고 재개하는, 수십 년간 다듬어진 성숙한 기능이다(반복적 메모리 dirty-page 전송으로 다운타임을 밀리초 단위까지 줄인다). 컨테이너의 대응물인 CRIU(Checkpoint/Restore In Userspace)는 프로세스의 메모리·열린 파일 디스크립터·소켓 상태를 덤프해 다른 머신에서 복원하는 것을 목표로 하지만, 컨테이너가 기대는 자원(네트워크 namespace의 연결 상태, 외부 서비스와 맺은 TCP 세션, GPU 같은 특수 장치 상태)이 다양할수록 완전한 복원이 어려워진다. 이 성숙도 차이는 앞선 비교표의 "마이그레이션" 행이 왜 그렇게 갈리는지를 설명하는 구체적인 근거다 — 격리 단위가 하드웨어 전체(VM)인가 커널 자료구조 몇 개(컨테이너)인가의 차이가, 그 상태를 통째로 옮기는 난이도 차이로 그대로 이어진다.

정리 ​

  • 하이퍼바이저는 하드웨어를 게스트 수만큼 복제해 서로 다른 커널을 격리하고, 컨테이너는 커널 하나를 공유한 채 namespace(시야)와 cgroup(한도)으로 프로세스를 나눈다 — 격리를 사는 계층이 다르다.
  • CPU 가상화는 트랩-앤-에뮬레이트가 원칙이고, x86의 예외를 바이너리 변환·반가상화로 우회하다가 VT-x/AMD-V로 하드웨어에서 풀었다. 메모리 가상화는 shadow page table(동기화 트랩)과 EPT/NPT(깊어진 워크) 사이의 트레이드오프다. I/O 가상화는 완전 에뮬레이션·virtio·SR-IOV가 호환성과 처리량을 다르게 교환한다.
  • Linux namespace(pid·net·mnt·uts·ipc·user·cgroup)는 커널 자원의 시야를 나누고, cgroup v1/v2는 CPU·메모리·I/O 사용량을 제한·계량한다 — 둘은 독립적인 메커니즘이며 컨테이너는 이 둘을 함께 쓴다.
  • OCI runtime-spec/image-spec이 컨테이너 실행과 이미지 형식을 표준화하고, 저수준 런타임(runc 등)과 고수준 런타임(containerd 등)이 보안이 중요한 부분과 관리가 복잡한 부분을 분리한다. 이미지는 content-addressed 레이어를 overlayfs로 겹쳐 보여주며, copy-up은 페이지가 아니라 파일 단위다.
  • 컨테이너와 VM의 진짜 차이는 신뢰 경계의 크기다 — 컨테이너는 호스트 커널 전체를, VM은 하이퍼바이저를 신뢰 경계로 삼는다. gVisor·Kata·microVM은 이 경계 크기를 조정하는 중간 지점이다.

확인 문제 ​

1. 동료가 "컨테이너는 namespace로 완전히 격리되니 VM만큼 안전하다"고 주장한다. 이 주장의 빈틈을 커널 공유 관점에서 지적하라.

정답과 해설

namespace는 커널 자원의 시야만 나눌 뿐 커널 자체를 복제하지 않는다. 모든 컨테이너는 여전히 하나의 호스트 커널의 syscall 표면을 공유하고, 그 커널(또는 컨테이너 런타임)의 구현 결함이 namespace 경계를 우회하면 호스트나 다른 컨테이너에 영향을 줄 수 있다. VM은 게스트마다 독립된 커널을 가지므로 한 게스트 커널의 버그가 다른 게스트나 호스트에 직접 닿지 않는다 — 신뢰 경계가 하이퍼바이저 자체로 좁혀진다. "안전하다"는 정도의 문제이지 이분법이 아니며, 신뢰하지 않는 코드를 실행해야 한다면 이 신뢰 경계의 크기 차이가 VM이나 gVisor·Kata 같은 대안을 선택하는 근거가 된다.

2. 빌드 서버(컴파일·링크로 프로세스 생성이 매우 잦음)를 VM에서 돌리는데, shadow page table 방식의 하이퍼바이저에서 EPT/NPT 지원 하이퍼바이저로 옮기니 체감 성능이 크게 개선됐다. 이 관찰을 메모리 가상화 모델로 설명하라.

정답과 해설

shadow page table은 게스트가 자신의 페이지 테이블을 수정할 때마다(프로세스 생성·종료마다 빈번히 발생) 그림자 테이블과의 동기화를 위해 VM exit을 일으킨다. 프로세스 생성이 잦은 빌드 워크로드는 이 동기화 트랩이 대량으로 발생해 병목이 된다. EPT/NPT는 게스트 페이지 테이블과 EPT/NPT를 하드웨어가 동시에 걷기 때문에 게스트의 페이지 테이블 수정에 트랩이 필요 없다 — 이 워크로드에서 지배적이던 비용이 사라진다. 대신 TLB 미스 시 페이지 워크가 깊어지는 비용을 지불하지만, VPID/ASID로 TLB 무효화 빈도를 낮춘 최신 하드웨어에서는 동기화 트랩 제거의 이득이 이를 능가하는 경우가 많다.

3. 컨테이너 이미지의 베이스 레이어에 10GiB짜리 정적 데이터 파일이 포함돼 있다. 애플리케이션이 이 파일의 마지막에 한 줄을 append하는 로그처럼 사용했더니, 컨테이너 시작 후 첫 쓰기에서 수 초의 지연과 디스크 사용량 급증이 관찰됐다(개념 설명 문제이며 이 저장소에서 실측하지 않았다). 원인과 대안을 제시하라.

정답과 해설

overlayfs의 copy-up은 파일 단위로 일어난다. 하위(이미지) 레이어에만 있는 파일에 처음 쓰려는 순간 그 파일 전체가 상위(컨테이너 쓰기 계층)로 복사된 뒤에야 수정이 반영된다. 10GiB 파일의 한 줄만 바꾸려 해도 10GiB 전체가 복사되므로 지연과 디스크 사용량이 급증한다. 8.2의 fork CoW가 4KiB 페이지 단위로 쓴 만큼만 비용을 내는 것과 대조적이다. 대안은 그런 파일을 이미지 레이어에 굽지 않고 별도 볼륨(호스트 마운트 또는 명명된 볼륨)에 두어, 애초에 overlayfs의 layer 경계 밖에서 다루는 것이다.

4. 2코어 CPU quota가 걸린 컨테이너에서 애플리케이션이 32코어짜리 호스트 전체를 기준으로 스레드 풀을 구성했다. 호스트 CPU 사용률은 낮은데도 컨테이너 안의 요청 지연이 주기적으로 튄다. 원인을 cgroup 메커니즘으로 설명하고 확인할 지표를 제시하라.

정답과 해설

nproc이나 런타임의 "가용 프로세서 수"가 cgroup CPU quota를 인식하지 못하고 호스트의 물리 코어 수(32)를 반환했을 가능성이 높다. 애플리케이션이 이 값을 기준으로 스레드 풀을 32(±α)로 잡으면 실제로는 2코어(quota) 안에서 16배 oversubscription이 된다. cpu.max의 주기(예: 100ms)마다 할당량(예: 20ms, 0.2코어라면)을 다 쓴 태스크들은 runnable 상태로 남아 다음 주기까지 실행되지 못하는 스로틀링을 겪는다 — 호스트 전체 CPU 사용률은 이 컨테이너의 좁은 quota 때문에 낮게 보일 수 있다. 확인할 지표는 해당 cgroup의 cpu.stat에서 nr_throttled(스로틀된 횟수)와 throttled_usec(누적 스로틀 시간)이며, 이 값이 지연 스파이크 시점과 맞물려 오르면 원인이 확정된다. 해결은 코어를 늘리는 것이 아니라 quota를 인식하는 방식으로 스레드 풀 크기를 quota에 맞추거나 quota 자체를 늘리는 것이다.

참고 자료 ​

  • Gerald J. Popek, Robert P. Goldberg, "Formal Requirements for Virtualizable Third Generation Architectures," Communications of the ACM 17(7), 1974 — 트랩-앤-에뮬레이트가 성립하기 위한 형식적 조건(민감한 명령이 반드시 특권 명령이어야 함)을 정의한 원 논문.
  • John Scott Robin, Cynthia E. Irvine, "Analysis of the Intel Pentium's Ability to Support a Secure Virtual Machine Monitor," USENIX Security Symposium, 2000 — x86이 Popek-Goldberg 조건을 만족하지 못했던 구체적인 명령어들을 분석한 연구.
  • Linux man-pages, namespaces(7) — 7종 namespace의 공식 정의와 각각의 CLONE_NEW* 플래그.
  • Linux man-pages, user_namespaces(7) — UID/GID 매핑과 rootless 컨테이너가 기대는 권한 모델의 1차 자료.
  • Linux man-pages, cgroups(7) — cgroup v1/v2의 공식 개요.
  • kernel.org 문서, Control Group v2 — cpu.max, memory.max/memory.high, io.max, cgroup 단위 PSI 파일의 공식 명세.
  • kernel.org 문서, Overlay Filesystem — lowerdir/upperdir/workdir 마운트 옵션과 copy-up·whiteout 동작의 공식 설명.
  • Open Container Initiative, runtime-spec과 image-spec — 컨테이너 실행 상태 기계와 이미지 형식의 표준 명세.
  • gVisor Documentation — 사용자 공간 커널(Sentry)로 syscall을 가로채는 격리 모델의 1차 자료.
  • Kata Containers Documentation — 컨테이너 인터페이스와 하이퍼바이저 격리를 결합한 microVM 접근의 1차 자료.