포스트

가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝

VM과 컨테이너의 차이 — 하이퍼바이저, 리눅스 namespace와 cgroups, overlayfs, seccomp로 보는 격리의 스펙트럼을 그림으로 정리합니다.

가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝

“둘 다 격리인데, 무엇이 다른가”

docker run nginx는 1초 만에 뜨는데, VM은 부팅에 수십 초가 걸립니다. 둘 다 “격리된 환경”을 준다는데, 한쪽은 깃털처럼 가볍고 한쪽은 묵직합니다. 이 차이를 “도커가 그냥 빠른 기술”로 외우면 컨테이너 탈출(container escape)이 왜 VM 탈출보다 위험한지, AWS Lambda가 왜 컨테이너가 아니라 마이크로 VM 위에서 도는지를 영영 설명하지 못합니다.

핵심 질문 하나로 모든 게 갈립니다: 무엇을 가상화하는가. VM은 하드웨어를 흉내 내서 그 위에 통째로 다른 커널을 올립니다. 컨테이너는 하드웨어를 흉내 내지 않고, 하나의 커널을 공유하면서 프로세스에게 “너 혼자 쓰는 시스템”이라는 뷰(view)만 보여줍니다. 이 글은 그 두 철학을, 그리고 둘을 절충한 마이크로 VM까지를 끝까지 따라갑니다.

격리의 스펙트럼

가상화는 “있다/없다”가 아니라 얼마나 강하게 격리하느냐의 연속선입니다. 강한 격리는 안전하지만 무겁고(밀도↓), 약한 격리는 가볍지만(밀도↑) 공격면이 넓습니다.

flowchart LR
  A["물리 서버<br/>격리 최강·밀도 최악"] --> B["VM<br/>게스트 커널 포함"]
  B --> C["마이크로 VM<br/>Firecracker·경량 게스트"]
  C --> D["컨테이너<br/>커널 공유·namespace"]
  D --> E["프로세스<br/>격리 거의 없음"]

왼쪽으로 갈수록 “내 것”이 많아지고(전용 커널·전용 하드웨어 뷰), 오른쪽으로 갈수록 “공유”가 많아집니다(커널을 나눠 씀). 정답은 없고, 워크로드가 요구하는 격리 강도와 집적 밀도 사이에서 한 점을 고르는 일입니다.

VM: 하드웨어를 통째로 흉내 낸다

가상 머신은 하이퍼바이저가 가짜 하드웨어(가상 CPU·메모리·디스크·NIC)를 만들어 주고, 그 위에 게스트 OS가 자기가 진짜 기계 위에 있는 줄 알고 부팅합니다.

  • Type 1 (베어메탈): 하드웨어 위에 하이퍼바이저가 직접 — ESXi, Xen, Hyper-V, 그리고 리눅스 커널을 하이퍼바이저로 바꾸는 KVM. 클라우드 인스턴스 대부분(AWS Nitro 포함)이 여기.
  • Type 2 (호스티드): 일반 OS 위 응용으로 — VirtualBox, VMware Workstation. 개발용.

문제는 x86이 원래 가상화에 친화적이지 않았다는 점입니다. 게스트 커널이 ring 0인 줄 알고 특권 명령을 실행하면, 진짜로 실행되면 안 됩니다. 고전적 해법이 트랩&에뮬레이트 — 특권 명령이 폴트를 일으키면 하이퍼바이저가 가로채 흉내 냅니다. 일부 명령은 트랩조차 안 돼서 VMware는 바이너리 변환, Xen은 게스트를 고치는 반가상화(paravirtualization) 로 우회했습니다. 지금은 하드웨어 보조 가상화(Intel VT-x / AMD-V)와 중첩 페이지 테이블(EPT/NPT)로 CPU가 직접 게스트를 지원해, 오버헤드가 크게 줄었습니다. I/O는 가짜 장치를 에뮬레이트하면 느리니, 게스트에 virtio 드라이버를 넣어 “나 가상이야”를 인정하고 빠른 경로를 씁니다.

현실 체크 — “VM이 무거운 진짜 이유는 커널이 N개라서다.” VM 10대 = 게스트 커널 10개가 각자 메모리에 상주하고, 각자 부팅하고, 각자 페이지 캐시를 따로 관리합니다. CPU 가상화 오버헤드는 VT-x로 거의 사라졌지만, 중복된 커널의 메모리·부팅 비용은 남습니다. 이게 같은 서버에 컨테이너는 수백 개 띄워도 VM은 수십 개에서 막히는 이유입니다.

