Docker 컨테이너와 VM의 차이와 선택 기준

“컨테이너는 가벼운 VM이다”라는 말을 흔히 듣는다. 틀린 말은 아니지만, 이 표현만 믿고 컨테이너에 VM 수준의 격리를 기대하면 곤란해진다. 신뢰할 수 없는 멀티테넌트 워크로드를 컨테이너 하나로 묶어뒀다가 커널 취약점 하나로 전체가 뚫리는 사고가 나거나, 반대로 그냥 프로세스 몇 개 격리하면 되는 워크로드에 VM을 통째로 띄워 자원을 낭비하는 경우도 흔하다. 두 기술은 “격리”라는 목표는 같지만 이를 구현하는 방식이 근본적으로 다르기 때문이다. 이 글에서는 VM과 컨테이너의 구조적 차이를 정리하고, 실제로 컨테이너를 하나 띄워 그 안의 namespace와 cgroups를 직접 까보면서 컨테이너의 격리가 실제로 무엇으로 이루어지는지 확인한다.

VM과 컨테이너의 구조 차이

VM은 하이퍼바이저(KVM, VirtualBox, Hyper-V 등)가 CPU/메모리/디바이스를 가상화해서 각 VM에게 “완전한 컴퓨터 한 대”를 내준다. 그래서 VM마다 자기 커널을 따로 부팅한다 — 호스트가 Ubuntu여도 게스트로 Windows나 다른 버전의 커널을 얼마든지 올릴 수 있다. 격리 경계가 하드웨어 가상화 계층에 있어서 매우 강하지만, 그만큼 커널 부팅 시간과 메모리 오버헤드가 따라붙는다.

컨테이너는 다르다. 별도 커널을 부팅하지 않고 호스트 커널을 여러 프로세스가 그대로 공유한다. 격리는 하드웨어 계층이 아니라 커널 기능 두 가지로 구현된다.

  • namespace: 프로세스가 보는 “시야”를 격리한다. PID namespace면 컨테이너 안에서 자기 프로세스만 보이고, net namespace면 자기만의 네트워크 인터페이스를 갖는 식이다.
  • cgroups(control groups): CPU/메모리/I/O 같은 자원 사용량을 제한하고 회계한다.

즉 컨테이너 안의 프로세스도 호스트 입장에서는 그냥 프로세스 하나다. ps aux를 호스트에서 실행하면 컨테이너 안 프로세스도 그대로 보인다 — 다만 컨테이너 안에서 ps를 실행하면 PID namespace 덕분에 자기 자신만 보일 뿐이다.

항목VM컨테이너
격리 단위하이퍼바이저 + 별도 커널namespace + cgroups (커널 공유)
시작 속도수십 초 (부팅 필요)수백 ms (프로세스 fork 수준)
커널게스트마다 독립적호스트와 동일 커널 공유
격리 강도강함 (하드웨어 계층)상대적으로 약함 (커널 계층)
대표 기술KVM/QEMU, VirtualBox, Hyper-VDocker, containerd, LXC/LXD

실습 — 컨테이너의 namespace와 cgroups 직접 확인하기

말로만 들으면 추상적이니 Ubuntu 24.04에 Docker를 설치하고 컨테이너 하나를 띄워서 실제로 커널이 어떤 namespace를 붙였는지, cgroups 자원 제한이 어떻게 걸리는지 직접 확인해본다.

$ sudo apt install -y docker.io
$ sudo docker run -d --name test nginx

컨테이너가 뜨면 그 안에서 동작하는 실제 프로세스(nginx 마스터)의 PID를 알아내서 /proc/PID/cgrouplsns로 어떤 namespace에 속해 있는지 확인한다.

$ PID=$(sudo docker inspect -f '{{.State.Pid}}' test)
$ sudo cat /proc/$PID/cgroup
0::/system.slice/docker-05f59ac769b14d3a6fef2337fec014d7e5ed7a8aee840b6c1ae350e92a1539ef.scope

$ sudo lsns -p $PID
        NS TYPE   NPROCS   PID USER COMMAND
4026531834 time      262     1 root /sbin/init splash
4026531837 user      260     1 root /sbin/init splash
4026532381 mnt         7 60745 root nginx: master process nginx -g daemon off;
4026532382 uts         7 60745 root nginx: master process nginx -g daemon off;
4026532383 ipc         7 60745 root nginx: master process nginx -g daemon off;
4026532384 pid         7 60745 root nginx: master process nginx -g daemon off;
4026532385 cgroup      7 60745 root nginx: master process nginx -g daemon off;
4026532386 net         7 60745 root nginx: master process nginx -g daemon off;

