Skip to content

2. What a container actually is

This chapter makes one claim and then proves it:

A container is a process. Nothing else. The kernel has no idea it is “in a container”.

Everything Docker does at runtime is telling the kernel, at the moment a process starts, to restrict four things. Once you can name all four, container behaviour stops being surprising.

1. NAMESPACES what the process can SEE
(its own PIDs, network, mounts, hostname, users)
2. CGROUPS what the process can USE
(CPU shares, memory ceiling, I/O, PID count)
3. CAPABILITIES what the process is ALLOWED TO DO
+ seccomp: which syscalls it may even call
+ LSM: AppArmor / SELinux policy
4. ROOT FS what the process's "/" IS
(a directory assembled from image layers,
then pivot_root'ed into)

That is the entire mechanism. A container is a process where all four have been set. Remove all four and you have a normal process. Set some but not others and you have something in between — which, incidentally, is exactly what --privileged and --net=host produce.

A namespace wraps a global kernel resource so that processes inside it see their own copy. Linux has eight.

NAMESPACE what it isolates what it means in practice
pid the process ID table your app is PID 1 and
cannot see host processes
mount the mount table its own filesystem tree
net interfaces, routes, iptables, its own IP, its own
port space "port 80", its own firewall
uts hostname and domainname `hostname` returns the
container ID by default
ipc shared memory, semaphores cannot poke another
container's shared memory
user UID/GID mapping root inside == UID 100000
outside (rootless mode)
cgroup the view of the cgroup tree cannot see the host's
cgroup layout
time boot time and monotonic clock rarely used

Two of these deserve real attention.

The PID namespace is why PID 1 matters. Inside the container, your process is process number one. On the host it is PID 47203. Same process, two numbers, because it exists in two PID namespaces at once — the kernel maintains both.

PID 1 on Linux is special in two ways that have nothing to do with containers and everything to do with why containers misbehave:

  • PID 1 does not get default signal handlers. A normal process that ignores SIGTERM still dies, because the kernel supplies a default action. PID 1 does not get that default. If your process has no SIGTERM handler, the signal is delivered and discarded, and docker stop hangs for ten seconds before Docker gives up and sends SIGKILL. This is chapter 8, and it is the single most common “why is my deploy slow” cause in containerized systems.
  • PID 1 inherits orphans. When any process’s parent dies, the kernel re-parents it to PID 1, which is expected to wait() on it and clear the entry. Your web server does not do this. Orphaned children become zombies and accumulate until the PID limit is hit.

The network namespace is why localhost lies. Each container gets a completely fresh network stack: its own loopback, its own interfaces, its own routing table, its own iptables rules, its own set of 65535 ports. So:

  • localhost inside a container means that container, not the host and not another container. An app connecting to localhost:5432 for a database in a different container will get connection refused, always. This is chapter 10.
  • Two containers can both bind port 8080 with no conflict. They are different port spaces.
  • --network=host skips creating the namespace at all. The container shares the host’s stack: same IP, same ports, real port conflicts, and localhost now genuinely means the host.

Do it by hand. You can create a namespaced process with no Docker at all:

unshare --pid --mount --uts --net --fork --mount-proc /bin/bash
then, inside:
hostname container-ish # only changes it here (uts)
ps aux # shows 2 processes (pid)
ip addr # shows only lo, down (net)

That is a container missing only cgroups and a root filesystem. Nothing was installed. The capability has been in the kernel the whole time.

Namespaces control visibility. They do nothing about consumption: a process alone in a PID namespace can still eat all the RAM on the box. cgroups (control groups) are the accounting and limiting side.

They are also not a container feature. Every ordinary systemd service on the machine is already in one — the systemd guide’s chapter on cgroups is the same mechanism without the namespaces, and it is why systemctl status can tell you a service’s memory use without parsing ps.

cgroups v2 — the current standard, and what you should assume — is a single tree of directories under /sys/fs/cgroup. Put a process’s PID in a directory, and the files in that directory control it.

/sys/fs/cgroup/
└── docker/
└── 3f2a9c1b…/ ← one directory per container
cgroup.procs ← the PIDs in this group
memory.max ← hard memory ceiling, bytes
memory.high ← soft ceiling: throttle before killing
memory.current ← current usage
cpu.max ← "quota period", both microseconds
cpu.weight ← relative share under contention
pids.max ← max processes (fork bomb defence)
io.max ← device read/write limits

Docker’s flags map straight onto these files:

docker run --memory=512m → memory.max = 536870912
docker run --cpus=1.5 → cpu.max = "150000 100000"
docker run --cpu-shares=512 → cpu.weight (relative, not a cap)
docker run --pids-limit=100 → pids.max = 100

Two behaviours follow directly, and both are worth internalizing.

Memory limits kill. CPU limits throttle.

OVER MEMORY LIMIT OVER CPU LIMIT
kernel OOM killer fires kernel simply stops scheduling
a process in the cgroup dies the process until the next period
exit code 137 (128 + 9) nothing dies, nothing logs,
"OOMKilled" in docker inspect everything just gets slower

There is no such thing as “using too much CPU” as an error. --cpus=0.5 means: in each 100 ms window, you get 50 ms of CPU, and then you are frozen until the window rolls over. A request that needed 60 ms of compute now takes 160 ms wall-clock, and no log line anywhere mentions it. The Kubernetes guide’s chapter on the kubelet works through why this makes CPU limits a bad default for latency-sensitive services.

Page cache counts as memory. A container that reads a large file accumulates page cache in its own cgroup. The kernel reclaims it under pressure rather than killing you, so this is usually harmless — but it makes docker stats show a container “using” 480 MB of a 512 MB limit when the application’s actual heap is 60 MB.

The JVM, Node and .NET need to be told. A runtime that sizes its heap from /proc/meminfo sees the host’s memory, because cgroup limits do not change what /proc/meminfo reports. A JVM in a 512 MB container on a 64 GB host may plan for a 16 GB heap and be OOM-killed on the first real request. Modern JVMs detect cgroup limits; older ones need -XX:+UseContainerSupport, and setting -XX:MaxRAMPercentage=75 explicitly is always safer than trusting detection.

Capabilities and syscall filtering — restricting the powers

Section titled “Capabilities and syscall filtering — restricting the powers”

Root inside a container is not root on the host, and the reason is capabilities.

Linux split the historical all-powerful root into about 40 distinct privileges. Docker starts containers with root holding roughly 14 of them and drops the rest:

KEPT (default) DROPPED (default)
CHOWN change file owner SYS_ADMIN mount, and ~30 other things
DAC_OVERRIDE bypass file perms SYS_MODULE load kernel modules
FOWNER bypass owner checks SYS_TIME set the system clock
SETUID/SETGID drop privileges SYS_PTRACE trace other processes
NET_BIND_SERVICE bind port <1024 NET_ADMIN configure interfaces
KILL signal any process SYS_BOOT reboot the host
NET_RAW raw sockets (ping) MKNOD* create device nodes

So a “root” shell in a container cannot load a kernel module, cannot change the host clock, cannot mount a filesystem, and cannot reconfigure the network. It looks like root, and id says uid=0, but most of what root means has been removed.

On top of that sit two more filters:

  • seccomp. Docker’s default profile blocks about 44 of the ~350 Linux syscalls outright, including kexec_load, mount, reboot, and the historically exploit-prone corners. This is the cheapest meaningful hardening in the system and it is on by default.
  • AppArmor or SELinux. A mandatory access control profile restricting file and capability access beyond what the above two do.

--privileged turns all of this off at once. Every capability granted, seccomp disabled, AppArmor unconfined, all host devices exposed. It is not “a bit more access” — it is essentially root on the host with a different filesystem view, and getting from there to the real host is a short walk. Chapter 11 is specific about that.

The root filesystem — restricting what “/” is

Section titled “The root filesystem — restricting what “/” is”

The last piece. The process needs a filesystem, and it must not be the host’s.

1. Docker assembles the image's read-only layers into one directory
using overlayfs, and adds a thin writable layer on top.
(Chapter 3 — this is the interesting part.)
2. It bind-mounts a small set of kernel filesystems in:
/proc /sys /dev/pts /dev/shm and a handful of device nodes
3. It bind-mounts anything you asked for:
-v /host/data:/data → a bind mount
-v mydata:/data → a named volume
--tmpfs /tmp → memory-backed
4. It calls pivot_root() into that directory.
From this instant the process cannot name anything outside it.
There is no ".." escape — pivot_root detaches the old root.

pivot_root is the reason “the container cannot see the host filesystem” is a real boundary rather than a convention. It is not path filtering. The old root is unmounted from the process’s mount namespace; the paths do not exist any more from inside.

This is also why anything written to the container’s own filesystem vanishes on removal. That writable top layer is discarded. It is not storage. Chapter 9 covers what to do instead.

Here is the whole of docker run in kernel terms:

clone(CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS |
CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWUSER)
↓
write the child PID into /sys/fs/cgroup/docker/<id>/cgroup.procs
write limits into memory.max, cpu.max, pids.max
↓
mount the overlay: lowerdir=layer1:layer2:…, upperdir=<writable>
mount /proc, /sys, /dev; bind-mount volumes
↓
pivot_root() into it
↓
drop capabilities, apply the seccomp profile, apply AppArmor
↓
setuid to the USER from the image
↓
execve("/app/server", …)

That last line is the point. After execve, it is a process. Nothing supervises it in a special way. The kernel schedules it like anything else. top on the host shows it. When it exits, the container is “stopped” — which just means Docker noticed the process ended and recorded its exit code.

Because containers are processes, you can now derive behaviour rather than memorize it:

  • Startup is milliseconds, because there is no boot — just clone and execve.
  • You cannot run a Windows container on Linux, or a Linux container on macOS, because the kernel is shared and there is only one. Docker Desktop runs a hidden Linux VM.
  • You cannot run a newer kernel feature than the host has, no matter what your image says. An image based on Ubuntu 24.04 running on a 4.x kernel host uses the 4.x kernel.
  • docker exec is just a new process joining the existing namespaces — which is why it does not restart anything, and why a container with no shell in the image cannot be exec’d into.
  • A container “hanging” is a process hanging. All your normal tools apply: strace, /proc/<pid>/stack, kill -QUIT for a JVM thread dump.
  • Isolation is only as strong as the kernel. One kernel bug separates a container from the host — unlike a VM, where a hypervisor also stands in the way. That is the real security trade, and chapter 11 takes it seriously.
  • A container is a normal Linux process with four restrictions applied at start: namespaces, cgroups, capabilities/seccomp, and a pivoted root filesystem. There is no container object in the kernel.
  • Namespaces restrict what it can see. The PID namespace makes your app PID 1 — which means it gets no default signal handlers and inherits orphan processes. The net namespace means localhost refers to the container itself, never the host.
  • cgroups restrict what it can use. Memory limits kill (exit 137, OOMKilled); CPU limits throttle silently — nothing dies, nothing logs, everything gets slower.
  • Runtimes that size their heap from /proc/meminfo do not see the cgroup limit unless told. Set -XX:MaxRAMPercentage or the equivalent.
  • Root in a container is not root on the host. Docker drops ~26 of ~40 capabilities and applies a seccomp profile blocking ~44 syscalls. --privileged removes all of that at once.
  • pivot_root detaches the old root from the mount namespace — the host filesystem does not exist from inside, rather than being merely hidden.
  • Everything containers do and cannot do follows from this: millisecond startup, one shared kernel, no cross-OS containers, docker exec as an ordinary process, and isolation only as strong as the kernel itself.

Next: images and layers — how a filesystem gets stacked out of diffs, why a deleted file still costs you space, and what a registry is really storing.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source