AWS Builder Center
Docker Series: Containers Are Not VMs

Docker Series: Containers Are Not VMs

Three lessons on what a container actually is and why the "lightweight VM" mental model will get you into trouble in production.

Cloud Infrastructure Engineer
A new engineer joins the team. They SSH into a production Kubernetes node and run ps aux. Thousands of lines scroll past: nginx workers, Python processes, Redis, their own shell, and hundreds of processes they don't recognize. They ask the senior on call: "Are all these from the host, or from the containers?"
The senior shrugs. "Both. Every container you see in kubectl get pods is just a group of processes down there."
The new engineer looks confused. "But containers are isolated. They're like tiny VMs, right?"
They're not. And carrying the "lightweight VM" mental model into production is the source of more confusion than almost any other misconception in modern infrastructure. This three-part series resets that model from the ground up: what a container actually is, the three Linux primitives it's built from, and what "Docker" even means once you strip away the branding.

Lesson 1: Containers Are Not VMs

Containers do not have their own kernel. They do not have their own network stack (unless you count a namespace-scoped slice of the host's). They do not boot. They have no BIOS, no UEFI, no init ramdisk, no bootloader. A container is a process or a tree of processes that the kernel has made look isolated by lying about what it can see. The kernel running inside the container is the same kernel running on the host.
Once you see that, everything else about Docker becomes easy. Images, volumes, networking, security, debugging — they all collapse into standard Linux concepts with a thin wrapper around them. This lesson is the mental reset.

The one-picture difference

VM-style host: Each app sits on top of its own guest OS (its own kernel), and every guest sits on top of a hypervisor, which sits on the host OS/hypervisor kernel, which sits on the hardware.
Container-style host: Each app is just a process, with an isolated view of the system but every process shares one Linux kernel, which sits directly on the hardware.
VMs virtualize hardware. Each guest has its own kernel, its own memory manager, its own scheduler, its own device drivers. The hypervisor's job is to give every guest the illusion of its own computer.
Containers virtualize nothing. There is one kernel, one scheduler, one set of device drivers. The kernel uses namespaces and cgroups to show each container a custom view of the host, but underneath that view, every read(), every clone(), every page fault is handled by the same kernel for every container on the machine.
KEY CONCEPT A VM runs a kernel. A container runs as a process. This is the single most important mental model shift. VMs have boot sequences, guest operating systems, and virtualized hardware. Containers have namespaces, cgroups, and a pivoted rootfs — plain Linux features you can invoke by hand from a shell. Every Docker concept collapses into this one distinction.

What is shared, what is not

WhatVMContainer
KernelSeparate per guestShared with host and all other containers
Device driversIn the guest kernelIn the host kernel
CPU schedulerGuest schedules guest threads; host schedules guestsHost scheduler handles all container processes
Memory managerGuest memory manager + hypervisorHost kernel only
FilesystemEntire disk imageOverlay layers on top of host filesystem
Network stackVirtual NIC + guest TCP/IPHost TCP/IP with namespaced interfaces
Boot timeSeconds to minutes (full OS boot)Milliseconds (just a fork + exec)
Image sizeGB (includes whole OS)MB (just the app + deps)
Memory overheadHundreds of MB per VMA few MB per container
Isolation strengthStrong (separate kernel)Weaker (shared kernel = shared attack surface)
The sharing is the whole point. Containers are fast because there's no kernel to boot, no drivers to load, no BIOS to initialize. They're small because you ship only the userspace. They're dense you can run hundreds of containers on one host because they cost almost nothing beyond the process itself.
The trade-off is the isolation story. A kernel bug that affects containers is still there for every container on the host. A container escape (a user inside a container gaining access to the host) usually comes from a kernel-level vulnerability, which is why some workloads still use VMs — or VM-per-container technologies like Firecracker, Kata, or gVisor — for stronger isolation.

See it with your own eyes

Everything above is testable with nothing more than ps and some grep.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Start a simple container in the background
docker run -d --name demo alpine sleep 1000

# Find its PID on the host (yes — the host can see the container's PID)
docker inspect --format='{{.State.Pid}}' demo
# 18342

# Confirm: the container's main process is a normal host process
ps -p 18342 -o pid,ppid,user,cmd
# PID PPID USER CMD
# 18342 18320 root sleep 1000

# The whole "container" is just a cgroup containing this PID
cat /proc/18342/cgroup
# 0::/system.slice/docker-abc123def456....scope

# And its namespaces
ls -l /proc/18342/ns
# lrwxrwxrwx 1 root root 0 ... cgroup -> 'cgroup:[4026531835]'
# lrwxrwxrwx 1 root root 0 ... ipc -> 'ipc:[4026532152]'
# lrwxrwxrwx 1 root root 0 ... mnt -> 'mnt:[4026532150]'
# lrwxrwxrwx 1 root root 0 ... net -> 'net:[4026532155]'
# lrwxrwxrwx 1 root root 0 ... pid -> 'pid:[4026532153]'
# lrwxrwxrwx 1 root root 0 ... uts -> 'uts:[4026532151]'

# Compare the kernel seen from inside the container vs the host
docker exec demo uname -r
uname -r
# Both return the same number — the host kernel

docker rm -f demo
PRO TIP docker inspect --format='{{.State.Pid}}' NAME gives you the host-side PID for a container's main process. From there you can reach every debug tool you know — ps, cat /proc/[pid]/*, nsenter, strace — and operate on the container as if it were any other process on the host. Most "I can't debug inside the container because the image is too minimal" problems disappear once you realize you don't have to go inside.

Why this matters in production

The "shared kernel" fact reshapes every production decision you make about containers.
Kernel-level bugs affect every container on the host. Spectre, Meltdown, CVE-2022-0185 (cgroup v1 vulnerability), the dirty pipe vulnerability — a single kernel patch is all your containers waiting on a host-kernel patch.
Resource limits are kernel features. When you say "limit this container to 2 CPUs and 4 GiB of memory," you're setting cgroup values. There's no guest kernel to convince — the host's cgroup controllers enforce the limit directly.
Syscall profiles matter. seccomp is a list of allowed syscalls. A container is sandboxed partly by denying certain syscalls at the kernel level. This is why --privileged is dangerous: it disables seccomp and gives the process full syscall access.
Docker is not a security boundary by itself. Kubernetes runs both trusted and semi-trusted workloads on the same kernel. For untrusted workloads you want a VM boundary — Firecracker microVMs, Kata Containers, or AWS Lambda's Firecracker backend.
Performance is almost native. Because there's no guest kernel or hypervisor, container CPU and memory performance is within 1–2% of bare metal. I/O performance depends on the driver (overlayfs, bind mounts, network).
WAR STORY A team ran their Kubernetes cluster on Ubuntu 20.04 nodes. The application ran in Alpine-based images. A critical musl-libc bug affected Alpine's DNS resolver specifically, but the ops team was sure the fix was "wait for Ubuntu to patch its kernel." Four days of production incidents later, someone pointed out that the resolver is in userspace (inside the container image), not the kernel. A one-line FROM-tag bump in the Dockerfile and a rebuild fixed the issue. The lesson: kernel bugs are fixed on the host; userspace bugs are fixed in the image. Knowing which is which is fundamental.

When you want VMs, not containers

If your mental model was "containers = lightweight VMs," you probably never thought carefully about when to choose a VM instead. With the shared-kernel model, the decision sharpens:
Use a VM when…Use a container when…
Running untrusted code (customer workloads on a shared host)Running your own code at scale
You need a different OS or kernel versionYour workload runs fine on the host kernel
You need stronger security isolation (PCI, HIPAA, defense)You accept shared-kernel risk for speed/density
You need hardware emulation (GPU passthrough, specific NIC behavior)You can run on the host's hardware directly
Boot once per hour, run for daysStart in ms, die in seconds, repeat
Memory/CPU overhead is acceptableYou need to pack hundreds of workloads on a node
Most modern infrastructure uses both. Kubernetes nodes are often VMs (for cloud isolation from the provider). The workloads inside those VMs are containers (for fast deploy and density). Firecracker-based systems do container-per-microVM, getting both benefits at the cost of more complexity.

Key concepts summary

  • Containers are processes, not VMs. They share the host kernel. There is no guest OS.
  • VMs virtualize hardware; containers virtualize views. The kernel uses namespaces and cgroups to show each container a tailored slice of the host.
  • uname -r inside a container returns the host kernel — the clearest proof the kernel is shared.
  • Shared kernel means shared attack surface. Kernel CVEs affect every container on the host; userspace CVEs live in the image.
  • Containers are fast because they skip the kernel: no boot, no drivers, no BIOS, just a fork + exec + cgroup assignment.
  • Resource limits are cgroup values, set and enforced by the host kernel. There's no guest to negotiate with.
  • For untrusted workloads or exotic OS needs, choose VMs. For your own code at scale, containers win on every axis except strong isolation.
  • Debug containers with host tools — ps, cat /proc/[pid]/*, nsenter, strace — they all work because the container is just a process.

Common mistakes

  • Saying "the container's OS" when you mean "the image's userspace." The image contains libc, coreutils, package managers — but no kernel.
  • Assuming container resource limits are separate from host limits. They're not — they're host-kernel cgroups, enforced by the host.
  • Treating the container as a black box that needs its own monitoring. Standard ps, top, iostat, ss, dmesg on the host see everything.
  • Running --privileged because "containers are isolated anyway." --privileged disables kernel-level isolation and is effectively running the process on the host.
  • Expecting a Windows container to run on a Linux host. The kernel is Linux; a Windows binary's syscalls have nothing to call.
  • Forgetting that overlayfs — the thing that makes container images layered — is a host kernel feature. If it's buggy on your host, every container on the host is affected.
  • Using "Docker" as a synonym for "container." Docker is one implementation of the OCI spec; containerd, CRI-O, podman, and runc are others. The underlying primitives are identical.

Lesson 2: The Three Primitives — Namespaces, cgroups, chroot

A junior on the team asks: "What does Docker actually do? Like, if I ran docker run alpine sh, what's happening on the host?" The senior pauses, opens a terminal, and types three commands — unshare, pivot_root, echo PID > cgroup.procs. Thirty seconds later there's a new shell that thinks it's PID 1, sees a different filesystem, and is capped at one CPU. "That," the senior says, "is what Docker does. Those three things. Everything else is plumbing."
Every container runtime — Docker, containerd, runc, podman, Kubernetes' CRI implementations — is built on three Linux primitives: namespaces (isolation), cgroups (resource limits), and a pivoted rootfs (filesystem view). Once you've seen how they compose, "what is a container" goes from mystery to recipe.

The recipe, in one page

A container is:
  1. A process (spawned via clone())…
  2. …placed in new namespaces (to isolate its view)…
  3. …with its root filesystem pivoted (so its / is the image's rootfs, not the host's)…
  4. …attached to a cgroup (to limit CPU, memory, I/O)…
  5. …with capabilities dropped and optionally a seccomp filter (to limit what syscalls it can make).
That's it. Every container runtime does those five steps. The Linux kernel provides all of them directly — no virtualization, no emulation. This is why you can build a container by hand from a shell, and why Docker is "just" a user-friendly wrapper around kernel features.
KEY CONCEPT Docker did not invent containers. LXC had most of this before Docker existed; FreeBSD jails had a subset in 2000; Solaris zones had it in 2004. What Docker invented was a friendly CLI (docker run), a standard image format, and a registry for distribution. The isolation primitives are Linux kernel features, not Docker features — which is why Docker can be replaced with containerd, runc, or podman without changing how containers behave.

Primitive 1: Namespaces — "the kernel lies to the process"

Namespaces isolate what a process can see. There are eight kinds, of which Docker uses seven by default:
NamespaceWhat it isolatesWhat the container sees vs. the host
mntMount tableOwn /, /etc, /var — the image's rootfs
pidPID number spaceContainer's main process is PID 1
netNetwork interfaces, routes, socketsOwn eth0 (or none), own routing table
utsHostname + NIS domainContainer has its own hostname
ipcSystem V IPC + POSIX message queuesNo shared memory with host processes
userUID/GID mappings (optional)Container's root can be unprivileged on host
cgroupView into /sys/fs/cgroupCgroup paths rooted at the container's cgroup
Every docker run creates a new set of these and puts the container's process inside them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Start a container
docker run -d --name demo alpine sleep 1000
PID=$(docker inspect --format='{{.State.Pid}}' demo)
echo "Container PID on host: $PID"

# Inspect the namespaces the container is in
ls -l /proc/$PID/ns | head
# lrwxrwxrwx 1 root root 0 ... mnt -> 'mnt:[4026532421]'
# lrwxrwxrwx 1 root root 0 ... net -> 'net:[4026532422]'
# lrwxrwxrwx 1 root root 0 ... pid -> 'pid:[4026532423]'
# ...
# Each inode number is a distinct namespace.

# Compare with host PID 1's namespaces (systemd on most systems)
diff <(ls -l /proc/1/ns | awk '{print $9, $11}') \
<(ls -l /proc/$PID/ns | awk '{print $9, $11}')
# Differs on: mnt, net, pid, uts, ipc, (possibly user, cgroup)

# Hop into the container's namespaces with nsenter — no docker exec needed
sudo nsenter -t $PID -n ip addr
# eth0@if8 ... 172.17.0.2/16 <- the container's network view

sudo nsenter -t $PID -m ls /
# bin etc lib usr ... <- the container's filesystem view

docker rm -f demo
PRO TIP nsenter -t <host-pid> -n <cmd> is the single most useful command for debugging container networking from the host. You get host-side tools (ip, ss, tcpdump, ping) with the container's network view. No need to install anything inside the minimal container image — you already have everything on the host.

Primitive 2: cgroups — "the kernel limits the process"

Where namespaces isolate views, cgroups limit resource usage. Every container is placed in a cgroup, and Docker's -m / --memory, --cpus, --pids-limit, --blkio-weight flags translate directly to cgroup files on the host.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Start a container with specific limits
docker run -d --name limited --memory=512m --cpus=1 alpine sleep 1000

# The cgroup
cat /proc/$(docker inspect --format='{{.State.Pid}}' limited)/cgroup
# 0::/system.slice/docker-<long-hash>.scope

# Memory limit on cgroup v2
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max
# 536870912 <- 512 MiB in bytes

# CPU limit (quota period microseconds)
cat /sys/fs/cgroup/system.slice/docker-*.scope/cpu.max
# 100000 100000 <- 100ms per 100ms = 1 CPU

# Current usage
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-*.scope/cpu.stat

docker rm -f limited
The cgroup does three things at once: it accounts for resource usage (memory.current, cpu.stat), it limits resource usage (processes get OOM-killed or throttled past the limit), and it contains the process tree (every child of the container inherits membership). docker stats is just a prettier version of reading these cgroup files.
1
2
3
4
5
docker run -d --name demo --memory=256m nginx
docker stats --no-stream demo
# CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
# abc123... demo 0.01% 12.3MiB / 256MiB 4.80% ... ... 5
docker rm -f demo
WARNING Resource limits are enforced by the kernel. When a container exceeds --memory, the kernel's OOM killer picks a process in that cgroup and kills it. If your container's main process is PID 1 and gets picked, the whole container dies. The OOM killer doesn't look at host memory pressure — it looks at the container's cgroup memory. This is why exit code 137 (SIGKILL) in docker ps -a almost always means "the container hit its memory limit."

Primitive 3: The pivoted rootfs — "the container sees a different /"

The container's filesystem is not a copy of the image. It's an OverlayFS mount composed of a read-only lower layer (the image's layers as unpacked on the host), a writable upper layer (unique to this container, for any changes it makes), and a merged view presented to the container as /.
When the container is created, Docker does the equivalent of:
1
2
3
4
mkdir /var/lib/docker/overlay2/<container-id>/merged
mount -t overlay overlay -o lowerdir=...,upperdir=...,workdir=... /var/lib/docker/overlay2/<container-id>/merged
# pivot_root into the merged dir so the container sees it as /
# (old root is unmounted so the host FS is no longer visible from the container)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Look at a running container's rootfs from the host
docker run -d --name demo alpine sleep 1000
docker inspect --format='{{.GraphDriver.Data.MergedDir}}' demo
# /var/lib/docker/overlay2/<long-hash>/merged

sudo ls /var/lib/docker/overlay2/<long-hash>/merged
# bin etc home lib mnt opt proc root run sbin srv sys tmp usr var
# This is exactly what the container sees as its /

# The container's changes (writes) go to the upper layer
sudo ls /var/lib/docker/overlay2/<long-hash>/diff
# (empty — nothing written yet)

docker exec demo touch /new-file
sudo ls /var/lib/docker/overlay2/<long-hash>/diff
# new-file <- the write landed in the upper layer

docker rm -f demo
KEY CONCEPT OverlayFS is how containers get both "ship a small image" and "let the container write to files." Only writes create copies; reads of unchanged files come straight from the read-only layers. This is copy-on-write, and it's the reason you can run 100 containers from one image without using 100× the disk space.

The full picture: what docker run does

Every step below is a standard Linux operation — Docker is just the orchestrator:
  1. CLI → daemon. The docker CLI sends the request to dockerd.
  2. Image ready? The daemon checks whether the image is already pulled locally.
  3. Create rootfs. An OverlayFS mount is built from the image layers.
  4. Create the container via containerd/runc.
  5. Execute the entrypoint.
  6. Networking — interfaces and routes are wired into the new net namespace.
  7. Run until exit.

Why these three primitives are enough

Namespaces + cgroups + a pivoted rootfs together give you isolation (the container sees its own processes, network, filesystem, hostname, IPC), limits (the container can't use more CPU, memory, or I/O than its cgroup allows), packaging (the rootfs is whatever tarball you shipped), portability (the same image runs anywhere there's a Linux kernel that understands the same namespaces and cgroups), and speed (start-up is fork + exec + cgroup-attach + overlay-mount — milliseconds, not seconds).
That's the whole container promise, delivered with plain kernel features. No virtual machine monitor, no hardware emulation, no guest OS.
WAR STORY A team spent a week convinced Docker had a resource leak. Containers would accumulate memory on the host until OOMs struck. The team blamed the Docker daemon, upgraded it, rebooted nodes — nothing helped. Finally someone ran ls /sys/fs/cgroup/system.slice/ | wc -l and saw 40,000 orphaned cgroups from containers that had never been properly cleaned up. Root cause: a kernel bug combined with a debug flag Docker had enabled. The fix was one line in /etc/docker/daemon.json. The lesson: when Docker "misbehaves," it's almost always the underlying primitive (cgroup, namespace, overlayfs, or kernel) that's misbehaving. Docker itself is a thin orchestrator — the real story is one layer down.

Key concepts summary

  • A container is a recipe of five Linux features: new namespaces, a new cgroup, a pivoted rootfs, dropped capabilities, and a seccomp filter.
  • Namespaces isolate what a process sees — mount, PID, net, UTS, IPC, user, cgroup — Docker uses all seven by default.
  • cgroups limit how much a process can use. Docker's --memory, --cpus, --pids-limit map directly to cgroup files.
  • OverlayFS is how containers get copy-on-write filesystems: read-only lower layers (the image) + writable upper layer (container changes) + merged view.
  • Capabilities split root's power. Default Docker drops most caps, so even root inside a container has limited privileges.
  • seccomp blocks dangerous syscalls. The default profile blocks ~60; --privileged and --security-opt seccomp=unconfined turn it off.
  • Docker is orchestration — the primitives are all kernel features. Replacing Docker with containerd, runc, or podman changes the CLI and daemon, not what containers are.
  • You can debug containers with host tools — nsenter, /proc/[pid], /sys/fs/cgroup/<scope> — without ever entering them.

Common mistakes

  • Assuming Docker implements isolation. It doesn't — the kernel does. Docker asks the kernel for namespaces and cgroups and wires them up.
  • Running --privileged containers "for convenience." This disables seccomp, gives back all capabilities, and exposes /dev from the host. The container is effectively a host process.
  • Confusing image layers with cgroups. Images are how your code is packaged (OverlayFS); cgroups are how your code is limited at runtime. Different primitives, different files on disk.
  • Forgetting the container's PID on the host when debugging. docker inspect --format='{{.State.Pid}}' is faster than docker exec for reading proc-level info.
  • Thinking docker stats is magic. It reads /sys/fs/cgroup/<scope>/memory.current, cpu.stat, io.stat and formats them — you can read the files directly and bypass Docker entirely.
  • Treating every runtime as "Docker." Kubernetes clusters often run containerd or CRI-O directly. The containers behave identically — same primitives — but the daemon and CLI differ.

Lesson 3: What Docker Actually Is

A Kubernetes cluster is throwing "Docker is deprecated" warnings on every upgrade. A platform engineer asks their team: "Should we worry?" Someone says "Kubernetes is removing Docker support." Someone else says "Docker Inc. went bankrupt." Another says "We have to migrate all our images." All three are wrong, in different ways. Kubernetes removed dockershim, an internal adapter it no longer needed, but continues to run containers built by Docker, on the exact same runtime (containerd) Docker itself has been using for years. Nothing about the images, the Dockerfiles, or the runtime behavior changed. Only one small piece of glue code.
That confusion is the entire reason this lesson exists. "Docker" has been many things across its history — a product, a company, a CLI, a daemon, a whole ecosystem. Today it's four distinct layers, and the "Docker removal" conversation only makes sense once you can name them.

"Docker" is four things

When someone says "Docker," they could mean:
  1. The CLI — the docker binary you type commands into.
  2. The daemon (dockerd) — a long-running process that manages images, containers, networks, and volumes. The CLI talks to it over a Unix socket.
  3. The runtime(s) — the lower-level components that actually start containers. This is a layered stack: containerd (high-level runtime) + runc (low-level runtime). Docker's daemon delegates to these.
  4. The company and ecosystem — Docker Inc., Docker Hub, Docker Desktop, and the marketing of "containers" as a platform.
Confusing these four is why "Kubernetes is removing Docker" was misinterpreted for months. Kubernetes removed one piece of plumbing between itself and the runtime; the images, the Dockerfiles, the registries, and the containers themselves kept working exactly as before.

The layered architecture

  • docker CLI — the user-facing command ("I want nginx running"), talking over HTTP/Unix socket to —
  • dockerd (daemon) — high-level orchestration: image pulls, builds (docker build), networking (the docker0 bridge), volumes, logs, the HTTP API surface. It talks over gRPC to —
  • containerd — the mid-level runtime (CRI): image distribution (pull/push/unpack) and lifecycle (create, start, kill). Also used directly by the Kubernetes kubelet. It hands off to —
  • runc — the low-level OCI runtime: reads the OCI runtime spec JSON and does the clone/pivot_root/setns/cgroup work. Replaceable with crun, runsc (gVisor), or kata-runtime.
Each layer has a single job, a clear boundary, and is open-source and replaceable independently of the others.
Layer 1: the CLI.
1
2
3
4
5
6
7
8
9
10
11
12
13
which docker
# /usr/bin/docker

docker version
# Client: Docker Engine - Community
# Version: 25.0.3
# Server: Docker Engine - Community
# Engine: 25.0.3

# The CLI is a thin HTTP client
strace -f -e trace=connect docker ps 2>&1 | grep -i sock | head
# connect(3, {sa_family=AF_UNIX, sun_path="/var/run/docker.sock"}, 23) = 0
# ← the CLI connected to the daemon's Unix socket
The CLI does not start containers. It sends a request to the daemon and formats the response.
Layer 2: the daemon (dockerd).
1
2
3
4
5
6
7
8
9
10
11
# The daemon process
ps aux | grep dockerd | grep -v grep
# root 1234 ... /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

# Its socket
ls -l /var/run/docker.sock
# srw-rw---- 1 root docker 0 Apr 20 09:00 /var/run/docker.sock

# Its config
cat /etc/docker/daemon.json 2>/dev/null
# {"log-driver": "json-file", "storage-driver": "overlay2", ...}
dockerd is the heavy-lifting layer. It manages images, builds (with BuildKit), coordinates the docker0 bridge and NAT rules, hosts the HTTP API, and calls the next layer down (containerd) to actually run containers. This is the daemon that has to be running for docker run to work — stop it, and the CLI fails immediately.
Layer 3: containerd.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# containerd is a separate daemon that dockerd talks to
ps aux | grep containerd | grep -v grep
# root 1200 ... /usr/bin/containerd
# root 1250 ... containerd-shim-runc-v2 ... (one shim per container)

ls /run/containerd/
# containerd.sock ← containerd's own socket

# Talk to containerd directly (bypass dockerd)
sudo ctr ns ls
# NAME LABELS
# default
# moby ← docker's containers live in this namespace
# k8s.io ← kubernetes' containers live here if using containerd directly

sudo ctr -n moby containers ls
# CONTAINER IMAGE RUNTIME
# abc123... ... io.containerd.runc.v2
containerd is the Container Runtime Interface (CRI) implementation that Kubernetes uses directly. It exposes a rich API (image pulls, unpacks, container lifecycle, a snapshotter for overlay layers, namespace separation) and is meant to be usable by multiple front-ends — Docker is one; Kubernetes' kubelet is another. This is the piece that was always "doing the work" under Docker. When Kubernetes "deprecated Docker," it just meant "we'll talk to containerd directly instead of going through dockerd → containerd, because dockershim adds nothing."
Layer 4: runc (and the OCI Runtime Spec).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
which runc
# /usr/bin/runc

# runc is a tiny binary that does one thing:
# read an OCI runtime-spec JSON and execute the container
# There is no runc daemon. runc starts, executes clone()+setns()+pivot_root(),
# and exits. The container keeps running under containerd-shim-runc-v2.

# See an OCI spec in action
sudo find /run/containerd -name config.json | head -1 | xargs sudo cat | jq .process
# {
# "terminal": false,
# "args": ["/docker-entrypoint.sh", "nginx", "-g", "daemon off;"],
# "env": ["PATH=/usr/local/sbin:...","NGINX_VERSION=1.25.3"],
# "cwd": "/",
# ...
# }
runc reads a JSON spec (the OCI runtime specification) that describes every detail of the container: binary to run, env vars, capabilities, namespaces to create, rootfs path, cgroup assignments, seccomp filter. It then invokes the kernel calls (clone, setns, pivot_root, cgroup attach) in the correct order and execves the target binary. runc has no daemon — it's called, does its work, and exits. The running container is kept alive by containerd-shim-runc-v2, a tiny process per container whose sole job is to own the container's stdout/stderr and wait on its exit.

The OCI: the standard that makes it all interoperable

The Open Container Initiative (OCI) is the standards body, hosted under the Linux Foundation, that publishes three specs:
  • OCI Image Spec — what a container image is: a tarball of layers + a JSON manifest. docker pull, docker push, and docker build all produce/consume OCI images.
  • OCI Runtime Spec — what a container bundle is: a rootfs directory + a config.json describing how to run it. runc (and alternatives) read this.
  • OCI Distribution Spec — the HTTP API of a container registry. Docker Hub, GHCR, ECR, and Harbor all implement this.
Because these are vendor-neutral specs, you can build an image with docker build and run it with podman (or containerd + runc, or CRI-O, or…); push it to Docker Hub and GHCR and ECR without changing the image; and swap runc for crun (a C implementation, faster startup), runsc (gVisor, a user-space kernel for stronger isolation), or kata-runtime (each container in a microVM).
KEY CONCEPT OCI compliance is the reason "container" means the same thing across tools. An image built with buildah, pushed to a Harbor registry, and run on a Kubernetes cluster using containerd + crun will behave identically to the same image run with docker run. This decoupling is what makes the container ecosystem healthy — no single vendor owns the contract.

Alternative stacks

Front-endDaemonMid-runtimeLow-runtimeTypical user
docker CLIdockerdcontainerdruncDevelopers, CI, local dev
podman CLInone (daemonless)conmonrunc / crunRootless workloads, RHEL
Kubernetes kubeletnonecontainerd or CRI-Orunc / crun / kata / gvisorProduction clusters
nerdctl CLInonecontainerdrunccontainerd users wanting a Docker-like CLI
finch / limalima VMcontainerdruncmacOS devs avoiding Docker Desktop licensing
Notice that the low-level runtime is almost always runc (or a runc-compatible binary) because the OCI runtime spec is the universal contract.

Kubernetes' "Docker deprecation," explained precisely

Kubernetes' kubelet needs to talk to a container runtime. Historically it used a shim layer called dockershim to translate between its CRI (Container Runtime Interface) and the Docker Engine API. Since containerd implements CRI natively, dockershim had no reason to exist. In Kubernetes 1.20+, dockershim was deprecated; in 1.24, it was removed. Clusters continue to run containers built by Docker, pulled from Docker Hub, on the same containerd + runc stack — nothing about images, the container runtime, or the container's behavior changed. Docker Desktop, docker build, and Docker Compose all still work for local development; they were never the target of the deprecation.
WAR STORY A team spent a weekend "migrating off Docker" after a nervous vendor memo. They rewrote CI, retrained engineers, and proudly announced they'd "removed Docker from production." Six months later an audit found that every image they built was still an OCI image, every registry was still OCI-compliant, and their containerd + runc runtime was identical to what was under Docker before. The actual code change in production: zero. The lesson: understand the stack before reacting to vendor messaging. Knowing the four layers — CLI, daemon, containerd, runc — makes it obvious which layer a given change affects, and spares you from unnecessary migrations.

When "Docker" matters, and when it doesn't

Docker-specific things (a different runtime won't help): the docker CLI ergonomics (docker run, docker exec, docker compose); the docker build / BuildKit build system; Docker Hub as a registry; Docker Desktop on macOS/Windows (uses a Linux VM under the hood).
OCI-standard things (any OCI-compliant runtime works): container images (every image is an OCI image); container runtime behavior (namespaces, cgroups, process lifecycle); registry interop (every registry speaks OCI Distribution); the Dockerfile format (every modern builder supports it).
So: you can run Docker images without Docker. You can push to Docker Hub without Docker. You can use Dockerfiles without Docker. The word "Docker" mostly describes the tooling you're using to produce and consume OCI artifacts.

Checking what you're actually running

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Is Docker running on this host?
systemctl is-active docker 2>/dev/null
# active

# Check which runtime dockerd is using
docker info | grep -E 'Runtime|Storage|Kernel|Operating'
# Default Runtime: runc
# Storage Driver: overlay2
# Kernel Version: 6.5.0-generic
# Operating System: Ubuntu 22.04.3 LTS

# On a Kubernetes node
crictl version
# RuntimeName: containerd
# RuntimeVersion: 1.7.22

crictl ps
# (containers managed by the kubelet via containerd — Docker not involved)
PRO TIP docker info is the single best "what is this machine running" snapshot. It tells you the default runtime (almost always runc), the storage driver (almost always overlay2), the kernel, the cgroup version, and the logging driver. When debugging a weird Docker behavior, paste the docker info output into the ticket first — most infra folks can diagnose from that alone.

Key concepts summary

  • "Docker" is four layers: CLI, daemon (dockerd), mid-runtime (containerd), low-runtime (runc). Confusing them is the #1 source of misconceptions.
  • runc does the kernel work — the tiny binary that reads an OCI runtime spec JSON and invokes clone/setns/pivot_root/cgroup-attach.
  • containerd is the orchestrator of runtimes: used directly by Kubernetes, and also used by dockerd under the hood.
  • The OCI publishes three specs — image, runtime, distribution — that make the ecosystem interoperable.
  • Kubernetes' "Docker deprecation" removed dockershim. Images, runtime behavior, and registries were never affected.
  • runc is replaceable: crun (faster), runsc/gVisor (stronger isolation), kata-runtime (microVMs per container) all drop in.
  • Docker Desktop runs a Linux VM on macOS/Windows — a packaging convenience, not a different runtime model.
  • docker info is the snapshot: it reveals the runtime, storage driver, kernel, and cgroup version in one command.

Common mistakes

  • Saying "Kubernetes removed Docker" and panicking. Kubernetes removed dockershim, not Docker images, not Dockerfiles, not the containerd runtime.
  • Treating Docker Hub, the Docker CLI, and the Docker daemon as one thing. They're independent — you can use the CLI without Docker Hub and vice versa.
  • Assuming images built by docker build are "Docker format." They're OCI images. Any OCI-compliant runtime can run them.
  • Running two daemons that both want port/socket ownership (e.g., Docker + Podman + nerdctl all fighting for /var/run/docker.sock). Pick one.
  • Thinking you need Docker Desktop on Linux. On Linux you install docker.io directly and the daemon runs natively — Desktop is for macOS/Windows, where a Linux VM is needed.
  • Forgetting that docker-compose (v1, Python) and docker compose (v2, Go plugin) are different binaries. The hyphen matters.
  • Debugging a "Docker bug" without checking which layer is at fault. Symptoms in the CLI often originate in runc, containerd, or the kernel.

Where this leaves you

Put the three lessons together and the picture is simple: a container is a process, isolated by namespaces, limited by cgroups, and rooted in a pivoted filesystem — all plain kernel features. Docker is the CLI and daemon that make those features convenient to use, but the actual work is done by containerd and runc, both of which speak the vendor-neutral OCI standard. Once that model replaces "containers are lightweight VMs," debugging, capacity planning, and security decisions all get a lot more grounded because you're reasoning about a shared Linux kernel, not an imaginary boundary that isn't really there.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article