Exercises — Containers — namespaces, cgroups, difference from VMs
Everything here rests on one sentence from the parent: a container is a normal process that is lied to about what it can SEE (namespaces) and limited in what it can USE (cgroups), on ONE shared kernel. Keep that picture in mind for every question.
Level 1 — Recognition
Goal: recognise which kernel feature or concept a scenario belongs to. No computation.
Exercise L1.1
For each item, say whether it is a namespace job (controls what you SEE) or a cgroup job (controls what you USE): (a) container's first process appears as PID 1; (b) container gets OOM-killed at 512 MB; (c) container has its own hostname; (d) container is throttled to 50% of a core; (e) container has its own private list of network interfaces.
Recall Solution L1.1
The rule: SEE = namespace, USE = cgroup. (Re-read the SEE-vs-USE figure above if unsure which column each item lands in.)
- (a) namespace (PID namespace — you see a fresh numbering starting at 1).
- (b) cgroup (memory controller — a quantity limit).
- (c) namespace (UTS namespace isolates the hostname you see).
- (d) cgroup (cpu controller — a quantity limit).
- (e) namespace (network namespace — a private view of interfaces).
Score = number correct out of 5.
Exercise L1.2
Match each syscall to its one-line job: clone, unshare, setns.
(i) move the current process into brand-new namespaces;
(ii) create a child process with fresh namespaces;
(iii) join an already-existing namespace (used by docker exec).
Recall Solution L1.2
clone→ (ii) create a child with fresh namespaces (viaCLONE_NEW*flags). See Processes and the clone/fork syscall.unshare→ (i) move the current process into new namespaces.setns→ (iii) join an existing namespace — this is howdocker execsteps into a running container. See Linux Kernel — syscalls.
Level 2 — Application
Goal: plug numbers into the CPU-quota and memory formulas.
Exercise L2.1
A cgroup has cpu.max = "50000 100000" (microseconds). What fraction of one core does it get, and how many milliseconds of CPU may it run in every 100 ms window?
Recall Solution L2.1
The formula (from the parent note):
WHAT the two numbers mean, seen on the bucket figure above: the kernel scheduler (see Scheduling — CFS and CPU shares) drops quota microseconds of runtime into the bucket, and refills it every period microseconds.
Plug in: cores of one core.
Milliseconds: of CPU per ms window (the mint stretch in the figure). After spending 50 ms, the group is frozen (slate stretch) until the next window refills the bucket.
Exercise L2.2
You want a container that may burst up to 1.5 cores, checked every 100 ms window. What string do you write into cpu.max?
Recall Solution L2.2
We invert the formula. We want with .
So write: cpu.max = "150000 100000".
WHY this is legal to exceed 1.0: on a multi-core machine a group can use more than one full core, because in a single 100 ms window it may accumulate runtime across several CPUs simultaneously — the bucket now holds more tokens than one CPU could spend alone.
Exercise L2.3
Convert 256 MiB into the exact byte value you would echo into memory.max.
Recall Solution L2.3
"MiB" (mebibyte) uses powers of two, not ten:
So: echo 268435456 > /sys/fs/cgroup/myapp/memory.max.
WHY exact bytes: the kernel counts a process's resident memory (RSS) in bytes; when the running total crosses this threshold the memory controller triggers the OOM-killer inside that group only.
Level 3 — Analysis
Goal: explain WHY a behaviour happens by reasoning about which global table is (not) private.
Exercise L3.1
You run unshare --pid --fork --mount-proc bash, then ps aux, and see only two or three processes — your shell is PID 1. The host still has 400 running processes. Explain, in terms of global tables, why they vanished from your view but are still running.
Recall Solution L3.1
The picture — see the figure below. The kernel normally keeps one global PID table (left ledger). A PID namespace gives your process group its own private copy of that table (right ledger). Crucially, the mint bar underneath shows the same single kernel still running every real process — nothing is deleted.

