Skip to content

Containers vs VMs

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.

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
VM stack vs container stack

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.

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 itself
Darwin 25.6.0
$ docker run --rm alpine:3.24 uname -sr # inside a container
Linux 6.12.76-linuxkit
$ docker info --format '{{.KernelVersion}}' # the engine
6.12.76-linuxkit

The 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:

scripts/namespaces.sh
#!/bin/sh
docker run -d --name docker-demo-a alpine:3.24 sleep 300
docker run -d --name docker-demo-b alpine:3.24 sleep 300
for 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'
done
docker rm -f docker-demo-a docker-demo-b
# container a # container b
pid:[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 ps

Every 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'
67108864
50000 100000

67108864 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 % PIDS
docker-b1-a 548KiB / 64MiB 0.84% 1
docker-b1-b 548KiB / 8.721GiB 0.01% 1

About 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.

  • 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.
  • 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 runc binary 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.

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
Image layers vs container layers

Deleting a container removes only its writable layer. The underlying image is untouched and can be used to spin up a fresh container immediately.

OptionBenefitCost
ContainerStarts fast (milliseconds), uses far fewer resources than a VMShares the kernel with the host, so isolation is weaker
VMMuch stronger isolation via the hypervisor, supports a different OS familyNeeds a full guest OS, boots slowly, uses a lot more RAM
  • 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.

An AI-written cache module for a Node service that “adapts to the container it runs in”:

🐛 Spot the bug

cache.js
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 page
console.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 limit
cpus 11 availableParallelism 1
constrainedMemory MiB 64 # what the cgroup really allows

So 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.js
budget MiB 2232
$ echo $?
137
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' docker-demo-oom
OOMKilled=true exit=137

Read the cgroup limit (Node exposes it as process.constrainedMemory(); it returns a huge number, not 0, when unlimited, so take the minimum):

cache.js
const os = require('os');
// Respect the cgroup limit when there is one
const 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).

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.

  • “A container has its own OS.” It has its own userland (files from the image) but no kernel. uname -r inside Alpine printed the Desktop VM’s 6.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 64m makes the app see 64 MB.” It only makes the kernel kill it above 64 MB. The app still reads 8930 MiB from /proc/meminfo unless 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.
What does a container share with the host that a VM does not?
Which of the following best describes why containers start faster than VMs?
You need to run a Windows workload on a Linux host. What should you use?
What is the relationship between a Docker image and a Docker container?
You start a container with --memory 64m. What does cat /sys/fs/cgroup/memory.max print inside it?
A Node service sizes its cache from os.totalmem() * 0.25 and runs with --memory 64m. What happens?