컨테이너: 커널은 하나, 뷰만 쪼갠다

컨테이너는 새로운 커널을 띄우지 않습니다. 호스트 커널을 그대로 공유하면서, 리눅스 커널의 두 기능을 조합해 프로세스를 가둡니다.

  • namespaces — 무엇이 보이는가를 격리. pid(자기 프로세스만 보임), mnt(자기 파일시스템 뷰), net(전용 네트워크 스택·IP), uts(호스트명), ipc, user(UID 매핑), cgroup. 같은 커널이 프로세스마다 다른 뷰를 보여줍니다.
  • cgroups — 얼마나 쓰는가를 제한. CPU·메모리·I/O·PID 수에 상한을 걸어 한 컨테이너가 자원을 독식하지 못하게 합니다.

여기에 overlayfs(레이어 이미지를 합쳐 보이는 union mount), capabilities/seccomp-bpf(쓸 수 있는 시스템콜·권한을 깎기)가 더해지면 우리가 아는 컨테이너가 됩니다. 즉 컨테이너라는 단일 커널 객체는 없습니다 — namespace + cgroup + 파일시스템 격리가 만든 착시입니다.

아래에서 두 스택을 나란히 보세요. 왼쪽 VM은 게스트 커널(빨강)을 VM마다 짊어지고, 오른쪽 컨테이너는 커널(초록)을 하나만 공유해 스택이 짧습니다.

가상 머신 (VM) 컨테이너 App A Bins/Libs Guest 커널 App B Bins/Libs Guest 커널 하이퍼바이저 물리 하드웨어 (CPU·RAM·디스크) 게스트 커널 N개 → 무겁다 · 부팅 느림 · 이미지 큼 App C Bins/Libs App D Bins/Libs 컨테이너 런타임 (containerd) 호스트 OS · 커널 (단 하나, 공유) 물리 하드웨어 (CPU·RAM·디스크) 커널 1개 공유 → 가볍다 · 즉시 시작 / 격리는 약함

namespace가 만드는 착시: 같은 프로세스, 다른 PID

가장 직관적인 예가 PID namespace입니다. 컨테이너 안에서 ps를 치면 내 프로세스가 PID 1입니다. 그런데 호스트에서 보면 같은 프로세스가 PID 4012입니다. 둘은 완전히 동일한 커널 태스크이고, 단지 namespace에 따라 다른 번호로 보일 뿐입니다.

아래에서 토큰이 호스트의 4012와 컨테이너의 1 사이를 오갑니다 — 하나의 태스크가 두 개의 뷰를 가진다는 뜻입니다. 격리는 새 실체가 아니라 ‘보이는 것’의 분리입니다.

호스트 PID namespace PID 1 · systemd PID 982 · containerd PID 4012 · 컨테이너 init PID 4013 · nginx PID 4014 · worker 컨테이너 PID namespace PID 1 · init (= 호스트 4012) PID 7 · nginx PID 8 · worker 같은 커널 태스크 4012 = 컨테이너 안에선 PID 1 — 격리는 '뷰'일 뿐, 커널은 하나다

현실 체크 — “커널이 하나라서, 컨테이너 탈출은 VM 탈출보다 치명적이다.” VM에서 빠져나오려면 하이퍼바이저를 뚫어야 하지만, 컨테이너는 호스트 커널을 그대로 공유합니다. 따라서 커널 취약점 하나(권한 상승 syscall 버그 등)면 컨테이너 경계가 무너져 호스트와 옆 컨테이너까지 닿습니다. 그래서 멀티테넌트(남의 코드를 같은 호스트에서 실행)에서는 seccomp로 시스템콜을 깎고, user namespace로 root를 무력화하고, 더 강한 격리가 필요하면 아예 VM 경계를 다시 끌어옵니다.

다시 만난 양 끝: 마이크로 VM

“컨테이너의 가벼움 + VM의 격리”를 둘 다 원할 때 등장한 게 마이크로 VM입니다. AWS가 Lambda·Fargate를 위해 만든 Firecracker는, 불필요한 장치 에뮬레이션을 걷어낸 초경량 VM을 ~125ms 만에 띄웁니다 — 함수 하나하나에 전용 커널(=VM 경계) 을 주면서도 컨테이너에 가까운 시작 속도를 냅니다. gVisor는 반대 방향으로, 유저 공간에 커널을 다시 구현해 게스트 시스템콜이 호스트 커널에 직접 닿지 못하게 막습니다.

