- 들어가며 — "가벼운 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, 읽기 전용 루트, 비특권 사용자를 직접 조여야 합니다.
현재 단락 (1/157)
컨테이너를 처음 배울 때 거의 모두가 같은 그림을 봅니다. 왼쪽에는 하이퍼바이저 위에 게스트 OS가 세 개 쌓여 있고, 오른쪽에는 도커 엔진 위에 컨테이너 세 개가 쌓여 있는 그림...