Split View: 컨테이너와 VM은 무엇이 다른가 — namespace, cgroup, 그리고 커널 공유의 청구서
컨테이너와 VM은 무엇이 다른가 — namespace, cgroup, 그리고 커널 공유의 청구서
- 들어가며 — "가벼운 VM"이라는 설명이 남기는 잘못된 직관
- 컨테이너는 격리된 프로세스일 뿐이다
- namespace — 무엇이 보이지 않게 되는가
- cgroup — 무엇을 못 쓰게 하는가
- 커널을 공유한다는 것의 실제 청구서
- VM은 무엇을 더 주고 무엇을 더 받는가
- 중간 지대 — gVisor, Kata Containers, Firecracker
- 마치며 — 격리의 세기가 아니라 격리의 종류가 다르다
들어가며 — "가벼운 VM"이라는 설명이 남기는 잘못된 직관
컨테이너를 처음 배울 때 거의 모두가 같은 그림을 봅니다. 왼쪽에는 하이퍼바이저 위에 게스트 OS가 세 개 쌓여 있고, 오른쪽에는 도커 엔진 위에 컨테이너 세 개가 쌓여 있는 그림입니다. 설명은 이렇게 끝납니다. "컨테이너는 게스트 OS가 없어서 가볍습니다."
틀린 말은 아니지만, 이 그림에서 얻은 직관은 실무에서 반복적으로 배신합니다. 컨테이너를 작은 VM으로 이해한 사람은 다음과 같은 상황에서 막힙니다. 컨테이너 안에서 uname -r을 쳤는데 호스트 커널 버전이 나오고, 컨테이너에서 sysctl -w vm.max_map_count가 호스트 전체에 영향을 주거나 아예 거부되고, free -m이 컨테이너 메모리 제한이 아니라 호스트 전체 메모리를 보여주고, JVM이 CPU 코어 수를 호스트 기준으로 잡아 스레드 풀을 과하게 만듭니다.
정확한 문장은 이것입니다. 컨테이너는 호스트 커널 위에서 도는 평범한 프로세스이고, 다만 볼 수 있는 것과 쓸 수 있는 것이 제한되어 있을 뿐입니다. 이 한 문장에서 위의 모든 현상이 따라 나옵니다.
컨테이너는 격리된 프로세스일 뿐이다
가장 빠른 확인 방법은 호스트에서 프로세스 목록을 보는 것입니다.
docker run -d --name web nginx:1.27
ps -eo pid,user,comm | grep nginx
48213 root nginx
48261 systemd+ nginx
48262 systemd+ nginx
호스트의 ps에 그대로 보입니다. VM이라면 게스트 안의 프로세스가 호스트 프로세스 테이블에 나타날 리 없습니다. 컨테이너의 프로세스는 처음부터 호스트 프로세스입니다.
컨테이너 안에서 보면 같은 프로세스의 번호가 다릅니다.
docker exec web ps -eo pid,comm
PID COMMAND
1 nginx
29 nginx
30 nginx
호스트에서 48213인 프로세스가 컨테이너 안에서는 1번입니다. 프로세스가 두 개인 것이 아니라, 같은 프로세스를 다른 네임스페이스에서 본 것입니다.
커널 버전은 숨길 수 없습니다.
uname -r
docker run --rm alpine:3.20 uname -r
docker run --rm ubuntu:24.04 uname -r
6.8.0-52-generic
6.8.0-52-generic
6.8.0-52-generic
알파인 컨테이너도, 우분투 컨테이너도 호스트 커널을 씁니다. 이미지가 제공하는 것은 커널이 아니라 사용자 공간의 파일 트리입니다. 맥이나 윈도우의 Docker Desktop에서 이 값이 낯설게 나온다면, 그것은 도커가 리눅스 VM을 하나 띄워 두고 그 안에서 컨테이너를 돌리기 때문입니다. 그 환경에서 여러분이 쓰는 것은 컨테이너이면서 동시에 VM 안입니다.
namespace — 무엇이 보이지 않게 되는가
컨테이너를 만드는 커널 기능은 마법이 아니라 몇 개의 시스템 콜입니다. clone과 unshare, setns가 전부입니다. 프로세스마다 어떤 네임스페이스에 속해 있는지는 파일로 노출됩니다.
ls -l /proc/self/ns
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 uts -> 'uts:[4026531838]'
대괄호 안의 숫자가 네임스페이스 식별자입니다. 컨테이너를 띄우고 같은 파일을 보면 대부분의 숫자가 달라집니다.
docker run --rm alpine:3.20 ls -l /proc/self/ns | awk '{print $9, $11}'
cgroup cgroup:[4026532561]
ipc ipc:[4026532499]
mnt mnt:[4026532497]
net net:[4026532562]
pid pid:[4026532500]
time time:[4026531834]
user user:[4026531837]
uts uts:[4026532496]
time과 user만 호스트와 같은 값입니다. 도커는 기본적으로 유저 네임스페이스를 쓰지 않기 때문입니다. 이 한 줄이 다음 절의 보안 논의 전체를 결정합니다.
각 네임스페이스가 담당하는 것은 이렇습니다.
pid는 프로세스 번호 공간을 분리합니다. 컨테이너의 첫 프로세스가 1번이 되고, 컨테이너 안에서는 다른 컨테이너나 호스트의 프로세스가 보이지 않습니다. 1번이 된다는 사실 자체가 신호 처리와 좀비 수확 책임을 함께 부여합니다.
mnt는 마운트 테이블을 분리합니다. 이미지의 루트 파일시스템이 컨테이너의 /로 보이는 것이 이 네임스페이스와 pivot_root의 결과입니다.
net은 네트워크 스택 전체를 복제합니다. 인터페이스, 라우팅 테이블, iptables 규칙, 소켓, 포트 번호 공간이 모두 별개입니다. 그래서 컨테이너 두 개가 각각 80번 포트를 열 수 있습니다.
uts는 호스트명과 도메인명을 분리합니다. 컨테이너 안에서 hostname을 바꿔도 호스트에 영향이 없는 이유입니다.
ipc는 System V IPC와 POSIX 메시지 큐를 분리합니다. 공유 메모리를 쓰는 프로세스를 두 컨테이너로 나누면 서로 보이지 않게 되는 지점입니다.
user는 UID와 GID를 매핑합니다. 컨테이너 안의 0번이 호스트의 0번이 아니게 만드는 유일한 수단입니다.
cgroup은 cgroup 계층 구조에서 자기 위치를 루트로 보이게 합니다.
직접 만들어 보면 감이 잡힙니다.
sudo unshare --pid --fork --mount-proc --uts --net --mount \
/bin/bash -c 'hostname mini; hostname; ps -ef; ip link'
mini
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 14:22 pts/0 00:00:00 /bin/bash -c hostname mini; ...
root 4 1 0 14:22 pts/0 00:00:00 ps -ef
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default
이미지도 없고 도커도 없이 컨테이너의 절반이 만들어졌습니다. 나머지 절반이 cgroup입니다.
cgroup — 무엇을 못 쓰게 하는가
네임스페이스는 시야를 가릴 뿐 자원을 나누지 않습니다. 메모리 제한과 CPU 배분은 전부 cgroup의 몫입니다. 최근 배포판은 cgroup v2가 기본이고, 컨트롤러가 하나의 통합 계층에 붙습니다.
docker run -d --name limited --memory=256m --cpus=0.5 nginx:1.27
CID=$(docker inspect -f '{{.Id}}' limited)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/pids.max
268435456
50000 100000
max
memory.max가 256MiB, cpu.max가 100ms 주기당 50ms입니다. 이 값을 넘기면 어떻게 되는지가 중요합니다. CPU는 스로틀링되고, 메모리는 죽습니다.
docker run --rm --memory=64m python:3.12-slim \
python -c "x = bytearray(200 * 1024 * 1024)"
echo "exit=$?"
exit=137
137은 128에 9를 더한 값, 즉 SIGKILL입니다. 커널의 OOM 킬러가 cgroup 한도 초과를 이유로 프로세스를 종료했습니다. 애플리케이션은 어떤 예외도 받지 못합니다. 그래서 컨테이너가 조용히 재시작을 반복하면 애플리케이션 로그가 아니라 커널 로그를 봐야 합니다.
dmesg -T | grep -i 'memory cgroup out of memory' | tail -2
[Sun Jul 26 14:31:07 2026] Memory cgroup out of memory: Killed process 51882 (python) total-vm:412508kB, anon-rss:66120kB
여기서 유명한 함정이 나옵니다. cgroup은 제한을 걸지만 /proc을 가리지는 않습니다. /proc/meminfo와 /proc/cpuinfo는 여전히 호스트 값을 보여줍니다.
docker run --rm --memory=256m --cpus=0.5 debian:bookworm-slim \
sh -c 'free -m | head -2; nproc'
total used free shared buff/cache available
Mem: 64228 9112 41003 512 14113 54216
32
256MB로 제한한 컨테이너가 64GB를 보고, 0.5 코어로 제한한 컨테이너가 32코어를 봅니다. 이 값을 그대로 믿는 런타임은 스레드 풀과 힙을 호스트 기준으로 잡고 곧바로 OOM으로 죽습니다. JVM은 UseContainerSupport로 cgroup을 직접 읽어 이 문제를 해결했고, Node의 os.cpus()나 Go의 runtime.NumCPU()는 여전히 호스트 값을 반환하므로 GOMAXPROCS를 명시하거나 automaxprocs 같은 라이브러리를 써야 합니다. lxcfs를 붙여 /proc을 가짜로 채우는 방법도 있지만, 애플리케이션 쪽에서 cgroup을 읽는 편이 훨씬 견고합니다.
커널을 공유한다는 것의 실제 청구서
여기까지가 구조이고, 지금부터가 결과입니다.
첫째, 커널 버전에 종속됩니다. 이미지 안의 glibc가 아무리 새것이어도 커널이 지원하지 않는 시스템 콜은 쓸 수 없습니다. 오래된 커널 위에서 최신 베이스 이미지를 돌리면 io_uring이나 새 seccomp 필터를 쓰는 코드가 실패합니다. 반대 방향도 있습니다. 도커 20.10 미만과 CentOS 7 계열에서 최신 glibc 이미지를 돌릴 때 기본 seccomp 프로파일이 clone3을 차단해 컨테이너가 즉시 죽는 사고는 오랫동안 반복되었습니다.
둘째, 커널 파라미터가 공유됩니다. sysctl은 네임스페이스화된 것과 아닌 것이 나뉩니다.
# net.* 대부분은 net 네임스페이스에 속하므로 컨테이너별 설정 가능
docker run --rm --sysctl net.ipv4.tcp_syncookies=0 alpine:3.20 \
sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 0
# vm.* 는 네임스페이스화되어 있지 않음
docker run --rm --sysctl vm.max_map_count=262144 alpine:3.20 true
docker: Error response from daemon: failed to create task for container:
sysctl "vm.max_map_count" is not in a separate kernel namespace: unknown.
Elasticsearch를 컨테이너로 띄울 때 만나는 그 오류입니다. 해결책은 컨테이너 설정이 아니라 호스트에서 sysctl -w vm.max_map_count=262144를 실행하는 것입니다. 즉 이 값은 그 노드의 모든 컨테이너가 공유합니다. VM이라면 게스트마다 독립적으로 설정했을 값입니다.
셋째, 보안 경계의 성격이 다릅니다. VM에서 게스트가 호스트를 장악하려면 하이퍼바이저나 가상 장치 모델의 버그를 뚫어야 합니다. 컨테이너에서는 커널 시스템 콜 인터페이스 전체가 공격 표면입니다. 리눅스 커널의 로컬 권한 상승 취약점 하나가 곧바로 컨테이너 탈출로 이어집니다.
실제 사례가 계속 나옵니다. CVE-2019-5736은 컨테이너 안에서 /proc/self/exe를 통해 호스트의 runc 바이너리를 덮어쓰는 공격이었고, CVE-2024-21626은 runc가 파일 디스크립터를 닫지 않은 채 컨테이너를 시작하는 문제를 이용해 WORKDIR 조작만으로 호스트 파일시스템에 접근했습니다. 둘 다 커널이 아니라 런타임의 버그였지만, 컨테이너와 호스트가 같은 커널 아래 있기 때문에 성립한 공격입니다.
그래서 컨테이너 격리는 네임스페이스만으로 완성되지 않고 여러 겹을 쌓습니다. 도커의 기본값이 이미 상당 부분을 합니다.
docker run --rm alpine:3.20 sh -c 'grep CapEff /proc/self/status'
CapEff: 00000000a80425fb
capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,
cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,
cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
40개가 넘는 capability 중 14개만 남습니다. 여기에 약 44개 시스템 콜을 막는 기본 seccomp 프로파일과 AppArmor 또는 SELinux 프로파일이 더해집니다. 운영에서는 여기서 더 줄이는 것이 정석입니다.
docker run -d \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--read-only --tmpfs /tmp \
--user 10001:10001 \
api:v312
반대로 이 방어막을 한 번에 무너뜨리는 옵션도 있습니다. --privileged는 모든 capability를 주고 seccomp와 AppArmor를 끄며 호스트 장치를 노출합니다. 도커 소켓을 컨테이너에 마운트하는 것도 사실상 같은 의미입니다. 그 소켓에 접근할 수 있으면 호스트 루트 파일시스템을 마운트한 특권 컨테이너를 새로 띄울 수 있습니다.
유저 네임스페이스는 이 그림을 근본적으로 바꾸는 유일한 스위치입니다.
// /etc/docker/daemon.json
{
"userns-remap": "default"
}
이러면 컨테이너 안의 UID 0이 호스트의 비특권 UID로 매핑되어, 탈출에 성공하더라도 즉시 루트가 되지는 않습니다. 대신 볼륨 소유권과 일부 네트워크 기능에서 제약이 생깁니다.
VM은 무엇을 더 주고 무엇을 더 받는가
VM의 격리 경계는 시스템 콜이 아니라 하드웨어 가상화입니다. 게스트는 자기 커널을 갖고, 게스트 커널이 처리하는 시스템 콜은 호스트 커널에 도달하지 않습니다. 게스트에서 호스트로 넘어오려면 VM exit 경로와 가상 장치 에뮬레이션을 뚫어야 하며, 그 인터페이스는 시스템 콜 300여 개보다 훨씬 좁습니다.
대가는 명확합니다. 게스트마다 커널과 초기화 과정이 필요하니 부팅이 수십 초 걸리고, 메모리는 게스트 커널과 페이지 캐시만으로 수백 MB가 먼저 나갑니다. 게스트에 할당한 메모리는 실제로 쓰지 않아도 대체로 잡혀 있고, IO는 가상 장치를 한 겹 더 지납니다.
밀도로 비교하면 차이가 분명합니다. 32GB 노드에서 512MB짜리 컨테이너는 50개 이상 뜨지만, 같은 노드에서 VM은 게스트 오버헤드까지 감안하면 그 절반도 어렵습니다.
| 항목 | 컨테이너 | VM | 마이크로VM(Firecracker) | gVisor |
|---|---|---|---|---|
| 커널 | 호스트와 공유 | 게스트 전용 | 게스트 전용(경량) | 사용자 공간 커널이 대행 |
| 시작 시간 | 수십 ms | 수십 초 | 약 125ms | 수백 ms |
| 메모리 오버헤드 | 거의 없음 | 수백 MB | 약 5MB | 수십 MB |
| 격리 경계 | 시스템 콜 | 하드웨어 가상화 | 하드웨어 가상화 | 시스템 콜 가로채기 |
| 다른 커널 실행 | 불가 | 가능 | 가능 | 불가 |
| 시스템 콜 성능 | 네이티브 | 네이티브에 근접 | 네이티브에 근접 | 느림 |
| 적합한 경계 | 같은 신뢰 도메인 | 다른 조직/테넌트 | 서버리스 멀티테넌시 | 신뢰 못 할 코드 실행 |
중간 지대 — gVisor, Kata Containers, Firecracker
멀티테넌트 환경에서 고객이 제출한 코드를 실행해야 한다면 두 축 사이 어딘가가 필요합니다. 그래서 세 가지 접근이 자리를 잡았습니다.
gVisor는 사용자 공간에 리눅스 시스템 콜 인터페이스를 다시 구현합니다. Sentry라는 프로세스가 컨테이너의 시스템 콜을 가로채 자체적으로 처리하고, 호스트 커널에는 아주 좁은 부분집합만 전달합니다. 호스트 커널 공격 표면이 극적으로 줄지만, 시스템 콜이 잦은 워크로드는 눈에 띄게 느려지고 일부 시스템 콜은 아예 지원되지 않습니다.
docker run --rm --runtime=runsc alpine:3.20 dmesg | head -3
[ 0.000000] Starting gVisor...
[ 0.310495] Preparing for the zombie uprising...
[ 0.582901] Setting up VFS2...
Kata Containers는 컨테이너마다 경량 VM을 띄우고 그 안에서 OCI 컨테이너를 실행합니다. runc 대신 kata-runtime을 쓰면 되고, 쿠버네티스에서는 RuntimeClass로 워크로드별 선택이 가능합니다.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata
handler: kata
---
apiVersion: v1
kind: Pod
metadata:
name: untrusted-job
spec:
runtimeClassName: kata
containers:
- name: job
image: registry.example.com/untrusted:latest
Firecracker는 AWS가 Lambda와 Fargate를 위해 만든 VMM입니다. 가상 장치를 네트워크, 블록, 시리얼, 키보드 정도로 줄여 부팅을 약 125ms까지 낮추고 VM당 메모리 오버헤드를 5MB 수준으로 만들었습니다. 컨테이너의 밀도와 VM의 격리를 동시에 노린 설계이고, 실제로 서버리스 플랫폼의 표준 답이 되었습니다.
선택 기준은 결국 신뢰 경계입니다. 같은 팀이 만든 서비스들을 한 노드에 모으는 것이라면 네임스페이스 격리로 충분하고, 오히려 seccomp와 capability를 조이는 데 시간을 쓰는 편이 낫습니다. 서로 다른 고객의 코드나 임의의 사용자 제출 코드를 한 노드에서 돌린다면 커널 공유는 감수할 수 없는 위험이므로 마이크로VM이나 gVisor를 얹거나, 아예 테넌트별로 노드를 분리해야 합니다. 다른 커널 버전이나 다른 OS가 필요하다면 논쟁의 여지 없이 VM입니다.
마치며 — 격리의 세기가 아니라 격리의 종류가 다르다
컨테이너와 VM은 같은 축 위의 무겁고 가벼운 두 점이 아닙니다. 컨테이너는 커널을 공유하고 시스템 콜을 경계로 삼으며, VM은 커널을 나누고 하드웨어를 경계로 삼습니다. 종류가 다른 것입니다.
이 하나를 기준으로 두면 실무 판단이 단순해집니다. 커널 버전이 다르거나 신뢰하지 않는 코드를 격리해야 하면 VM 계열, 배포 단위를 가볍고 빠르게 굴리는 것이 목적이면 컨테이너, 둘 다 필요하면 Kata나 Firecracker입니다. 그리고 컨테이너를 고른 순간부터는 격리를 커널이 알아서 해 주리라 기대하는 대신 capability, seccomp, 읽기 전용 루트, 비특권 사용자를 직접 조여야 합니다.
What Actually Differs Between a Container and a VM — namespace, cgroup, and the Bill for Sharing a Kernel
- Introduction — The Wrong Intuition Left Behind by "Lightweight VM"
- A Container Is Just an Isolated Process
- namespace — What Becomes Invisible
- cgroup — What You Are Not Allowed to Use
- The Actual Bill for Sharing a Kernel
- What a VM Gives You More Of, and What It Takes More Of
- The Middle Ground — gVisor, Kata Containers, Firecracker
- Closing — It Is Not a Difference in Strength of Isolation but in Kind
Introduction — The Wrong Intuition Left Behind by "Lightweight VM"
Almost everyone sees the same picture when first learning about containers. On the left, three guest operating systems stacked on a hypervisor; on the right, three containers stacked on the Docker engine. The explanation ends like this: "Containers are lightweight because they have no guest OS."
It is not wrong, but the intuition you take from that picture betrays you repeatedly in practice. Someone who understands a container as a small VM gets stuck in situations like these. You type uname -r inside the container and get the host kernel version. sysctl -w vm.max_map_count in a container either affects the entire host or is refused outright. free -m shows the whole host's memory rather than the container's memory limit. The JVM picks up the CPU core count from the host and builds an oversized thread pool.
The accurate sentence is this. A container is an ordinary process running on the host kernel, merely restricted in what it can see and what it can use. Every one of the phenomena above follows from that single sentence.
A Container Is Just an Isolated Process
The quickest way to confirm it is to look at the process list on the host.
docker run -d --name web nginx:1.27
ps -eo pid,user,comm | grep nginx
48213 root nginx
48261 systemd+ nginx
48262 systemd+ nginx
They show up in the host's ps exactly as they are. With a VM, there is no way a process inside the guest would appear in the host's process table. A container's processes are host processes from the very beginning.
Seen from inside the container, the same processes have different numbers.
docker exec web ps -eo pid,comm
PID COMMAND
1 nginx
29 nginx
30 nginx
The process that is 48213 on the host is number 1 inside the container. There are not two processes; it is the same process viewed from a different namespace.
The kernel version cannot be hidden.
uname -r
docker run --rm alpine:3.20 uname -r
docker run --rm ubuntu:24.04 uname -r
6.8.0-52-generic
6.8.0-52-generic
6.8.0-52-generic
The alpine container and the ubuntu container both use the host kernel. What an image provides is not a kernel but a userspace file tree. If this value looks unfamiliar on Docker Desktop for Mac or Windows, that is because Docker keeps a Linux VM running and executes the containers inside it. In that environment, what you are using is a container and simultaneously the inside of a VM.
namespace — What Becomes Invisible
The kernel features that create a container are not magic, just a handful of system calls. clone, unshare, and setns are all of it. Which namespaces a process belongs to is exposed as files.
ls -l /proc/self/ns
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 uts -> 'uts:[4026531838]'
The numbers in brackets are namespace identifiers. Start a container and look at the same file, and most of the numbers differ.
docker run --rm alpine:3.20 ls -l /proc/self/ns | awk '{print $9, $11}'
cgroup cgroup:[4026532561]
ipc ipc:[4026532499]
mnt mnt:[4026532497]
net net:[4026532562]
pid pid:[4026532500]
time time:[4026531834]
user user:[4026531837]
uts uts:[4026532496]
Only time and user have the same values as the host. That is because Docker does not use the user namespace by default. That single line determines the entire security discussion in the next section.
Here is what each namespace is responsible for.
pid separates the process number space. The container's first process becomes number 1, and processes belonging to other containers or the host are invisible from inside. The very fact of becoming number 1 also confers responsibility for signal handling and zombie reaping.
mnt separates the mount table. The image's root filesystem appearing as the container's / is the result of this namespace plus pivot_root.
net replicates the entire network stack. Interfaces, routing tables, iptables rules, sockets, and the port number space are all separate. That is why two containers can each open port 80.
uts separates the hostname and domain name. It is why changing hostname inside a container has no effect on the host.
ipc separates System V IPC and POSIX message queues. It is the point at which splitting processes that use shared memory into two containers makes them invisible to each other.
user maps UIDs and GIDs. It is the only means of making UID 0 inside the container not be UID 0 on the host.
cgroup makes a process's own position in the cgroup hierarchy appear as the root.
Building one yourself makes it click.
sudo unshare --pid --fork --mount-proc --uts --net --mount \
/bin/bash -c 'hostname mini; hostname; ps -ef; ip link'
mini
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 14:22 pts/0 00:00:00 /bin/bash -c hostname mini; ...
root 4 1 0 14:22 pts/0 00:00:00 ps -ef
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default
With no image and no Docker, half of a container has been built. The other half is cgroup.
cgroup — What You Are Not Allowed to Use
Namespaces only mask the view; they do not divide resources. Memory limits and CPU allocation are entirely the job of cgroups. Recent distributions default to cgroup v2, where controllers attach to a single unified hierarchy.
docker run -d --name limited --memory=256m --cpus=0.5 nginx:1.27
CID=$(docker inspect -f '{{.Id}}' limited)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/pids.max
268435456
50000 100000
max
memory.max is 256MiB and cpu.max is 50ms per 100ms period. What happens when you exceed these values is what matters. CPU gets throttled; memory gets killed.
docker run --rm --memory=64m python:3.12-slim \
python -c "x = bytearray(200 * 1024 * 1024)"
echo "exit=$?"
exit=137
137 is 128 plus 9, that is, SIGKILL. The kernel's OOM killer terminated the process for exceeding the cgroup limit. The application receives no exception at all. So when a container silently restarts over and over, you need to look at the kernel log, not the application log.
dmesg -T | grep -i 'memory cgroup out of memory' | tail -2
[Sun Jul 26 14:31:07 2026] Memory cgroup out of memory: Killed process 51882 (python) total-vm:412508kB, anon-rss:66120kB
Here comes the famous trap. cgroups impose limits but do not mask /proc. /proc/meminfo and /proc/cpuinfo still show host values.
docker run --rm --memory=256m --cpus=0.5 debian:bookworm-slim \
sh -c 'free -m | head -2; nproc'
total used free shared buff/cache available
Mem: 64228 9112 41003 512 14113 54216
32
A container limited to 256MB sees 64GB, and a container limited to 0.5 cores sees 32 cores. A runtime that believes these values sizes its thread pools and heap against the host and dies of OOM immediately. The JVM solved this with UseContainerSupport, reading cgroups directly; Node's os.cpus() and Go's runtime.NumCPU() still return host values, so you have to set GOMAXPROCS explicitly or use a library like automaxprocs. There is also the option of attaching lxcfs to fake out /proc, but reading cgroups from the application side is far more robust.
The Actual Bill for Sharing a Kernel
That was the structure; from here on are the consequences.
First, you are coupled to the kernel version. No matter how new the glibc inside the image is, you cannot use a system call the kernel does not support. Run a modern base image on an old kernel and code using io_uring or new seccomp filters fails. There is a reverse direction too. On Docker older than 20.10 and CentOS 7 derivatives, running a modern glibc image where the default seccomp profile blocks clone3 and the container dies instantly was an incident that repeated for years.
Second, kernel parameters are shared. sysctls split into those that are namespaced and those that are not.
# Most net.* belong to the net namespace, so they can be set per container
docker run --rm --sysctl net.ipv4.tcp_syncookies=0 alpine:3.20 \
sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 0
# vm.* is not namespaced
docker run --rm --sysctl vm.max_map_count=262144 alpine:3.20 true
docker: Error response from daemon: failed to create task for container:
sysctl "vm.max_map_count" is not in a separate kernel namespace: unknown.
That is the error you meet when bringing up Elasticsearch as a container. The fix is not a container setting but running sysctl -w vm.max_map_count=262144 on the host. Which means this value is shared by every container on that node. On a VM it is a value you would have set independently per guest.
Third, the nature of the security boundary is different. For a guest to take over the host on a VM, it has to break through a bug in the hypervisor or in a virtual device model. In a container, the entire kernel system call interface is the attack surface. A single local privilege escalation vulnerability in the Linux kernel leads straight to container escape.
Real cases keep appearing. CVE-2019-5736 was an attack that overwrote the host's runc binary through /proc/self/exe from inside a container, and CVE-2024-21626 exploited runc starting a container without closing a file descriptor to reach the host filesystem through nothing more than WORKDIR manipulation. Both were bugs in the runtime rather than the kernel, but they worked because the container and the host sit under the same kernel.
That is why container isolation is not completed by namespaces alone but layered several deep. Docker's defaults already do a substantial part.
docker run --rm alpine:3.20 sh -c 'grep CapEff /proc/self/status'
CapEff: 00000000a80425fb
capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,
cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,
cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
Out of more than 40 capabilities, only 14 remain. On top of that come the default seccomp profile blocking roughly 44 system calls and an AppArmor or SELinux profile. In production, cutting further from here is standard practice.
docker run -d \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--read-only --tmpfs /tmp \
--user 10001:10001 \
api:v312
Conversely, there are options that tear down all of these defenses at once. --privileged grants every capability, turns off seccomp and AppArmor, and exposes host devices. Mounting the Docker socket into a container amounts to effectively the same thing. Anyone with access to that socket can start a new privileged container with the host root filesystem mounted.
The user namespace is the only switch that fundamentally changes this picture.
// /etc/docker/daemon.json
{
"userns-remap": "default"
}
With this, UID 0 inside the container maps to an unprivileged UID on the host, so even a successful escape does not immediately make you root. In exchange you gain constraints around volume ownership and some networking features.
What a VM Gives You More Of, and What It Takes More Of
A VM's isolation boundary is hardware virtualization, not system calls. The guest has its own kernel, and system calls handled by the guest kernel never reach the host kernel. To get from guest to host you have to break through the VM exit path and virtual device emulation, and that interface is far narrower than the roughly 300 system calls.
The price is clear. Every guest needs a kernel and an initialization sequence, so boot takes tens of seconds, and memory starts with hundreds of MB gone to the guest kernel and page cache alone. Memory assigned to a guest is generally held even when it is not actually used, and IO passes through one more layer of virtual devices.
Compare by density and the difference is obvious. On a 32GB node you can run more than 50 containers of 512MB, but on the same node, once guest overhead is counted, even half that number of VMs is difficult.
| Item | Container | VM | MicroVM (Firecracker) | gVisor |
|---|---|---|---|---|
| Kernel | Shared with host | Guest-only | Guest-only (lightweight) | Userspace kernel stands in |
| Start time | Tens of ms | Tens of seconds | About 125ms | Hundreds of ms |
| Memory overhead | Nearly none | Hundreds of MB | About 5MB | Tens of MB |
| Isolation boundary | System calls | Hardware virtualization | Hardware virtualization | System call interception |
| Running a different kernel | Not possible | Possible | Possible | Not possible |
| System call performance | Native | Close to native | Close to native | Slow |
| Suitable boundary | Same trust domain | Different orgs/tenants | Serverless multi-tenancy | Running untrusted code |
The Middle Ground — gVisor, Kata Containers, Firecracker
If you have to run customer-submitted code in a multi-tenant environment, you need something between the two poles. Three approaches have established themselves.
gVisor reimplements the Linux system call interface in userspace. A process called Sentry intercepts the container's system calls and handles them itself, passing only a very narrow subset to the host kernel. The host kernel attack surface shrinks dramatically, but syscall-heavy workloads slow down noticeably and some system calls are not supported at all.
docker run --rm --runtime=runsc alpine:3.20 dmesg | head -3
[ 0.000000] Starting gVisor...
[ 0.310495] Preparing for the zombie uprising...
[ 0.582901] Setting up VFS2...
Kata Containers boots a lightweight VM per container and runs the OCI container inside it. You use kata-runtime instead of runc, and on Kubernetes you can select it per workload with a RuntimeClass.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata
handler: kata
---
apiVersion: v1
kind: Pod
metadata:
name: untrusted-job
spec:
runtimeClassName: kata
containers:
- name: job
image: registry.example.com/untrusted:latest
Firecracker is the VMM AWS built for Lambda and Fargate. By reducing virtual devices to roughly network, block, serial, and keyboard, it brought boot down to about 125ms and memory overhead per VM to around 5MB. It is a design aimed at container density and VM isolation at the same time, and it has in fact become the standard answer for serverless platforms.
The selection criterion comes down to the trust boundary. If you are colocating services built by the same team on one node, namespace isolation is enough, and your time is better spent tightening seccomp and capabilities. If you run different customers' code or arbitrary user-submitted code on one node, kernel sharing is an unacceptable risk, so you add a microVM or gVisor, or separate nodes per tenant outright. If you need a different kernel version or a different OS, it is a VM without argument.
Closing — It Is Not a Difference in Strength of Isolation but in Kind
Containers and VMs are not two points, heavy and light, on the same axis. A container shares the kernel and takes the system call as its boundary, while a VM divides the kernel and takes hardware as its boundary. They are different in kind.
Hold on to that one thing and practical decisions become simple. If kernel versions differ or you must isolate untrusted code, the VM family; if the goal is running deployment units lightly and quickly, containers; if you need both, Kata or Firecracker. And from the moment you choose containers, instead of expecting the kernel to handle isolation for you, you have to tighten capabilities, seccomp, a read-only root, and an unprivileged user yourself.