Reading the figure:
- The 400 host processes still exist and still consume CPU/RAM — the kernel (mint bar) still runs them. They fill the left ledger.
- But
psreads/proc, which--mount-procremounted to reflect your PID namespace — so it reads the right ledger. That ledger only has entries you created, renumbered from 1. - Your shell is PID 1 because a fresh table starts counting at 1 (top of the right ledger).
The key idea: isolation here is bookkeeping, not deletion. The two ledgers describe the same running processes; you were simply handed a different ledger to read. This is exactly the "lied to about what you can SEE" from the parent note.
Exercise L3.2
Inside a container, uname -r prints 6.5.0-14-generic. On the host, uname -r prints the same 6.5.0-14-generic. Inside a VM on the same host, uname -r prints 5.15.0-89-generic. What does this single command prove about the difference between a container and a VM?
Recall Solution L3.2
uname -r asks the running kernel for its version.
- Container = host: identical strings prove there is exactly one kernel. The container is a process on the host kernel; when it asks "what kernel am I on?", it gets the host's answer because that is its kernel. Isolation was namespaces + cgroups (software bookkeeping), not a second kernel.
- VM ≠ host: a different version proves the VM booted its own separate guest kernel on virtual hardware provided by a hypervisor (see Virtualization and Hypervisors (Type 1 vs Type 2)).
One-line rule proved: same kernel version ⇒ container; different kernel version ⇒ VM.
Exercise L3.3
A colleague adds a fork-bomb-prone service into a cgroup with pids.max = 100 but forgets to give it any namespaces. The service misbehaves and runs kill -9 on a host database process — and it works. Which protection was present, which was missing, and why did the kill succeed?
Recall Solution L3.3
- Present: the
pidscgroup controller. It caps the number of processes at 100, so a fork bomb cannot explode past that count. That is a USE limit. - Missing: the PID namespace. Without it, the service shares the host's one global PID table — so it can SEE the database's PID and name it as a target.
- Why the kill succeeded:
killneeds (1) the target's PID to be visible, and (2) sufficient privilege. The cgroup limited quantity but did nothing about visibility, so condition (1) held. The kill went through.
Lesson: cgroups ≠ isolation. You needed a PID namespace to make host PIDs invisible (and a user namespace to drop privilege).
Level 4 — Synthesis
Goal: combine namespaces, cgroups, filesystems, and networking into a working design.
Exercise L4.1
Design the minimum set of namespaces + cgroups + filesystem setup to run an untrusted web service so that it: (a) cannot see host processes, (b) has its own root /, (c) has its own IP, (d) cannot use more than 512 MiB RAM or 1 core, (e) where container-root is not host-root. List each piece and the ONE thing it provides.
Recall Solution L4.1
Build it feature by feature — each requirement maps to exactly one mechanism:
| Requirement | Mechanism | What it gives |
|---|---|---|
| (a) can't see host processes | PID namespace (CLONE_NEWPID) |
private process table |
(b) own root / |
Mount namespace + a new root via pivot_root/chroot |
private mount table — see Filesystems — mount and chroot |
| (c) own IP | Network namespace (CLONE_NEWNET) + a virtual interface pair bridged to the host |
private interfaces/routes — see Networking — virtual interfaces and bridges |
| (d) ≤512 MiB, ≤1 core | cgroup: memory.max = 536870912, cpu.max = "100000 100000" |
resource caps |
| (e) container-root ≠ host-root | User namespace (CLONE_NEWUSER) mapping UID 0 → e.g. 100000 |
privilege drop |
The synthesis idea: clone() with exactly the flags the requirements demand — CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWUSER — privatises every global table these five requirements name; the cgroup meters what it consumes. Together = isolation (SEE) + limits (USE). That is a container. (Note: we did not add CLONE_NEWUTS, because no requirement asks to isolate the hostname — only add the namespace flags a requirement actually needs.) The root filesystem itself would typically be an overlayfs image.
Byte check for (d): bytes; cpu.max "100000 100000" = core.
Exercise L4.2
"Docker for Windows can run Linux containers." Explain the whole chain of what actually happens, and why a Linux binary's syscalls cannot execute directly on the Windows NT kernel.
Recall Solution L4.2
The core fact: a container is just processes on the host kernel, and those processes make syscalls to that kernel. A Linux binary issues Linux syscall numbers (e.g. clone, openat). The Windows NT kernel implements a different syscall interface, so those numbers mean nothing to it — the binary would crash immediately.
So the chain is:
- Docker Desktop quietly boots a lightweight VM (via a hypervisor) that runs a real Linux kernel.
- Your "Linux container" is a set of namespaced+cgrouped processes inside that Linux VM, on the Linux kernel.
- From your Windows desktop it looks seamless, but there are two layers: a VM (to get a Linux kernel) and a container (inside it).
Conclusion: the rule "you cannot run a foreign OS kernel in a container" is not broken — Docker smuggled in a real Linux kernel via a VM first. One kernel per container, and it must be the right kernel.
Level 5 — Mastery
Goal: debug and reason about limiting behaviour, edge cases, and degenerate inputs.
Exercise L5.1
A container is set cpu.max = "0 100000". What happens to processes in it, and why? Then contrast with cpu.max = "max 100000".
Recall Solution L5.1
Apply at the boundaries (picture the refill bucket from Level 2 — now the bucket is either empty or bottomless).
"0 100000": , so cores. The scheduler refills a bucket of zero runtime each window → the group gets no CPU at all and is permanently frozen (starved). This is a degenerate input: a valid-looking string that halts the container. Rarely what you want."max 100000": the literal tokenmaxmeans no limit. effectively, so the group can use as many cores as the machine has — unthrottled.
Mastery point: the same file spans the whole range from "totally frozen" (0) to "unlimited" (max). Always sanity-check the edges, not just a mid value like 50000.
Exercise L5.2
Two containers on one host each have cpu.max = "150000 100000" (1.5 cores each), but the machine has only 2 physical cores. Both run CPU-bound work at the same time. Do they each get 1.5 cores? What actually happens, and what enforces it?
Recall Solution L5.2
cores requested, but only exist. cpu.max is a ceiling, not a reservation — it caps how much each may take, but cannot manufacture cores that don't exist.
- Since neither individually asks for more than 2 cores, each tries to burst to 1.5. But total demand (3.0) exceeds supply (2.0), so the CFS scheduler (see Scheduling — CFS and CPU shares) fairly time-slices the 2 available cores between the two groups.
- What actually happens: with equal CPU weight and both fully CPU-bound, CFS splits the 2 cores evenly, so each container converges to about core — below its 1.5 ceiling. Neither container's
cpu.maxceiling ever bites, because the hardware limit (2 cores total) bites first. If one container's load dropped, the other could rise toward its 1.5 ceiling, since spare cores would become available. - What enforces it: two layers act in order — first CFS fairly shares the scarce real cores; the per-group
cpu.maxthrottle would only kick in if a group tried to exceed its own 1.5-core ceiling, which never happens here.
Mastery point: distinguish limit (cpu.max — an upper bound) from weight/share (cpu.weight — proportional split when contended). Ceilings prevent hogging; weights decide who wins under contention. Neither creates extra cores.
Exercise L5.3
A process is put in a cgroup with memory.max = 268435456 (256 MiB) but is given no mount namespace and no PID namespace. It reads /proc/meminfo and reports the host's total RAM as, say, 32 GiB — then allocates 1 GiB. What does the app think is happening, what does the kernel actually do, and why is this a classic bug?
Recall Solution L5.3
- What the app thinks:
/proc/meminfoshows the host's 32 GiB (no mount/PID namespace means it reads the real host/proc). So the app happily sizes its cache to, say, 8 GiB, thinking it has room. - What the kernel does: the memory cgroup counts this group's RSS. The moment the group crosses 256 MiB, the OOM-killer fires inside the group and kills the app — long before 1 GiB.
- Why it's classic: visibility (
/proc) and limits (cgroup) are separate. Without a namespace to make/procreflect the cgroup, the app sees host numbers and mis-plans, then dies. This is the real-world "container reports host RAM" bug (JVMs and Node used to size heaps from host/proc/meminfo).
Fix in practice: run inside proper namespaces and have the runtime read cgroup limits (memory.max) instead of /proc/meminfo. Once more: SEE (namespace) and USE (cgroup) are independent — set both, and make the app trust the cgroup.
Recall Self-test checklist (reveal after finishing)
Rule that decides container vs VM in one command ::: uname -r — same version = container (one kernel), different = VM (own kernel)
Two independent legs every container stands on ::: namespaces (isolate what you SEE) + cgroups (limit what you USE)
cpu.max "0 100000" does what ::: quota 0 → 0 cores → process permanently starved/frozen
Why "256 MB" ≠ "256 MiB" for memory.max ::: MB = 256×10^6 = 256000000; MiB = 256×2^20 = 268435456 bytes
Why Docker-for-Windows can run Linux containers ::: it boots a hidden lightweight Linux-kernel VM; the containers run inside that