Containers vs VMs: Concepts
Why this matters
You're about to spend the rest of this track running containers without
a clear mental model of what one is, which leads to wrong intuitions
later (expecting a container to behave like a tiny VM, being surprised
when kill 1 inside a container stops the whole thing). Fifteen minutes
of concepts now saves hours of confusion later.
Concepts
A container is a normal Linux process
There's no container-specific execution mode in the Linux kernel. When
Docker "starts a container," it starts an ordinary process — the same
kind of process you'd see in ps on any Linux box. What makes it a
"container" is that, before that process runs, the kernel is asked to
give it a restricted view of the system. Everything else in this module
builds on that one idea.
Namespaces control what a process can see
A namespace limits what part of the system a process is allowed to observe. Docker uses several:
- A PID namespace makes the container's first process appear as PID 1 inside its own view, even though it has some large real PID on the host.
- A mount namespace gives it its own root filesystem view (built from image layers, module 02).
- A network namespace gives it its own loopback interface and routing
table — which is why containers get their own IP, and why
localhostinside a container does not mean "the host."
You already met this reasoning indirectly on the Linux track: a namespace is just "this process sees a private version of X."
Host reality What the container "sees"
real PID 48213 ──────────────────► PID 1 (via PID namespace)
host rootfs /var/lib/docker/... ──► / (via mount namespace)
veth + host IP 172.17.0.1 ───────► eth0 172.17.0.2 (net namespace)
localhost = only itself
Cgroups control what a process can use
Where namespaces limit what a process can see, cgroups (control
groups) limit what it can use: CPU shares, memory limits, and I/O
bandwidth. This is the same kind of resource-capping you did with
ulimit, nice, or ionice on the Linux track, except Docker applies
it per container on your behalf.
A VM virtualizes hardware instead
A virtual machine works at a completely different layer: it virtualizes hardware and boots a full, separate kernel on emulated (or hardware-assisted virtual) CPU, memory, and disk. Two VMs on one host are two fully independent operating systems that happen to share a physical machine. Two containers on one host are two processes that happen to share one kernel with different views of it.
CONTAINERS VIRTUAL MACHINES
┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ App A │ │ App B │ │ App A │ │ App B │
├──────────┤ ├──────────┤ ├──────────────┤ ├──────────────┤
│ bins/libs│ │ bins/libs│ │ bins/libs │ │ bins/libs │
└────┬─────┘ └────┬─────┘ ├──────────────┤ ├──────────────┤
│ │ │ Guest OS │ │ Guest OS │
│ namespaces │ │ + kernel │ │ + kernel │
│ + cgroups │ └──────┬───────┘ └──────┬───────┘
┌────▼────────────▼────┐ ┌───────▼─────────────────▼───────┐
│ Shared host kernel │ │ Hypervisor │
├──────────────────────┤ ├─────────────────────────────────┤
│ Hardware │ │ Hardware │
└──────────────────────┘ └─────────────────────────────────┘
one kernel, many views one kernel per VM, hardware-virtualized
The apartment-building analogy
Think of a Linux host as an apartment building. VMs are like separate buildings, each with its own foundation, plumbing, and electrical system (own kernel) — completely isolated, but expensive and slow to construct. Containers are like apartments in the same building, sharing the foundation and utilities (one kernel), but each with its own locked door, address, and utility meter (namespaces + cgroups) — much cheaper to create, but fundamentally dependent on that one shared foundation being sound.
Consequences that follow directly
- Containers start in milliseconds (process creation + namespace setup) vs. seconds-to-minutes for a VM (full kernel boot).
- A container image is generally an order of magnitude smaller than a VM image, because it doesn't bundle a kernel.
- All containers on one Docker host share the same kernel. (Docker Desktop on Windows runs a real Linux kernel inside the WSL2 VM to make this work — which is why Windows containers are a wholly separate, rarer thing you won't touch here.)
- If a container's PID 1 exits, the container exits — there's no separate "OS" still running underneath it, unlike a VM.
- Isolation is real but shallower than a VM's: a shared kernel means a kernel-level vulnerability can potentially cross container boundaries in a way it can't cross VM boundaries. This is why module 09 (security) matters.
In Docker Desktop: when you run the containers in this module's exercises, open the Containers tab to watch them appear and disappear. A short-lived container that exits immediately will flash in and out of the list; a
sleep 300container staysRunninguntil you stop it. That list is the GUI view of the same process states this module is describing.
Command reference
| Command | What it does | Example |
|---|---|---|
docker run --rm -it <image> <cmd> |
Runs a container attached to your terminal, auto-removes it on exit | docker run --rm -it ubuntu bash |
ps aux (inside container) |
Lists processes as seen inside the container's PID namespace | ps aux |
hostname (inside container) |
Shows the container's hostname, which defaults to its container ID | hostname |
docker run --rm <image> sh -c "cmd1; cmd2" |
Runs multiple shell commands in one throwaway container | docker run --rm alpine sh -c "cat /etc/os-release; uname -a" |
uname -a |
Shows kernel version — identical between host and container, proving the shared kernel | uname -a |
docker stats |
Live view of each container's CPU/memory usage against its cgroup limits | docker stats |
Flag breakdown for docker run --rm -it ubuntu bash:
--rm— automatically deletes the container when it exits, so you don't accumulate stopped containers for these throwaway experiments.-i— keeps STDIN open so you can type into the container.-t— allocates a pseudo-TTY so the shell behaves like a real terminal (prompt, line editing).-itis just those two combined, and it's the standard pair for "give me an interactive shell."ubuntuis the image;bashis the command to run instead of the image's default.
In Docker Desktop:
docker statshas a GUI equivalent — click a running container in the Containers tab and its detail view shows live CPU and memory usage against the cgroup limits, the same numbersdocker statsstreams in the terminal.
Hands-on exercises
(WSL2 Ubuntu terminal) Compare kernels. Run on the host:
uname -aThen run the same command inside a container:
docker run --rm ubuntu uname -aExpect the kernel version (the
x.x.x-microsoft-standard-WSL2part) to be identical in both outputs — proof the container isn't running its own kernel.(WSL2 Ubuntu terminal) See the PID namespace in action. Start an interactive container:
docker run --rm -it ubuntu bashInside it, run:
ps auxExpect to see only a couple of processes, with
bashas PID 1. Typeexitto leave.(WSL2 Ubuntu terminal) Now look at the same container from the host's perspective. Start a long-running container:
docker run --rm -d --name pidtest ubuntu sleep 300Flag breakdown:
-druns it detached (in the background) so your prompt returns;--name pidtestgives it a stable name to refer to;sleep 300is the command it runs. Now find its real PID on the host:docker inspect --format '{{.State.Pid}}' pidtestExpect a large PID number — nowhere near 1 — proving that "PID 1 inside the container" is a namespace illusion, not a literal special process. Clean up:
docker rm -f pidtest.(Docker Desktop GUI, then CLI) Observe that PID 1 exiting kills the container, using the GUI to watch it happen. First start a container that exits on its own:
docker run --name shortlived ubuntu bash -c "sleep 5 && exit 0"While it runs, open Docker Desktop's Containers tab and watch
shortlived: it showsRunningfor about five seconds, then flips toExited (0)on its own — no separate "OS" lingers once PID 1 (thebash -c ...) finishes. Cross-check from the CLI:docker ps -a --filter name=shortlivedExpect the same
Exited (0)status the GUI showed. Clean up:docker rm shortlived.(WSL2 Ubuntu terminal) Explore network namespace isolation. Start two containers and check their IPs:
docker run --rm alpine hostname -i docker run --rm alpine hostname -iExpect two different IP addresses (each container gets its own network namespace with its own interface), even though both ran the same image seconds apart.
(WSL2 Ubuntu terminal) Compare startup time to get a feel for "milliseconds vs. seconds/minutes." Time a container start:
time docker run --rm alpine echo "hi"Expect the
realtime to be well under a second (after the image is already pulled — run it twice if the first run includes a pull). There is no equivalent "boot a VM in under a second" — that's the structural difference in practice, not just in theory.(WSL2 Ubuntu terminal) See cgroup memory limits enforced. Run a memory-constrained container and read its limit from inside:
docker run --rm -m 50m ubuntu bash -c "cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes"Flag breakdown:
-m 50msets a hard memory cap of 50 MB via a cgroup. Expect a number corresponding to roughly 50 MB (52428800 bytes) — the cgroup limit Docker set for you, readable from inside the container the same way you'd inspect any cgroup on a bare Linux box.(WSL2 Ubuntu terminal) Watch live resource usage against limits. Start a container, then read its stats:
docker run -d --name statstest -m 100m alpine sleep 300 docker stats --no-stream statstestFlag breakdown:
--no-streamprints a single snapshot and returns instead of continuously updating. Expect aMEM USAGE / LIMITcolumn showing usage against the100MiBlimit you set. For the live version, clickstatstestin Docker Desktop's Containers tab and watch the same figures update in its detail view. Clean up:docker rm -f statstest.(WSL2 Ubuntu terminal) Diagnose and fix: a "container that won't die." Start a container that ignores the normal stop signal:
docker run -d --name stubborn ubuntu bash -c "trap '' TERM; sleep 600" docker stop -t 3 stubborn docker ps -a --filter name=stubbornFlag breakdown:
docker stop -t 3sendsSIGTERM, then waits 3 seconds for a graceful exit before escalating toSIGKILL. Expectdocker stopto wait ~3 seconds, fail to get a graceful exit (the trap ignoresSIGTERM), and fall back toSIGKILL— the container ends upExited. Notice the parallel to a Linux process ignoringSIGTERMand needingSIGKILL(kill -9) — same signal model, because a container process is a Linux process. Confirm cleanup:docker rm stubborn.(WSL2 Ubuntu terminal) Contrast image size with VM-image size as a proxy for the structural difference. Compare:
docker images ubuntu docker system df -v | head -n 20Note the
ubuntuimage size (tens of MB) — a comparable general-purpose VM image (an Ubuntu cloud.vhd/.qcow2) is typically several hundred MB to a few GB, because it must include a bootable kernel and full boot infrastructure a container image doesn't need. You can see the same image size in Docker Desktop's Images tab, in theSizecolumn next toubuntu.
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: This module explained cgroups as "what a process can use" and stated that a container's PID 1 exiting takes the whole container down. Put both ideas together in one experiment: start a container with a hard memory cap well below what a memory-hungry process would need, then run something inside it that deliberately tries to allocate more memory than that cap. Watch what happens to the container, and explain the outcome in terms of both the cgroup limit (this module) and the fact that the container's life is tied to its PID 1 process. Tie it back to module 00 as well: relate the per-container cap you set to the global VM-wide memory limit Docker Desktop enforces in its Settings, and reason about why one is per-container and the other is not.
Stuck? One hint
Run a container with a low -m cap and give it a command that keeps allocating memory (a short loop that appends to a growing list, or a tool that stresses memory) — then check the container's exit status and reason about which process died and why.
Common mistakes & troubleshooting
- Expecting
docker execto "log into a machine." It attaches a new process into an existing container's namespaces — closer to running a second command inside an already-running process's world than SSHing into a separate box. - Assuming a stopped container still uses CPU/memory. It doesn't — once PID 1 exits, the container is fully stopped, same as any exited Linux process; only its filesystem layer and metadata remain on disk until removed.
- Thinking containers are "lightweight VMs." This mental model leads people to expect kernel-level isolation guarantees a container doesn't give — see module 09 for why running untrusted code in a container is not equivalent to running it in a VM.
- Forgetting a container's
localhostis its own, not the host's.curl localhost:8000from inside a container will not reach a server running on your host at port 8000 — that requires networking (module- or
host.docker.internal.
- or
- Confusing image size with "how much RAM this will use." Image size is disk footprint from layers (module 02); memory usage is runtime and controlled separately by cgroup limits.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What two Linux kernel features does Docker use to create the isolation a container has?
- Why does a container's main process appear as PID 1 inside the container but have a completely different PID when viewed from the host?
- What happens to a container when its PID 1 process exits, and why is this different from a VM?
- Why do containers typically start in milliseconds while VMs take seconds or minutes?
- Name one isolation guarantee a VM gives you that a container does not, and explain why (in terms of kernels).
- If two containers run the exact same image, will they have the same IP address? Why or why not?
- Where in Docker Desktop can you watch a container's live CPU/memory usage, and which CLI command shows the same thing?
Show answers
- Namespaces (what the process can see: PID, mount, network, etc.) and cgroups (what the process can use: CPU, memory, I/O).
- The PID namespace gives the container process its own numbering scheme starting from 1, but the kernel still tracks it with a real, host-wide PID underneath — the "PID 1" view is scoped to that namespace only.
- The container stops entirely, because there's no separate kernel or OS still running underneath it — unlike a VM, where the guest OS and its other processes keep running even if one process inside it dies.
- Starting a container is just creating a process with some namespace and cgroup setup (fast, kernel-native); starting a VM means booting an entire separate kernel and OS from scratch.
- Kernel-level isolation: a VM has its own kernel, so a kernel exploit in the guest doesn't directly give access to the host kernel; all containers on a host share one kernel, so a severe enough kernel vulnerability can potentially be exploited across container boundaries.
- No — each container gets its own network namespace and its own interface/IP assigned when it starts, regardless of which image it was created from.
- Click the container in the Containers tab to open its detail view,
which shows live CPU and memory; the CLI equivalent is
docker stats(add--no-streamfor a single snapshot).
Further reading & sources
- Docker: What is a container? - Docker's own overview of containers vs VMs and where the isolation comes from.
- man7: namespaces(7) - the authoritative Linux manual page on the PID, mount, and network namespaces that make a process a "container".
- man7: cgroups(7) - the manual page for the control groups that cap a container's CPU, memory, and I/O.
- Docker: Container runtime overview - how the Docker engine turns these kernel features into the containers you run.
- DigitalOcean: The Docker ecosystem — containers vs VMs - a beginner-friendly tutorial reinforcing the shared-kernel model.
Next
Continue to 02-images-and-containers to start actually working with images and the container lifecycle.