남의 코드를 초 단위로 수억 번 실행해야 하는 서버리스에서, “공유 커널 컨테이너”의 격리는 충분하지 않았습니다. 밀도를 조금 양보하고 격리를 사서 스펙트럼의 가운데로 돌아온 셈입니다.

직접 들여다보기: namespace와 cgroup 만져 보기

컨테이너가 마법이 아니라 커널 기능 조합임을, 도커 없이 맨손으로 확인해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 새 PID + 마운트 namespace에서 셸을 띄우면, 그 안의 ps는 자기만 본다
sudo unshare --pid --mount --fork --mount-proc bash
  ps -ef          # PID 1이 방금 띄운 bash — 격리된 뷰

# 한 프로세스가 들어가 있는 namespace 목록
lsns
ls -l /proc/$$/ns          # pid/net/mnt/uts/ipc/user/cgroup → 각 namespace의 inode

# cgroup v2로 메모리 상한 걸기 (200MB 넘으면 OOM)
sudo mkdir /sys/fs/cgroup/demo
echo 200M | sudo tee /sys/fs/cgroup/demo/memory.max
echo $$   | sudo tee /sys/fs/cgroup/demo/cgroup.procs

# 도커는 결국 이걸 묶어줄 뿐 — 확인
docker run --memory=200m --cpus=1.5 --rm nginx
nsenter -t <PID> -n -m bash   # 실행 중 컨테이너의 net+mnt namespace로 진입

unshare 한 줄로 “PID 1”을 만들어 내는 순간, 컨테이너가 커널이 원래 갖고 있던 격리 기능의 사용자 친화적 포장이라는 게 손에 잡힙니다.

면접/리뷰 단골 질문

  • Q. VM과 컨테이너의 본질적 차이는? → 가상화 대상이 다르다. VM은 하드웨어를 가상화해 게스트 커널을 통째로 올리고, 컨테이너는 호스트 커널을 공유하며 namespace로 뷰만, cgroup으로 자원만 격리한다.
  • Q. 컨테이너가 “가볍다”는 게 정확히 뭐가 가벼운가? → 게스트 커널이 없어 메모리 중복이 없고, 부팅 과정이 없어(프로세스 시작일 뿐) 즉시 뜬다. CPU 가상화 오버헤드 얘기가 아니다.
  • Q. namespace와 cgroup의 역할 구분은? → namespace = 무엇이 보이는가(PID·네트워크·마운트 뷰 격리), cgroup = 얼마나 쓰는가(CPU·메모리·I/O 제한). 컨테이너는 이 둘의 조합이다.
  • Q. 컨테이너 탈출이 VM 탈출보다 위험한 이유는? → 커널을 공유하므로 커널 취약점 하나로 경계가 무너진다. VM은 하이퍼바이저라는 더 작고 단단한 경계를 추가로 뚫어야 한다.
  • Q. 서버리스가 컨테이너 대신 마이크로 VM을 쓰는 이유는? → 멀티테넌트에서 공유 커널 격리가 부족해서. Firecracker 같은 마이크로 VM은 전용 커널(VM 경계)을 주면서도 시작이 빨라, 격리와 밀도를 절충한다.

정리

  • 가상화는 “무엇을 가상화하나”의 문제다 — VM은 하드웨어(게스트 커널 포함), 컨테이너는 커널 뷰(공유 커널).
  • VM이 무거운 진짜 이유는 CPU 오버헤드가 아니라 중복된 게스트 커널의 메모리·부팅 비용이다.
  • 컨테이너는 단일 실체가 아니라 namespace(뷰 격리) + cgroup(자원 제한) + overlayfs + seccomp가 만든 착시다.
  • 커널을 공유하므로 컨테이너 격리는 VM보다 약하다 → 커널 취약점 = 탈출 위험.
  • 격리는 스펙트럼이고, 마이크로 VM(Firecracker)은 격리와 밀도를 절충해 서버리스를 가능하게 했다.

다음 글: 시리즈의 마지막, 지금까지의 프로세스·메모리·I/O 지식을 하나로 묶어 “느려요”를 진단으로 바꾸는 성능 관찰과 병목 분석으로 갑니다. 컨테이너의 자원 제한이 실제로 어떻게 병목으로 나타나는지도 거기서 만납니다. (프로세스·PID의 기초가 흔들린다면 프로세스 편으로 돌아가세요.)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝 지금 보는 글
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)