Namespaces from first principles
Build a container by hand with unshare — no Docker.
Run readlink /proc/1/ns/pid on any Linux box and the answer comes back as a number in square brackets. That number is the closest thing the Linux kernel has to a container. There is no container object in the kernel source, no create_container() system call (a syscall is how a program asks the kernel to do something for it), nothing in /proc named container. What exists is a namespace, and the honest picture of a namespace is the one-way mirror in a police interview room. The person inside sees a wall. Everyone on the other side watches the whole room. Docker wraps a handful of those mirrors around an ordinary process and hands you the result under a nicer name. Build that stack by hand once and you stop believing the glass will hold anybody, because glass is all it is.
$ ls -l /proc/self/nstotal 0lrwxrwxrwx 1 you you 0 Jul 16 10:22 cgroup -> 'cgroup:[4026531835]'lrwxrwxrwx 1 you you 0 Jul 16 10:22 ipc -> 'ipc:[4026531839]'lrwxrwxrwx 1 you you 0 Jul 16 10:22 mnt -> 'mnt:[4026531841]'lrwxrwxrwx 1 you you 0 Jul 16 10:22 net -> 'net:[4026531840]'lrwxrwxrwx 1 you you 0 Jul 16 10:22 pid -> 'pid:[4026531836]'lrwxrwxrwx 1 you you 0 Jul 16 10:22 user -> 'user:[4026531837]'lrwxrwxrwx 1 you you 0 Jul 16 10:22 uts -> 'uts:[4026531838]'
Each of those symlinks points at one namespace, and the number in brackets is an inode number (the serial number a filesystem stamps on every object it tracks, borrowed here to tag each mirror). The rule is short. Same number, same namespace, same view. Different number, different view. That one comparison is the entire detection technique for the rest of this lesson. The 4026531xxx range is the host's starting set, the low fixed numbers the kernel hands its very first namespaces at boot, which is why pid:[4026531836] reads the same on every Linux machine you will ever touch. Namespaces created later get higher numbers, something like 4026532677. Memorize pid:[4026531836]. If a container reports it, that process is standing in the host's own process namespace and has no private one at all.
Build a container by hand
unshare is a small program that starts another program inside brand new namespaces. No daemon, no image, no registry. The single command below hands /bin/sh its own process tree, its own mount table, its own hostname, its own network stack, and its own user mapping. The --map-root-user part then remaps you, the caller, to root inside the new user namespace (userns for short), so you can hold all of this without any real privilege on the host.
$ unshare --pid --mount --uts --net --user --map-root-user --fork --mount-proc /bin/sh# iduid=0(root) gid=0(root) groups=0(root),65534(nogroup)# hostname box; hostnamebox# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 10:24 pts/0 00:00 /bin/shroot 7 1 0 10:24 pts/0 00:00 ps -ef# ip -o link show1: lo: <LOOPBACK> mtu 65536 state DOWN
Look at what changed. Inside, sh is process ID 1 (PID 1, the first process, the one the kernel starts and every other process descends from), and ps can find nothing else on the machine. You can rename the host and the real host never hears about it. The network is an empty stack with a single loopback interface, and even that one is down. You have built the visible shell of a container with a binary that ships in every distribution. Now walk over to a second terminal and look at what you actually created.
# in a SECOND terminal on the host, the unshared sh is still running:$ lsns --type pidNS TYPE NPROCS PID USER COMMAND4026531836 pid 291 1 root /sbin/init4026532677 pid 2 30512 you /bin/sh$ sudo readlink /proc/1/ns/pid /proc/30512/ns/pidpid:[4026531836]pid:[4026532677]
There it is, in two lines. Your container is host PID 30512 wearing a private pid namespace numbered 4026532677, parked in the process list right beside init like any other program. docker ps will never show it, because Docker had nothing to do with it. lsns and /proc show it every single time. The two inode numbers differ, so this shell genuinely cannot reach host processes. Correct isolation, viewed from the outside, looks like a different number. That is the whole tell.
The mirror hides; it doesn't disarm
A namespace edits what a process sees. It edits nothing about what that process can do to the kernel sitting behind the glass, and that kernel is the same one carrying every other workload on the machine. Give the shell CAP_SYS_ADMIN (a Linux capability, one of roughly forty keys the kernel chopped root's power into) and it can mount and unmount filesystems inside its own view. Point it at a kernel bug and the isolation stops counting for anything, because every container on the box rides the same syscall path into the same code. Which is why deliberately sharing a host namespace is such a bad trade. It removes even the visibility barrier and lets the container reach into the host's world with both hands.
$ docker run -d --name apm --pid=host nginx # 'just so the agent can see host procs'$ docker inspect -f '{{.HostConfig.PidMode}}' apmhost$ cid=$(docker inspect -f '{{.State.Pid}}' apm)$ sudo readlink /proc/1/ns/pid /proc/$cid/ns/pidpid:[4026531836]pid:[4026531836]
Identical inodes. The --pid=host flag took the mirror out entirely, so that container stands in the host's process namespace and can list, trace, and signal anything running on the machine. Add --cap-add SYS_PTRACE on top and it can read the memory of those processes too, secrets included. You detect it with the inode comparison above, or with a docker inspect that shows PidMode, NetworkMode, or IpcMode set to host. Search your run commands and Compose files for --pid=host, --net=host, and pid: host. Every hit is a mirror somebody took out on purpose.
Close it with a user namespace
The strongest move is to keep every mirror you already have and add the one Docker leaves switched off by default: the user namespace. It maps container UID 0 (root's numeric user ID) onto a high, boring host UID, so root inside the container becomes a costume with no authority anywhere else. If a process does claw its way out of the other namespaces, it lands on the host as an account that owns no files and can open no doors. You turn it on for the whole daemon with the userns-remap setting.
# /etc/docker/daemon.json# { "userns-remap": "default" }$ sudo systemctl restart docker$ docker run -d --name safe alpine sleep 300$ ps -o pid,user,args -p $(docker inspect -f '{{.State.Pid}}' safe)PID USER COMMAND20531 165536 sleep 300$ docker run --rm alpine cat /proc/self/uid_map0 165536 65536
Container root is now host UID 165536, an account with nothing to its name and no rights to speak of. The uid_map line states the bargain in three numbers: UIDs 0 through 65535 inside the container correspond to 165536 and upward outside it. Take away --pid=host and --net=host as well, and every mirror is back where it belongs, with unprivileged glass behind it.
Building one by hand pays off twice. It shows you the kernel already owned every piece long before Docker existed, and it fixes the gap that matters in your head. The process inside believes its mount table, its PID 1, and its network stack are the whole machine. The host sees one ordinary program wearing a few extra numbers. Everything a vendor sells you as container isolation rests on that gap and on nothing else.
The mistake teams make is treating namespaces like a bank vault door. They hide; they do not disarm. A process that keeps CAP_SYS_ADMIN can still mount things, pivot the root filesystem, and prod /proc in ways that matter a great deal on a shared kernel. Namespaces plus a user namespace remap, plus dropped capabilities, plus a seccomp profile (a filter that decides which syscalls a process is allowed to make at all) is what turns the costume into a boundary you can defend in an incident review.
On call at 3am, the useful question is never "is this a container?" It is "which namespaces does this process hold, and which capabilities came along for the ride?" Read /proc/<pid>/ns and the CapEff (effective capabilities) line in that process's status file before you trust any isolation claim a dashboard makes.
After a change window, that same check is your close-out step. Run it, confirm the control is still on, paste the command and its output into the ticket, and refuse to sign the change off if the reading has drifted. Pick the tightest scope the workload will tolerate. It is a small habit, and it pays you back on every host and in every pipeline you own.
Try this
On a Linux lab box, never production, build a tiny "container" with unshare and no Docker anywhere in sight, then compare what you see inside against what the host sees. You want the PID and mount views to disagree with the host. You also want to feel, with your own hands, how little that disagreement does to slow down a privileged process.
$ # host view first$ echo "host pid1=$(readlink /proc/1/exe)"; hostname; ls / | head -3host pid1=/usr/lib/systemd/systemdlab-hostbinbootdev$ sudo unshare --mount --pid --fork --mount-proc /bin/bash# inside the new namespaces:$ echo "inner pid=$$"; ls /proc | head -5; hostnameinner pid=11selfthread-selflab-host$ # still the same hostname unless you also unshare --uts$ exit$ echo "back on host pid=$$"back on host pid=24881
Takeaway
Namespaces are the costume, not the vault. Real isolation is what you stack on top of what unshare showed you: user remapping, dropped capabilities, syscall filters. Then you verify it by reading /proc, never by reading a slide deck.