Namespaces & cgroups by hand
Build a container with no Docker.
There is no container object inside the Linux kernel (the core program that runs everything else and talks to the hardware). You can read the source and grep for one; it is not there. What is there are two older features that shipped years before Docker existed: namespaces and cgroups (control groups). Docker, Podman, and Kubernetes stack those two together and paint a friendly label on top. Scrape the label off and a container is one ordinary process that the kernel has been told to show less and allow less. Building one by hand takes twenty minutes and removes every bit of mystery, which is what you want as a defender, because you cannot harden or investigate something you believe is magic.
Two separate knobs do the work, and keeping them straight is the whole lesson. Namespaces control what a process can SEE. cgroups control what it can USE. One is a set of blinders. The other is a metered power strip that trips when you draw too much.
Namespaces With Unshare
A namespace is a private copy of one kind of system resource. The process inside sees only its copy and treats that copy as the whole machine. Think of a hotel room whose walls are painted to look like the entire world: you can walk around, but you only ever see the room. Linux has several kinds, one per resource. PID (process identifier) namespaces give a process its own numbering of processes. Mount namespaces give it its own view of which filesystems are mounted where. UTS (Unix Timesharing System, an old name that now only covers the hostname fields) gives it its own hostname. Network namespaces give it its own interfaces, routes, and firewall rules.
The unshare command runs a program after splitting off (un-sharing) the namespaces you name. Give a shell its own PID, mount, UTS, and network namespaces in a single call:
Read the flags left to right. --pid, --mount, --uts, and --net each ask for a fresh namespace of that kind. --fork matters more than it looks: a PID namespace only takes effect for children of the process that created it, so unshare forks a child to become PID 1 inside. Without --fork you get errors and a shell that is not actually PID 1. --mount-proc mounts a brand-new /proc (the virtual filesystem the kernel uses to expose process information) inside the mount namespace, so ps reads the new process list instead of the host's. Skip it and ps still shows every process on the machine, because it is reading the host's /proc. The empty network namespace is the clearest tell: one loopback interface, down, and nothing else.
Watching It From The Host
Isolation runs one way, like a one-way mirror in an interview room. The process inside cannot see out, but you, standing on the host, can see everything about it. That asymmetry is what makes containers investigable. Every namespace a process belongs to shows up as a file under /proc/<PID>/ns, and each file points at an inode number (the unique ID the kernel puts on a filesystem object, here standing in as the namespace's real identity), like a license plate on the namespace. Two processes carrying the same plate are in the same namespace.
Those are the host's own namespace inodes. From a second terminal on the host, lsns lists namespaces and who lives in them. Your unshared shell, PID 1 to itself, shows up here with its true host PID and its own pid-namespace inode:
That is the defender's lever. A process cannot hide from lsns or from /proc by entering a namespace, because the kernel still tracks it from the outside. If malware spawns itself into fresh namespaces to masquerade as an innocent PID 1, lsns and /proc/<PID>/cgroup still hand you the real tree, its host PID, and its parent. A process that claims to be PID 1 but sits at host PID 18542 with a non-initial pid inode is a process worth a second look.
cgroups Apply The Limits
Namespaces changed what the shell can see. It can still use the whole machine's memory and every CPU (central processing unit) core. cgroups fix that. A cgroup (control group) is a labelled bucket you drop processes into, with control files that cap how much of each resource the bucket may draw. It behaves like the breaker panel in a house: each circuit has a rating, and pull too much through one and it trips instead of melting the wiring.
Modern systems use cgroup v2 (control groups version 2), one unified tree mounted at /sys/fs/cgroup. First, see which resource controllers the kernel offers and which of them the parent hands down to its children:
memory, cpu, and pids appear in cgroup.subtree_control, which is why child groups you create will have memory.max and cpu.max files. This is a real gotcha: if a controller is missing from the parent's subtree_control, the matching control file never appears in the child, and your write fails with 'No such file or directory'. Create a group, cap its memory and CPU, and read the memory ceiling back:
Notice the memory ceiling reads back as 104857600, not 100M. The kernel accepts human units on the way in and stores plain bytes, rounded to page size, so always trust the number you read back over the one you wrote. cpu.max reads '50000 100000', meaning 50,000 microseconds of CPU time allowed per 100,000-microsecond window, which is half of one core.
Nothing is limited yet, because the group is empty. Put a process in by writing its PID to cgroup.procs, then prove the ceiling is real by trying to allocate past it. Running tail on /dev/zero (an endless stream of zero bytes) makes the tool try to buffer forever, which is a fine memory bomb for a test:
The kernel let the process grow to 100 megabytes, then the OOM (out-of-memory) killer stepped in, and memory.events recorded oom_kill 1. That counter is the one to watch in production. If a service keeps dying and memory.events shows oom_kill climbing, the process is hitting its cgroup ceiling, not a host-wide shortage, and the fix is the limit, not more RAM (random-access memory, the fast working memory the process actually runs in). When you are finished with the group entirely, get every process out of it (the shell leaves when it exits, or move its PID back to the root group with echo $$ | sudo tee /sys/fs/cgroup/cgroup.procs) and run sudo rmdir /sys/fs/cgroup/demo. rmdir refuses while any process is still inside.
Confirm The Limit Actually Bit
Setting a limit and checking that it fires are different jobs. For CPU, the proof lives in cpu.stat. Burn a core inside the group and watch the throttle counters climb:
nr_throttled counts the scheduling windows in which the group hit its cap and got parked. Forty-eight of fifty-one periods throttled means the 50 percent limit is doing its job. If you set a cpu.max and nr_throttled stays at zero under load, the process never reached the cap and your limit is a no-op. cpu.stat and memory.events are how you verify a change worked instead of hoping it did.
A real container is these same files. Point the tools at a running Docker process and you see the same kind of inodes and the same control-group path. Here, nginx runs as host PID 31002:
Look at the time and user rows: their inodes match the host's initial namespaces and point at PID 1, which is the kernel telling you Docker did not isolate them. Root in that container is root on the host if it breaks out, unless someone turned on user-namespace remapping. The mount, PID, UTS, IPC (inter-process communication), and network rows carry their own high inodes, so those are private, and the last line shows the systemd scope (systemd is the service manager that starts and supervises processes on most modern Linux) that holds the limits. Same primitives you set by hand, wired together by a runtime, and every one of them readable from /proc and /sys/fs/cgroup the moment an incident lands on you.
Try this
Work through “Confirm The Limit Actually Bit” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: isolation is not a security boundary. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.