여기서 컨테이너와 VM의 차이가 그대로 드러난다. timeuser namespace의 ID(4026531834, 4026531837)가 호스트의 PID 1(/sbin/init, NPROCS 262 — 호스트 전체 프로세스 수)과 완전히 동일하다. 즉 이 컨테이너는 호스트와 시간/유저 네임스페이스를 그대로 공유하고 있다는 뜻이다. 반면 mnt/uts/ipc/pid/cgroup/net은 컨테이너 전용으로 새로 만들어져 있고(NPROCS 7 — 컨테이너 안 프로세스 수만), 이 6개 namespace만으로 “격리된 것처럼 보이는” 환경을 만든 것이다. VM처럼 커널 자체가 다른 게 아니라, 같은 커널을 보는 창문 몇 개를 골라서 가린 것에 가깝다.

cgroups v2 쪽도 확인해보면, 컨테이너마다 /sys/fs/cgroup 아래 자기 scope 디렉터리가 생기고 그 안에 자원 제어용 파일들이 있다.

$ sudo ls /sys/fs/cgroup/system.slice/docker-05f59ac.../
cgroup.controllers
cgroup.events
cgroup.freeze
cpu.max
cpu.pressure
cpuset.cpus
memory.max
memory.current
io.max
...

docker run에 자원 제한 옵션을 주면 이 파일들에 그대로 값이 써진다. --memory=100m --cpus=0.5로 컨테이너를 하나 더 띄워서 확인하면 다음과 같다.

$ sudo docker run -d --name limited --memory=100m --cpus=0.5 nginx
$ CID=$(sudo docker inspect -f '{{.Id}}' limited)
$ sudo cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/memory.max
104857600
$ sudo cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/cpu.max
50000 100000

memory.max의 104857600바이트는 정확히 100 × 1024 × 1024, 즉 100MiB다. cpu.max의 “50000 100000″은 100,000μs(100ms) 주기마다 50,000μs(50ms)만 CPU를 쓸 수 있다는 뜻으로, 0.5 CPU 제한을 그대로 표현한 값이다. Docker의 --memory/--cpus 옵션은 결국 이 cgroups v2 파일에 숫자를 써주는 래퍼일 뿐이라는 걸 실제 파일 값으로 확인할 수 있다.

언제 VM을, 언제 컨테이너를 써야 할까

  • 강한 격리가 필요한 멀티테넌트(신뢰할 수 없는 코드를 실행해야 하는 경우 등)라면 VM이 안전하다. 컨테이너는 커널을 공유하므로 커널 취약점 하나가 곧 전체 테넌트에 영향을 줄 수 있다.
  • 호스트와 다른 커널/OS가 필요하면(Windows 게스트, 다른 커널 버전 테스트 등) 컨테이너로는 애초에 불가능하고 VM이 필수다.
  • 빠른 배포·수평 확장·마이크로서비스처럼 프로세스 단위로 자원을 아끼며 여러 개를 띄워야 한다면 컨테이너가 유리하다. 이미지 하나로 개발/스테이징/운영 환경을 동일하게 맞추는 것도 컨테이너의 강점이다.
  • 두 요구사항이 겹칠 때(컨테이너의 배포 편의성 + VM 수준 격리)는 gVisor나 Kata Containers처럼 컨테이너 인터페이스를 유지하면서 실제로는 별도 커널/샌드박스로 격리하는 런타임을 검토할 만하다.

주의사항

  • 이번 실습은 VirtualBox 위의 VM에서 진행했는데, 이 VM 자체가 nested virtualization을 지원하지 않아(modprobe kvm_amd 시도 시 “Operation not supported”) KVM/QEMU 쪽은 실제로 띄워보지 못했다. 컨테이너 쪽(namespace/cgroups)만 직접 실측했고, VM 쪽 수치는 이 글에 포함하지 않았다.
  • 컨테이너는 커널을 공유하는 구조라 호스트 커널 자체의 취약점(예: 과거 runc CVE-2019-5736 같은 컨테이너 탈출 취약점)에 영향을 받을 수 있다. 신뢰 경계를 컨테이너만으로 설계하지 말고, 필요하면 위에서 언급한 샌드박스 런타임이나 VM과 병행하는 걸 고려한다.
  • Ubuntu 기본 저장소의 docker.io 패키지는 Docker 공식 저장소의 최신 Docker CE보다 버전이 낮을 수 있다. 최신 기능(BuildKit 신규 옵션 등)이 필요하면 Docker 공식 apt 저장소를 추가해서 설치하는 게 낫다.

마무리

VM과 컨테이너는 “격리”라는 같은 목표를 완전히 다른 계층에서 구현한다. VM은 하이퍼바이저로 하드웨어 자체를 나누고, 컨테이너는 하나의 커널 위에서 namespace로 시야를, cgroups로 자원을 나눈다. 이 차이를 이해하면 “그냥 다 컨테이너로” 혹은 “그냥 다 VM으로” 같은 단순한 선택 대신, 워크로드의 신뢰 수준과 자원 효율 요구에 맞는 선택을 할 수 있다.

참고

답글 남기기