Containers vs VMs
The fundamental difference
Section titled “The fundamental difference”Both containers and virtual machines (VMs) solve the same problem — running software in an isolated environment — but they achieve isolation at different layers of the stack.
A VM boots a full guest operating system inside a hypervisor. Every VM carries its own kernel, system libraries, and init system. This makes VMs heavy: they take seconds (sometimes minutes) to start and consume hundreds of megabytes of RAM before your application even loads.
A container skips the guest OS entirely. It shares the host kernel and isolates only the process namespace, filesystem, and network using Linux kernel features (namespaces and cgroups). This makes containers lightweight: startup is measured in milliseconds and memory overhead is minimal.
Stack comparison
Section titled “Stack comparison”flowchart TB
subgraph VM["Virtual Machine stack"]
direction TB
vmApp["Application"] --> vmGuest["Guest OS (kernel + system libs)"]
vmGuest --> vmHyper["Hypervisor (VMware, KVM, Hyper-V)"]
vmHyper --> vmHost["Host OS Kernel"]
vmHost --> vmHw["Hardware"]
end
subgraph CT["Container stack"]
direction TB
ctApp["Application"] --> ctRt["Container Runtime (containerd / runc)"]
ctRt --> ctHost["Host OS Kernel"]
ctHost --> ctHw["Hardware"]
end The hypervisor layer and the guest OS are gone in the container model. Multiple containers on the same host share one kernel, making the per-container footprint dramatically smaller.
Prove it: what a container really is
Section titled “Prove it: what a container really is”Three commands settle the “shares the kernel” claim. Run them yourself; the output below is from Docker Desktop 4.81.0 on macOS (Engine 29.6.1, linux/arm64).
1. One kernel. The kernel a container reports is the engine host’s, not something inside the image:
$ uname -sr # on the Mac itselfDarwin 25.6.0$ docker run --rm alpine:3.24 uname -sr # inside a containerLinux 6.12.76-linuxkit$ docker info --format '{{.KernelVersion}}' # the engine6.12.76-linuxkitThe container reports the same kernel as docker info, and it is not the Mac’s Darwin kernel. Docker Desktop runs one small Linux VM (linuxkit) and every container on it shares that VM’s kernel. On a Linux server there is no VM: the container’s kernel is the server’s own. Containers do not boot a kernel; they borrow one.
2. Namespaces limit what a process can see. Each container gets its own namespace instances, so the same process in two containers has different namespace IDs, and its process table starts at PID 1:
#!/bin/shdocker run -d --name docker-demo-a alpine:3.24 sleep 300docker run -d --name docker-demo-b alpine:3.24 sleep 300for c in docker-demo-a docker-demo-b; do docker exec "$c" sh -c 'for n in pid net mnt uts; do readlink /proc/self/ns/$n; done; ps'donedocker rm -f docker-demo-a docker-demo-b# container a # container bpid:[4026534874] pid:[4026535010]net:[4026534876] net:[4026535012]mnt:[4026534871] mnt:[4026535007]uts:[4026534872] uts:[4026535008]PID USER TIME COMMAND 1 root 0:00 sleep 300 # PID 1 inside the container 7 root 0:00 psEvery namespace ID differs, and sleep 300 is PID 1 in its own process table (it is an ordinary process with some other PID in the host’s table). Add --pid host and the container shares the engine host’s process table instead: docker run --rm --pid host alpine:3.24 ps listed 457 processes, starting with /initd and kthreadd (the VM’s), which is what “namespace” means in practice, and why --pid host is a privilege you should not hand out.
3. cgroups limit what a process can use. --memory and --cpus become files under /sys/fs/cgroup:
$ docker run --rm --memory 64m --cpus 0.5 alpine:3.24 sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max'6710886450000 10000067108864 is 64 MiB in bytes; 50000 100000 is “50 ms of CPU per 100 ms period”, half a CPU. Without limits memory.max is the word max. This is the cgroup v2 interface (docker info reports Cgroup Version: 2).
Footprint. An idle sleep 300 container measured with docker stats --no-stream:
NAME MEM USAGE / LIMIT MEM % PIDSdocker-b1-a 548KiB / 64MiB 0.84% 1docker-b1-b 548KiB / 8.721GiB 0.01% 1About half a mebibyte and one process for an idle Alpine container; the second container has no limit so its “limit” is the VM’s 8.7 GiB. The alpine image is a single 8.66 MB layer shared by both (docker ps -s shows 0B (virtual 8.66MB) for each). We do not quote a comparable VM number here because we did not measure one on this machine; the structural difference (a VM adds a guest kernel and init) is what matters.
When to use containers
Section titled “When to use containers”- Microservices — each service ships as its own image, scales independently, and restarts without affecting siblings.
- CI/CD pipelines — ephemeral containers give every build a clean, reproducible environment that vanishes on completion.
- Consistent developer environments — the same image runs on a developer’s laptop, in CI, and in production, eliminating “works on my machine” bugs.
- Fast horizontal scale-out — new container instances start in milliseconds, making auto-scaling responsive.
When to use VMs
Section titled “When to use VMs”- Strong security isolation — every container shares one kernel, so a kernel or runtime bug can become an escape. This has happened: CVE-2019-5736 let a process inside a container overwrite the host’s
runcbinary and obtain host root (NVD; fixed in Docker 18.09.2). A VM’s hypervisor boundary is a smaller attack surface. - Different OS family — running Windows workloads on a Linux host (or vice versa) requires a VM because containers share the host kernel.
- Legacy applications — software that requires a specific kernel version, kernel module, or system-level configuration it cannot share with the host.
Images vs containers
Section titled “Images vs containers”These two terms are often confused. The distinction is precise:
- An image is a static, read-only blueprint — a layered filesystem snapshot that includes your application, its dependencies, and metadata. Images are built once and shared via registries.
- A container is a running (or stopped) instance of an image. When Docker starts a container it adds a thin, writable layer on top of the read-only image layers. You can run many containers from the same image simultaneously; they all share the same image layers but each gets its own writable layer.
flowchart TB
subgraph IMG["Image (read-only layers)"]
direction TB
i3["Layer 3: app binary"]
i2["Layer 2: python:3.14"]
i1["Layer 1: debian:bookworm-slim"]
i3 --> i2 --> i1
end
subgraph CON["Container = Image + writable layer"]
direction TB
cw["Writable layer (runtime changes)"]
c3["Layer 3: app binary (shared, read-only)"]
c2["Layer 2: python:3.14 (shared, read-only)"]
c1["Layer 1: debian:bookworm-slim (shared, read-only)"]
cw --> c3 --> c2 --> c1
end Deleting a container removes only its writable layer. The underlying image is untouched and can be used to spin up a fresh container immediately.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Container | Starts fast (milliseconds), uses far fewer resources than a VM | Shares the kernel with the host, so isolation is weaker |
| VM | Much stronger isolation via the hypervisor, supports a different OS family | Needs a full guest OS, boots slowly, uses a lot more RAM |
Common mistakes
Section titled “Common mistakes”- Assuming a container gives isolation as strong as a VM, when it still shares the same kernel as the host.
- Trying to run a workload that needs a different OS family (for example, Windows on a Linux host) with a container, when that case requires a VM.
- Confusing an image (a static blueprint) with a container (a running instance) when planning a deployment.
Reading AI-generated code
Section titled “Reading AI-generated code”An AI-written cache module for a Node service that “adapts to the container it runs in”:
🐛 Spot the bug
const os = require('os');// AI: "size the in-memory cache to 25% of RAM so it adapts to any environment"const budget = Math.floor(os.totalmem() * 0.25);console.log('budget MiB', Math.round(budget / 2 ** 20));const buf = Buffer.alloc(budget, 1); // touch every pageconsole.log('allocated', buf.length);Show the answer
A container shares the kernel, and /proc/meminfo (which os.totalmem() reads) is the VM’s memory, not the cgroup limit. Proven with a 64 MiB limit:
$ docker run --rm --memory 64m --cpus 0.5 node:24-alpine node -e "..."totalmem MiB 8930 # the Desktop VM, not the 64 MiB limitcpus 11 availableParallelism 1constrainedMemory MiB 64 # what the cgroup really allowsSo the script budgets 2232 MiB in a 64 MiB container and is killed by the kernel:
$ docker run --name docker-demo-oom --memory 64m --memory-swap 64m node:24-alpine node /app/cache.jsbudget MiB 2232$ echo $?137$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' docker-demo-oomOOMKilled=true exit=137Read the cgroup limit (Node exposes it as process.constrainedMemory(); it returns a huge number, not 0, when unlimited, so take the minimum):
const os = require('os');// Respect the cgroup limit when there is oneconst limit = process.constrainedMemory() || os.totalmem();const budget = Math.floor(Math.min(limit, os.totalmem()) * 0.25);console.log('budget MiB', Math.round(budget / 2 ** 20));const buf = Buffer.alloc(budget, 1);console.log('allocated', buf.length);With --memory 64m this prints budget MiB 16 / allocated 16777216 and exits 0. Other runtimes have their own container-awareness (the JVM reads the cgroup by default); check yours rather than trusting /proc/meminfo or nproc (nproc printed 11 in the same container).
Mental Model
Section titled “Mental Model”A VM virtualises hardware: a guest kernel believes it owns a small computer. A container virtualises the view of one kernel: a normal process, wrapped in namespaces (what it can see) and cgroups (what it can use). Anything the kernel does not virtualise — /proc/meminfo, the clock, the kernel version, a kernel bug — is shared and visible.
Common Misconceptions
Section titled “Common Misconceptions”- “A container has its own OS.” It has its own userland (files from the image) but no kernel.
uname -rinside Alpine printed the Desktop VM’s6.12.76-linuxkit. - “On a Mac, containers run on macOS.” They run in a Linux VM. Darwin never sees them, which is why bridge IPs and Linux paths behave differently from a Linux server.
- “
--memory 64mmakes the app see 64 MB.” It only makes the kernel kill it above 64 MB. The app still reads 8930 MiB from/proc/meminfounless the runtime reads the cgroup. - “Containers are always faster than VMs.” They avoid guest-kernel boot and duplication. CPU-bound work runs at the same speed; the win is start-up and density.