- Published on
What Actually Differs Between a Container and a VM — namespace, cgroup, and the Bill for Sharing a Kernel
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 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.