Sign in
← Docker
12 min · intro · Lesson 1 of 8

Why containers

How a container differs from a VM and from emulation. Namespaces and cgroups in short, and what actually runs under the hood in Docker.

In this lesson we'll look at how a container differs from a virtual machine and from emulation, what Linux kernel mechanisms create it, and what actually happens when you type docker run. In the labs you'll check this with your own hands: you'll see that containers from different distributions run on one kernel, and you'll find a container the kernel killed for exceeding its memory limit.

What problem containers solve

The old scenario: "works on my machine, doesn't work on yours". One developer installs one version of a dependency, another installs a different one, and production runs on some old Ubuntu altogether. Setting all this up by hand takes forever, and the environments still drift apart.

A container is a packaged application together with its file system, dependencies and start command. On any Linux machine this package deploys the same way, so production and dev stop drifting apart.

Under the hood a container is an ordinary Linux kernel process with its own namespaces and cgroup limits. There's no separate technology: the kernel isolates the process from the rest of the system.

VM, container and emulation

Three similar words that mean different things.

WhatWhat runsOwn kernelOverheadCPU architecture
VM (KVM, VMware)a full guest OSyesgigabytes, seconds to startsame as the host
Containera process on the host kernelnomegabytes, millisecondssame as the host
Emulation (QEMU)a guest OS or a program of another architectureyes, if it's an OSvery expensive on CPUcan be different (arm on x86)

The key difference between a container and a VM: a container has no kernel of its own. When you run Postgres in a container on a Linux host, postgres stays an ordinary process on the host kernel. The kernel just shows it a trimmed-down view of the system, where there's almost no one else around.

Emulation works differently. QEMU can run code for a foreign architecture (ARM on x86, say), but it translates every instruction and pays for that in speed. When Docker on a Mac with Apple Silicon runs an amd64 image, emulation kicks in: QEMU, or Rosetta if it's enabled in Docker Desktop settings. Such a container runs noticeably slower than a native one. How much slower depends on the workload.

Containers on macOS and Windows

A container needs a Linux kernel. macOS and Windows don't have one, so Docker Desktop brings up a small Linux VM, and all containers run inside it. On Windows, WSL2 usually plays that role.

This is easy to check. docker info shows the kernel the containers live on:

docker info --format '{{.OperatingSystem}} {{.KernelVersion}}'

On a Mac with Docker Desktop the answer will look something like this:

Docker Desktop 5.15.49-linuxkit

The linuxkit kernel belongs to the Docker Desktop VM, not to macOS. On a Linux host you'll see the host's own kernel there, on WSL2 the WSL kernel. "Host kernel" in this lesson means the kernel of the machine where Docker Engine runs.

Namespaces: what gets isolated

When Docker starts a container, the kernel creates new namespaces for the process: separate namespaces instead of ones shared with the host. The main ones:

There's also the user namespace (its own uid and gid mapping) and the cgroup namespace. Docker doesn't enable user by default; it needs separate configuration (userns-remap). You can skip them for now.

You can see a process's namespaces in /proc/self/ns:

docker run --rm alpine:3.20.3 ls /proc/self/ns
cgroup
ipc
mnt
net
pid
pid_for_children
time
time_for_children
user
uts

Namespaces are created by flags to the clone() and unshare() system calls. In the narrowest sense, "creating a container" means "starting a process with the right flags". On Linux you can do this without Docker, with the unshare command.

cgroups: resource limits

Namespaces isolate what a process sees. cgroups (control groups) limit how much it can take: CPU, memory, disk IO, number of processes.

When you run docker run --memory=512m --cpus=1, Docker creates a cgroup for the container and puts its processes into it. If the processes try to take more memory than allowed, the kernel's OOM-killer kills the process inside that cgroup, and leaves its neighbors alone.

You can see the limit from inside the container too: it's an ordinary cgroup v2 file:

docker run --rm --memory 32m alpine:3.20.3 cat /sys/fs/cgroup/memory.max
33554432

33554432 bytes is exactly 32 MiB. A container killed for memory stops with exit code 137 (128 + 9, signal SIGKILL). docker kill gives the same code. The .State.OOMKilled field in docker inspect tells the two apart: after a memory kill it's true.

Without cgroups, one greedy container could eat all of the host's memory. With cgroups, the problem stays inside the container.

Image layers and copy-on-write

A container image consists of read-only layers. Every Dockerfile instruction that changes files (COPY, RUN and others) adds a new layer: an archive of the file system difference.

When you run a container, one writable layer sits on top of the stack of read-only layers. Every change the process makes inside the container is written there. The base layers don't change, and every container started from that image shares them.

This is called copy-on-write. As long as no one touches a file, it stays in the base layer. As soon as a process changes it, a copy of the file rises into the top layer, and further changes go there.

We'll cover this in detail in the lesson about images. For now, one thing matters: the image is immutable, and the container is mutable on top of it.

What starts a container

When you type docker run, a chain of calls runs under the hood, and each link does its own part of the work:

docker CLIwrapper over the API dockerddaemon, holds state containerdlifecycle runcOCI runtime, calls clone() Linux kernel: namespaces, cgroups, OverlayFS
Each link does its own part of the work, and the real isolation comes from the bottom layer: the kernel.

Docker isn't the only way to run containers. Podman, containerd directly, kubelet in Kubernetes all end up at an OCI runtime (runc or, say, crun) and at the same kernel mechanisms.

When a container doesn't fit

A container doesn't always fit. Cases where a VM is better:

For web applications, backends, databases, queues and CI, a container fits in the vast majority of cases.

Common mistakes

"A container is a lightweight VM". No: a VM has its own kernel, a container doesn't. That's why a container starts in a fraction of a second, but its isolation is also thinner.

"On a Mac the container runs on macOS". No: on macOS and Windows, containers live inside the Docker Desktop Linux VM. uname -r inside the container will show that VM's kernel.

"The alpine image means an alpine kernel". An image brings only files: libraries, utilities, /etc/os-release. There's always one kernel, the one Docker Engine runs on.

"137 means it ran out of memory". Code 137 only means SIGKILL. Both the OOM-killer and docker kill give it. Check .State.OOMKilled.

"Docker = containers". Docker is just a convenient wrapper over namespaces, cgroups and OverlayFS. Swap Docker for Podman, and the kernel won't notice the difference.

What you'll need for the tasks

We'll go through docker run flags in detail in the next lesson, where we'll start our first container. Exactly how the kernel builds isolation (namespaces, cgroups, capabilities) is covered in the "Linux: isolation" track.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson docker-basics-01-why-containers