# Docker: namespaces in isolation

## Concept

A container is not a box. There is no container object in the Linux
kernel, no lightweight VM, nothing that "contains" anything. There is one
ordinary process tree on one ordinary kernel, and a set of *namespaces* —
per-process filters that change which entries of a kernel table that
process is allowed to see. `docker run` is a `clone()` with a few extra
flags.

This exercise makes that concrete. You'll take one `sh` process running in
a container and look at it from two directions at once: inside, it's PID 1
with two children and nothing else in the world; outside, it's PID 39023
with a `containerd-shim` parent, sitting in a list of ~137 processes. Same
process. Two numbers. Then you'll kill it from outside using the number it
can't see, and finally build a PID namespace by hand with `unshare` —
no Docker, no image, no daemon — to show there was never any magic.

## Provenance

`namespaces(7)`, DESCRIPTION — the sentence the whole exercise tests:

> A namespace wraps a global system resource in an abstraction that makes
> it appear to the processes within the namespace that they have their own
> isolated instance of the global resource. Changes to the global resource
> are visible to other processes that are members of the namespace, but are
> invisible to other processes. **One use of namespaces is to implement
> containers.**

Note what that does *not* say: nothing is moved, copied, or fenced. An
abstraction is applied to a *global* resource. Parts 1–3 are that sentence,
run.

`namespaces(7)`, "The /proc/pid/ns/ directory" — the claim Part 2 checks:

> If two processes are in the same namespace, then the device IDs and inode
> numbers of their `/proc/pid/ns/xxx` symbolic links will be the same; an
> application can check this using the `stat.st_dev` and `stat.st_ino`
> fields returned by `stat(2)`.

`pid_namespaces(7)`, "Nesting PID namespaces" — the asymmetry Part 3 is
about, stated by the kernel's own documentation:

> A process is visible to other processes in its PID namespace, and to the
> processes in each direct ancestor PID namespace going back to the root PID
> namespace. […] Conversely, the processes in a child PID namespace can't
> see processes in the parent and further removed ancestor namespaces. More
> succinctly: a process can see (e.g., send signals with `kill(2)` […]) only
> processes contained in its own PID namespace and in descendants of that
> namespace.
>
> A process has one process ID in each of the layers of the PID namespace
> hierarchy in which is visible […]

(The "in which is visible" typo is upstream's, quoted as-is.)

`proc_pid_status(5)` — where `NSpid` is actually documented. **Not** in
`namespaces(7)`, which is where an earlier draft of this exercise cited it:

> `NStgid` — Thread group ID (i.e., PID) in each of the PID namespaces of
> which pid is a member. The leftmost entry shows the value with respect to
> the PID namespace of the process that mounted this procfs (or the root
> namespace if mounted by the kernel), followed by the value in successively
> nested inner namespaces. (Since Linux 4.1.)
>
> `NSpid` — Thread ID in each of the PID namespaces of which pid is a
> member. The fields are ordered as for `NStgid`.

Docker CLI reference, "PID settings (`--pid`)" — what Session B does:

> `'host': use the host's PID namespace inside the container`
>
> By default, all containers have the PID namespace enabled. PID namespace
> provides separation of processes. The PID Namespace removes the view of
> the system processes, and allows process ids to be reused including PID 1.

Docker CLI reference, "Escalate container privileges (`--privileged`)":

> The `--privileged` flag gives the following capabilities to a container:
> Enables all Linux kernel capabilities · Disables the default seccomp
> profile · Disables the default AppArmor profile · Disables the SELinux
> process label · Grants access to all host devices · Makes `/sys`
> read-write · Makes cgroups mounts read-write
>
> In other words, the container can then do almost everything that the host
> can do.

Sources:
[namespaces(7)](https://man7.org/linux/man-pages/man7/namespaces.7.html) ·
[pid_namespaces(7)](https://man7.org/linux/man-pages/man7/pid_namespaces.7.html) ·
[user_namespaces(7)](https://man7.org/linux/man-pages/man7/user_namespaces.7.html) ·
[proc_pid_status(5)](https://man7.org/linux/man-pages/man5/proc_pid_status.5.html) ·
[docker container run — `--pid` / `--privileged`](https://docs.docker.com/reference/cli/docker/container/run/#pid) ·
[Isolate containers with a user namespace](https://docs.docker.com/engine/security/userns-remap/)

## Prerequisites

The exercise's own prose covers namespaces from scratch. Two pieces of
background carry the two surprises, and are worth having first:

- **`/proc` is a filesystem, not a kernel query API.** `ps` does not ask
  the kernel "who exists" — it opens `/proc` and reads directory entries.
  Which `/proc` you read is decided by your *mount* namespace, and which
  entries appear there is decided by the reader's *PID* namespace, at the
  moment `/proc` was mounted. **This is the one that carries steps 26 and
  44**, both of which look like bugs until you have it.
  ([`proc(5)`](https://man7.org/linux/man-pages/man5/proc.5.html))
- **Capabilities are a separate mechanism from namespaces.** A namespace
  decides *which* copy of a resource you affect; a capability decides
  whether you may modify it at all. Docker gives every container a private
  UTS namespace and then drops `CAP_SYS_ADMIN`, so it owns a hostname it
  cannot set. Used in step 31 and again in step 45.
  ([`capabilities(7)`](https://man7.org/linux/man-pages/man7/capabilities.7.html))
- **User namespaces and UID mapping**, for Part 6 only — enough to know
  that a UID map is what would make uid 0 inside be uid 100000 outside, and
  that Docker does not install one by default.
  ([`user_namespaces(7)`](https://man7.org/linux/man-pages/man7/user_namespaces.7.html))

## Environment

- **Host:** macOS 26.5.2 (build 25F84), Apple Silicon (arm64)
- **Docker:** Docker Desktop, Engine 29.6.2 — Linux VM kernel `6.12.76-linuxkit`, 16 GiB allocated to the VM
- **Alpine image:** `alpine:3.20` — Alpine 3.20.10, digest `sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc`
- **Captured:** 2026-07-26

Two notes on reading the transcripts below. The image tag is pinned
deliberately: bare `alpine` is `alpine:latest`, a different digest
(`sha256:28bd5fe8…` at time of capture), so every command here says
`alpine:3.20`. And the timestamps inside the Linux VM read `Jul 25 16:47`
because the VM runs UTC while the Mac was on `+08` — same instant, and the
`Captured` date above is the Mac's.

The transcripts were captured against a daemon with **nothing else
running**: `docker ps` showed only this exercise's own containers. The
host process listings in Session B are therefore the real, complete list,
not a filtered one. The one process you will see that this exercise did
not start is Docker Desktop's own internal `/portainer` container —
shim id `14fcb7e6…`, which never appears in `docker ps`.

## Mental model: three vantage points, and eight kinds of blindness

### The vantage points

On Linux, "inside the container" and "the host" are the only two places
you can stand. On macOS there are **three**, and knowing which one you're
standing on is most of the exercise:

```
  ┌─────────────────────────────────────────────────────────┐
  │ macOS (Darwin, arm64)                                   │
  │   • no /proc at all — it isn't a Linux kernel           │
  │   • `docker` here is just a client talking to a socket  │
  │                                                         │
  │   ┌─────────────────────────────────────────────────┐   │
  │   │ the Linux VM (6.12.76-linuxkit, aarch64)        │   │
  │   │   • THIS is "the host" every Docker doc means   │   │
  │   │   • PID 1 = initd, ~137 processes               │   │
  │   │   • initial namespaces: pid:[4026531836], ...   │   │
  │   │                                                 │   │
  │   │   ┌─────────────────────────────────┐           │   │
  │   │   │ container ns-a                  │           │   │
  │   │   │   • PID 1 = sh, 3 processes     │           │   │
  │   │   │   • pid:[4026532695]            │           │   │
  │   │   └─────────────────────────────────┘           │   │
  │   └─────────────────────────────────────────────────┘   │
  └─────────────────────────────────────────────────────────┘
```

The usual instruction — "from the host, run `ls /proc/<pid>/ns/`" —
simply cannot be typed on a Mac; `/proc` does not exist there. The way in
is a container that *opts out* of the PID namespace:
`docker run --privileged --pid=host`. That is not a workaround for a
missing feature. It is the same mechanism the exercise is about, used in
reverse: a container is only isolated because of the namespaces it was
given, so hand one back and the "container" is a shell on the host.

**If you're on native Linux**, everything below works identically —
just use your own shell wherever the exercise says "Session B", and drop
the `--pid=host` container. The `/proc` paths, inode numbers, `NSpid`
field and `unshare` behavior are all kernel features, not Docker ones.

### The eight namespaces

`ls -l /proc/<pid>/ns/` lists them. Each is a kernel object identified by
an inode number; two processes are "in the same namespace" iff they show
the same inode. That's the whole ontology.

| Namespace | What it filters | What breaks without it | Docker flag to disable |
| --- | --- | --- | --- |
| `pid` | The process ID table. Your first process becomes PID 1; you cannot see or signal anything outside. | `ps aux` in the container lists every host process, and `kill` reaches them. | `--pid=host`, or `--pid=container:NAME` to share another container's |
| `mnt` | The mount table. This is what makes the image's filesystem *be* `/`. | The container sees the host's filesystem tree. | (no flag; you'd have to `nsenter`) |
| `net` | Interfaces, addresses, routes, iptables, socket port space. | Two containers can't both bind :80. | `--network host` |
| `uts` | `hostname` and `domainname`, nothing else. The oldest and smallest one (UTS = UNIX Time-sharing System). | `hostname foo` in a container renames the host. | `--uts=host` |
| `ipc` | SysV shared memory / semaphores / message queues, and POSIX message queues. | Two Postgres containers with the same shm key collide. | `--ipc=host`, `--ipc=container:NAME` |
| `user` | UID/GID *mapping*. Lets uid 0 inside be uid 100000 outside. | Root in the container is root on the host — **which is the default**. | on by default (see Part 6) |
| `cgroup` | The cgroup path you can see, so a container can't read its position in the host's hierarchy. | `/proc/self/cgroup` leaks the container ID and the host's layout. | `--cgroupns=host` |
| `time` | `CLOCK_MONOTONIC` and `CLOCK_BOOTTIME` offsets. Newest (Linux 5.6), and the only one Docker gives you with no offset applied. | Checkpoint/restore can't rewind a process's idea of uptime. | n/a |

Two properties are worth having in your head before you start:

- **A namespace is a filter, not a wall.** It changes what a process can
  *name*. It does not move the process anywhere, does not stop the host
  from acting on it, and does not limit what it can consume — that last
  one is cgroups' job, a completely separate mechanism.
- **The isolation is per-namespace-type and individually revocable.**
  There is no "container namespace". `--pid=container:ns-a` shares exactly
  one of the eight and leaves the other seven alone, which is precisely
  how a Kubernetes pod's sidecars work.

## Setup

Three terminals. **A** is your macOS shell, **B** is a shell in the Linux
VM, **C** is a shell in the container.

**A** — start the container under test. It runs a shell with two
identifiable children, so there's a small process tree to look at:

```bash
docker run -d --name ns-a alpine:3.20 sh -c 'sleep 4242 & sleep 4243 & wait'
```

**B** — a shell on the "host". `--pid=host` opts out of the PID namespace,
`--privileged` keeps the kernel from redacting other processes' `/proc`
entries:

```bash
docker run --rm -it --privileged --pid=host --cgroupns=host alpine:3.20 sh
```

(On native Linux: skip this and just use a local shell, with `sudo` where
noted.)

**C** — a shell inside the container:

```bash
docker exec -it ns-a sh
```

Every PID and inode number below will be different on your machine. The
kernel hands inodes out sequentially **and recycles them** — you will see
that happen in this very exercise, where a namespace inode freed in Part 4
comes back in Part 5 attached to a different namespace *type*. What matters
is which numbers match each other *at the same moment*, never their values.
One exception: `4026531836` and its neighbours in the `402653183x` range
are the kernel's initial namespaces, created at boot, and are the same on
every Linux system.

## Steps

### Part 1 — the same processes, twice

What this part tests: whether "the container's processes" and "the host's
processes" are two sets of processes or one. Per the mental model there is
only ever one process tree, so the two views must be the same processes
under different names.

1. **C**: look at the container's world.

   ```
   / # ps -o pid,ppid,args
   PID   PPID  COMMAND
       1     0 sh -c sleep 4242 & sleep 4243 & wait
       7     1 sleep 4242
       8     1 sleep 4243
       9     0 ps -o pid,ppid,args
   ```

   Four processes, and PID 1 is your `sh`. Note `PPID 0` — PID 1's parent
   isn't merely hidden, it is unrepresentable, because it lives in a
   namespace this process has no way to name.

2. **Predict**: `docker top` asks the *daemon* what's running in the
   container. Same list?
3. **A**: `docker top ns-a`
4. **Observe**:

   ```
   UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
   root                39023               38999               0                   16:46               ?                   00:00:00            sh -c sleep 4242 & sleep 4243 & wait
   root                39036               39023               0                   16:46               ?                   00:00:00            sleep 4242
   root                39037               39023               0                   16:46               ?                   00:00:00            sleep 4243
   ```

   The same three commands, with completely different PIDs — and PID 1's
   phantom parent has a number now, `38999`. `docker inspect -f
   '{{.State.Pid}}' ns-a` returns `39023` to match. This is already the
   aha in miniature: `1` and `39023` are the same process, and neither
   number is more real than the other.

5. **A**: try the thing every Docker tutorial tells you to do next:

   ```
   $ ls /proc/39023/ns/
   ls: /proc/39023/ns/: No such file or directory
   ```

   Not "permission denied" — macOS has no `/proc` at all, so the error is
   about the very first path component. The PID `docker top` just printed
   is not a PID in any table macOS maintains. That's what Session B is for.

### Part 2 — a namespace is an inode

What this part tests: the claim from the mental model, and from
`namespaces(7)`, that "same namespace" is literally "same inode number".
If the container's `sh` and the VM's PID 39023 are one process, their
namespace inodes must be identical — and both must differ from the VM's own.

6. **C**: read the container init's namespaces from the inside.

   ```
   / # ls -l /proc/1/ns/
   total 0
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 cgroup -> cgroup:[4026532696]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 ipc -> ipc:[4026532694]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 mnt -> mnt:[4026532554]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 net -> net:[4026532697]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 pid -> pid:[4026532695]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 pid_for_children -> pid:[4026532695]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 time -> time:[4026532825]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 time_for_children -> time:[4026532825]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 user -> user:[4026531837]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 uts -> uts:[4026532693]
   ```

   Ten entries, not eight. `pid_for_children` and `time_for_children` are
   not extra namespaces — they name the namespace this process's *next*
   child will get, which is how `unshare(2)` can put your children somewhere
   you aren't yet. Here they match `pid` and `time`, as they do in any
   quiescent process.

7. **Predict**: now read *the same process* from the VM, as PID 39023. Do
   the inodes match, or does each side see its own private numbering?
8. **B**: `ls -l /proc/39023/ns/`
9. **Observe**: byte-for-byte the same ten lines, timestamps included —

   ```
   / # ls -l /proc/39023/ns/
   total 0
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 cgroup -> cgroup:[4026532696]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 ipc -> ipc:[4026532694]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 mnt -> mnt:[4026532554]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 net -> net:[4026532697]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 pid -> pid:[4026532695]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 pid_for_children -> pid:[4026532695]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 time -> time:[4026532825]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 time_for_children -> time:[4026532825]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 user -> user:[4026531837]
   lrwxrwxrwx    1 root     root             0 Jul 25 16:47 uts -> uts:[4026532693]
   ```

   The namespace inode is a global fact about the process, not a per-viewer
   one. It has to be: it's how the kernel answers "are these two processes
   in the same namespace?", and per `namespaces(7)` it's the documented way
   for *userspace* to ask the same question with `stat(2)`.

10. **B**: for contrast, the VM's own init:

    ```
    / # ls -l /proc/1/ns/
    total 0
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 cgroup -> cgroup:[4026531835]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 ipc -> ipc:[4026531839]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 mnt -> mnt:[4026531841]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 net -> net:[4026531840]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 pid -> pid:[4026531836]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 pid_for_children -> pid:[4026531836]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 time -> time:[4026531834]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 time_for_children -> time:[4026531834]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 user -> user:[4026531837]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:47 uts -> uts:[4026531838]
    / # cat /proc/1/comm
    initd
    ```

    `4026531834`–`4026531841` is the kernel's initial set, allocated at
    boot; you'll see those exact numbers on any Linux box. Nine of the ten
    lines differ from the container's. The tenth — `user:[4026531837]` —
    is identical, and Part 6 is about why that one matters more than the
    other nine.

11. **B**: the single line that states the whole exercise.

    ```
    / # grep -E '^(Name|Pid|NSpid|Uid):' /proc/39023/status
    Name:	sh
    Pid:	39023
    Uid:	0	0	0	0
    NSpid:	39023	1
    ```

    `NSpid: 39023 1` — one process, listed once per PID namespace it is
    visible in, outermost first (`proc_pid_status(5)`: "the leftmost entry
    shows the value with respect to the PID namespace of the process that
    mounted this procfs … followed by the value in successively nested
    inner namespaces"). Not "the host PID and the mapped PID": the kernel
    genuinely holds both, because PID namespaces nest, and a process gets
    an ID in every ancestor namespace.

12. **C**: read the same field for the same process from the inside, and
    the asymmetry stops being an abstraction.

    ```
    / # grep -E '^(Name|Pid|NSpid):' /proc/1/status
    Name:	sh
    Pid:	1
    NSpid:	1
    ```

    One number, not two. The inner view is not *redacting* 39023; from
    where this process stands there is no outer layer to have an ID in.
    That is the whole of `pid_namespaces(7)`'s "processes in a child PID
    namespace can't see processes in the parent", visible in four bytes.

13. **B**: and the parent the container can't see:

    ```
    / # ps -o pid,args | grep '[c]ontainerd-shim'
      490 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 14fcb7e67d5d1dd64ff4cdae63f6ac18e6d23b23af8c65d3cfd4f50ddbf25bb9 -address /run/containerd/containerd.sock
    38999 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 6a353dfa121c6e0c370146f6216cf2f969138a9a769efa168b7399495779dcb4 -address /run/containerd/containerd.sock
    39047 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 796a8bdda9ec791a8ed5f302c1a8957463f26ab0747cd183ea5323fc4301c25e -address /run/containerd/containerd.sock
    ```

    One shim per container, and the `-id` *is* the container ID — so you
    can read this list without guessing. `38999` is 39023's parent, and its
    id `6a353dfa121c…` is ns-a's. `39047` is **the B shell you are typing
    in**; `--pid=host` did not exempt it from being an ordinary container.
    `490` is Docker Desktop's own internal `/portainer` container, which
    `docker ps` never shows you. "Ordinary process supervised by an
    ordinary parent" is the entire architecture; the shim is what lets
    `dockerd` be restarted without killing your containers.

### Part 3 — the isolation is a view, not a wall

What this part tests: the first bullet of the mental model. A namespace
controls what a process can *name*. It does not put the process out of
reach of anything outside.

14. **C**: try to signal the process you now know is PID 39023.

    ```
    / # kill 39023
    sh: can't kill pid 39023: No such process
    ```

    Not "permission denied" — *no such process*. In this namespace that
    integer names nothing, and the kernel's answer is a perfectly honest
    `ESRCH`.

15. **Predict**: now the other direction. From B, `kill 39037` — the
    `sleep 4243` whose in-container name is PID 8. Does the container's
    PID namespace protect it?
16. **B**: `kill 39037`
17. **C**: `ps -o pid,args`
18. **Observe**:

    ```
    / # ps -o pid,args
    PID   COMMAND
        1 sh -c sleep 4242 & sleep 4243 & wait
        7 sleep 4242
       27 ps -o pid,args
    ```

    PID 8 is gone, and B printed nothing at all — `kill` succeeded silently.
    The container has no way to name 39037 and no way to refuse it either.
    Isolation runs strictly outward: hierarchical, and one-way.

19. **B**: the mount namespace is just as porous from outside. `/proc/<pid>/root`
    is the kernel's door into another process's mount view:

    ```
    / # ls /proc/39023/root/
    bin    etc    lib    mnt    proc   run    srv    tmp    var
    dev    home   media  opt    root   sbin   sys    usr
    / # cat /proc/39023/root/etc/hostname
    6a353dfa121c
    ```

    That's the container's own root filesystem, read from outside with no
    `docker exec` involved — and `6a353dfa121c` is the container ID, which
    Docker writes into `/etc/hostname` at start. This is how host-level
    tooling (backup agents, security scanners, `docker cp`) reaches into a
    container without entering it.

### Part 4 — joining one namespace and not the others

What this part tests: the second bullet of the mental model — that the
eight namespaces are independent, and "join a container" is not an atomic
thing you do.

20. **Predict**: `--pid=container:ns-a` starts a *brand new* container that
    shares ns-a's PID namespace. Whose processes does its `ps` list, and
    whose files does its `ls` see?
21. **A**:

    ```bash
    docker run --rm --pid=container:ns-a alpine:3.20 ps -o pid,args
    ```

22. **Observe**:

    ```
    PID   COMMAND
        1 sh -c sleep 4242 & sleep 4243 & wait
        7 sleep 4242
       34 ps -o pid,args
    ```

    A separate container, a separate image, its own writable layer — and
    it can see ns-a's process tree with ns-a's numbering. Check what it
    did *not* inherit:

    ```
    $ docker run --rm --pid=container:ns-a alpine:3.20 sh -c \
        'echo "pid ns: $(readlink /proc/self/ns/pid)"; \
         echo "mnt ns: $(readlink /proc/self/ns/mnt)"; \
         echo "net ns: $(readlink /proc/self/ns/net)"; \
         echo "uts ns: $(readlink /proc/self/ns/uts)"; cat /etc/hostname'
    pid ns: pid:[4026532695]
    mnt ns: mnt:[4026532962]
    net ns: net:[4026532966]
    uts ns: uts:[4026532963]
    16bfbd1a51b2
    ```

    `pid:[4026532695]` is ns-a's, from step 6. The other three are fresh,
    and the hostname is its own container ID rather than ns-a's
    `6a353dfa121c`. One namespace shared, seven fresh. This is exactly a
    Kubernetes pod: containers in a pod share the net (and optionally pid)
    namespace so they can reach each other on `localhost` and see each
    other's processes, while keeping separate filesystems.

23. **B**: `nsenter` is the same operation without Docker — it's the
    `setns(2)` syscall with a CLI. Enter 39023's pid *and* mount
    namespaces:

    ```
    / # nsenter -t 39023 -p -m ps -o pid,args
    PID   COMMAND
        1 sh -c sleep 4242 & sleep 4243 & wait
        7 sleep 4242
       50 ps -o pid,args
    ```

    That is `docker exec`, in one command, with no daemon in the loop.

24. **Predict**: drop the `-m`, so you join the PID namespace but keep the
    VM's mount table. `ps` should still be filtered to the container,
    right?
25. **B**: `nsenter -t 39023 -p ps -o pid,args`
26. **Observe**:

    ```
    / # nsenter -t 39023 -p ps -o pid,args | head -4
    PID   COMMAND
        1 /initd
        2 [kthreadd]
        3 [pool_workqueue_]
    / # nsenter -t 39023 -p ps -o pid,args | wc -l
    138
    ```

    The VM's entire process list — 137 processes plus a header — from a
    process that really is in the container's PID namespace. `ps` doesn't
    ask the kernel who exists; it reads `/proc`, and `/proc` is a *mount*,
    still the VM's. The PID namespace changed which processes the kernel
    would report; nobody changed which `/proc` was being read. Remember
    this for Part 7.

### Part 5 — the other namespaces, side by side

What this part tests: that the remaining namespace types work the same
way, and that "disabled" and "empty" are different states.

27. **A**: a second container with no network and an explicit hostname:

    ```bash
    docker run -d --name ns-b --network none --hostname ns-b-box alpine:3.20 sleep 3600
    ```

28. **Predict**: `--network none`. Does ns-b get *no* network namespace,
    or an empty one?
29. **A**: `docker exec ns-b ls -l /proc/self/ns/` and
    `docker exec ns-b ip -o addr show`
30. **Observe**:

    ```
    $ docker exec ns-b ls -l /proc/self/ns/
    total 0
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 cgroup -> cgroup:[4026532966]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 ipc -> ipc:[4026532964]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 mnt -> mnt:[4026532962]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 net -> net:[4026532967]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 pid -> pid:[4026532965]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 pid_for_children -> pid:[4026532965]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 time -> time:[4026533095]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 time_for_children -> time:[4026533095]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 user -> user:[4026531837]
    lrwxrwxrwx    1 root     root             0 Jul 25 16:48 uts -> uts:[4026532963]

    $ docker exec ns-b ip -o addr show
    1: lo    inet 127.0.0.1/8 scope host lo\       valid_lft forever preferred_lft forever
    1: lo    inet6 ::1/128 scope host \       valid_lft forever preferred_lft forever
    ```

    Its own private network namespace — `net:[4026532967]`, where ns-a is
    `4026532697` and the VM is `4026531840` — containing a loopback
    interface and nothing else. `--network none` doesn't remove the
    namespace, it declines to plug anything into it; every namespace exists
    in exactly one state, and "empty" is a normal one. `user` is
    `4026531837`, the same as everyone.

    Look at `mnt:[4026532962]` and `uts:[4026532963]` and compare them to
    step 22. Those are the *same integers* the short-lived
    `--pid=container:ns-a` container had, one for a different namespace
    type — that container exited, its nsfs inodes were freed, and the
    kernel handed the numbers straight back out. This is the recycling the
    Setup warned about, caught in the act. Inode equality only means
    anything between two things alive at the same moment.

    Compare ns-a, which has the same *kind* of namespace with a veth in it:

    ```
    $ docker exec ns-a ip -o addr show
    1: lo    inet 127.0.0.1/8 scope host lo\       valid_lft forever preferred_lft forever
    1: lo    inet6 ::1/128 scope host \       valid_lft forever preferred_lft forever
    11: eth0    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0\       valid_lft forever preferred_lft forever
    ```

    Same `lo`, plus one interface. And the hostnames — `ns-b-box` vs ns-a's
    `6a353dfa121c` — differ because of one namespace whose entire contents
    is a string.

31. **A**: worth one detour, because it's the exception that sharpens the
    rule: the UTS namespace is the container's own, but it can't write to
    it.

    ```
    $ docker run --rm alpine:3.20 sh -c 'hostname changed-me; hostname'
    hostname: sethostname: Operation not permitted
    9ff52a110ca0
    $ docker run --rm --cap-add SYS_ADMIN alpine:3.20 sh -c 'hostname changed-me; hostname'
    changed-me
    ```

    Namespaces decide *which* hostname you affect; capabilities decide
    whether you may set one at all. Docker drops `CAP_SYS_ADMIN` by
    default, so the answer is no — two independent mechanisms, and real
    container security is always both.

32. **A**: the opposite end. `--network host` doesn't create an empty
    namespace; it hands over the VM's:

    ```bash
    docker run --rm --network host alpine:3.20 sh -c \
      'echo "net ns: $(readlink /proc/self/ns/net)"; ip -o addr show | head -4'
    ```

    ```
    net ns: net:[4026531840]
    1: lo    inet 127.0.0.1/8 scope host lo\       valid_lft forever preferred_lft forever
    1: lo    inet6 ::1/128 scope host \       valid_lft forever preferred_lft forever
    4: eth0    inet 192.168.65.3/24 brd 192.168.65.255 scope global eth0\       valid_lft forever preferred_lft forever
    4: eth0    inet6 fdc4:f303:9324::3/64 scope global flags 02 \       valid_lft forever preferred_lft forever
    ```

    `4026531840` is the initial namespace from step 10. Note what "host"
    means here on a Mac: `192.168.65.3` is the *VM's* address, not your
    Mac's — a third reminder that "host" is the middle box in the diagram.

33. **A** then **B**: the cgroup namespace, which is pure cartography —
    same cgroup, two different paths:

    ```
    $ docker exec ns-a cat /proc/self/cgroup
    0::/
    ```
    ```
    (B) / # cat /proc/39023/cgroup
    0::/docker/6a353dfa121c6e0c370146f6216cf2f969138a9a769efa168b7399495779dcb4
    ```

    The container believes it's at the root of the hierarchy. Nothing
    about its limits changed; only the path it's allowed to see did. (Your
    B shell needs `--cgroupns=host` for this, or it gets its *own*
    relative view and prints `0::/../6a353dfa…`.)

### Part 6 — the namespace you didn't get

What this part tests: the one line in step 10's output that was identical
between the container and the VM's init. Seven namespaces were unshared
for you; the security-critical one was not.

34. **A**: start a third container running as a non-root user:

    ```bash
    docker run -d --name ns-c --user 1000 alpine:3.20 sleep 5150
    ```

    ```
    $ docker exec ns-a id
    uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
    $ docker exec ns-c id
    uid=1000 gid=0(root) groups=0(root)
    ```

35. **Predict**: ns-a runs as root inside. ns-c runs as uid 1000 inside.
    What UIDs does the VM report for them?
36. **B**: `ps -o pid,user,args | grep -E '[s]leep 5150|[s]leep 4242'`
37. **Observe**:

    ```
    / # ps -o pid,user,args | grep -E '[s]leep 5150|[s]leep 4242'
    39023 root     sh -c sleep 4242 & sleep 4243 & wait
    39036 root     sleep 4242
    40102 1000     sleep 5150
    / # grep '^Uid:' /proc/39023/status
    Uid:	0	0	0	0
    / # grep -E '^(Pid|Uid|NSpid):' /proc/40102/status
    Pid:	40102
    Uid:	1000	1000	1000	1000
    NSpid:	40102	1
    ```

    No translation whatsoever. The container's root *is* the host's root —
    the same uid 0, the same credential the kernel checks on every file
    access — and ns-c's 1000 inside is a literal 1000 outside.
    `user:[4026531837]` was the initial user namespace, and Docker left
    every container in it. Note that ns-c is also `NSpid: 40102 1`: the PID
    namespace is doing its job, it is only the *user* namespace that isn't
    there.

    That is why `--privileged --pid=host` in Session B handed you the
    whole VM, and why "a container escape" is usually a short sentence:
    with uid 0 unmapped, a process that gets out of its other seven
    namespaces is root. It's also why `--user 1000` is the cheapest
    hardening step available — 1000 outside is a nobody.

    The fix Linux offers is a user namespace with a UID map, so uid 0
    inside is uid 100000 outside. Docker can do it
    (`dockerd --userns-remap=default`), Podman does it by default for
    rootless containers, and both pay for it in volume-permission pain,
    which is roughly why it isn't the default here. Docker's own docs list
    the price: with userns-remap on, `--pid=host`, `--network=host` and
    plain `--privileged` all stop working — which is to say, Session B of
    this exercise would be impossible.

### Part 7 — none of this was Docker

What this part tests: the claim in the concept — that `docker run` is
`clone()` with flags, and every namespace above is a kernel feature you
can reach directly.

38. **B**: establish where you're standing.

    ```
    / # readlink /proc/self/ns/pid
    pid:[4026531836]
    / # ps -o pid,args | wc -l
    141
    ```

    The initial PID namespace, 140 processes plus a header. (It was 137 in
    step 26; ns-b and ns-c have since started.)

39. **Predict**: `unshare --pid --fork --mount-proc sh` — one command, no
    image, no daemon, no `docker`. What does `ps` print inside it?
40. **B**: `unshare --pid --fork --mount-proc sh -c 'readlink /proc/self/ns/pid; ps -o pid,args'`
41. **Observe**:

    ```
    pid:[4026533233]
    PID   COMMAND
        1 ps -o pid,args
    ```

    A brand-new PID namespace and a world containing one process. That's
    the entire trick a container plays, in a busybox one-liner. Docker's
    remaining value-add is images, networking, and lifecycle management —
    not isolation, which is 30 characters of `unshare`.

42. **Predict**: now drop `--mount-proc`, keeping `--pid --fork`. You'll
    still be PID 1 in a new namespace. What does `ps` show?
43. **B**: `unshare --pid --fork sh -c 'echo "pid ns: $(readlink /proc/self/ns/pid)"; echo "my pid: $$"; ps -o pid,args | wc -l; ps -o pid,args | head -3'`
44. **Observe**:

    ```
    pid ns: pid:[4026533232]
    my pid: 1
    142
    PID   COMMAND
        1 /initd
        2 [kthreadd]
    ```

    **PID 1 that can see 141 processes.** Two different processes are both
    "PID 1" in that listing — the shell (correctly, in its new namespace)
    and `initd` (as read from a `/proc` mounted in the old one). The
    namespace is completely real; `ps` is reading a stale filesystem.
    `--mount-proc` is shorthand for "also unshare the mount namespace and
    mount a fresh `/proc`", which is why *every* container gets a mount
    namespace whether or not it wants a private filesystem. Step 26 was
    the same bug from the other direction.

45. **B**: the smallest namespace of all, for contrast:

    ```
    / # hostname
    796a8bdda9ec
    / # unshare --uts sh -c 'hostname ns-by-hand; hostname'
    ns-by-hand
    / # hostname
    796a8bdda9ec
    ```

    A namespace whose whole content is one string, unshared and modified
    and discarded in one line, with the outer hostname untouched. (This
    works here because B is `--privileged` and therefore holds
    `CAP_SYS_ADMIN` — in an ordinary container it fails, exactly as in
    Part 5. `796a8bdda9ec` is B's own container ID: even the shell that
    can see the whole host still has a private UTS namespace, because
    `--pid=host` hands back exactly one of the eight.)

## What you should see

One `sh` process that is PID 1 with two children when it looks at itself,
and PID 39023 with a `containerd-shim` parent among ~137 processes when
the VM looks at it — with `NSpid: 39023 1` in `/proc/39023/status` and a
bare `NSpid: 1` in the container's own `/proc/1/status`, the same field
answering differently depending on who reads it. Identical namespace inodes
(`pid:[4026532695]`, all ten lines, timestamps included) read from either
side, all differing from the VM's initial `pid:[4026531836]` — except
`user:[4026531837]`, which matches. `kill 39023` from inside failing with
*No such process*, while `kill 39037` from outside silently removes the
container's PID 8. A second container joining exactly one of ns-a's eight
namespaces and keeping its own hostname. And
`unshare --pid --fork --mount-proc` producing a one-process world with no
Docker involved — plus, without `--mount-proc`, a PID 1 that still lists
141 processes.

## Why

Because namespaces virtualize *names*, not resources. A PID namespace
gives the kernel a second table in which to register your process, and
`/proc` shows a process only the table entries reachable from its own. So
the container's `ps` is not lying and the VM's `ps` is not privileged
information — they're two indexes into one list of tasks, and `NSpid` is
the row that holds both keys.

That design forces the asymmetry you saw in Part 3, and
`pid_namespaces(7)` states it outright: a process is visible in its own
namespace and every ancestor, and "the processes in a child PID namespace
can't see processes in the parent". Naming therefore works outward-in only.
Permission checks, meanwhile, are unaffected by namespaces at all —
`kill 39037` succeeded because the caller was uid 0 and the target was
uid 0, exactly as it would have with no containers in the picture. Nothing
consulted the boundary.

It also explains why isolation has to be assembled rather than switched
on. Each namespace type is a separate kernel object with a separate
`CLONE_NEW*` flag, and `docker run` sets seven of them. The eighth, user,
it leaves alone — so the uid 0 in your container is the uid 0 that owns
the VM. Every practical container-security control (`--user`, seccomp,
dropped capabilities, read-only rootfs, userns-remap) exists to shore up
that gap, because namespaces were built to make processes *not see* each
other, which is a different goal from making them safe from each other.

And the `--mount-proc` surprise in Part 7 is the general shape of every
container bug you'll hit: the kernel state changed, and something that
caches or re-reads the old view didn't. Same reason a process that had a
file open before the mount namespace was unshared keeps reading it, and
the same reason `/proc/<pid>/root` in Part 3 works at all.

## Go deeper

- Make a network namespace persist without a container. In a privileged
  container (`apk add iproute2` first — busybox's `ip` has no `netns`
  subcommand): `ip netns add ns-lab && ip netns exec ns-lab ip -o link show`
  lists a namespace with nothing but `lo` and the tunnel stubs. `ip netns`
  is a thin wrapper over `unshare --net` plus a bind mount of
  `/proc/self/ns/net` onto `/var/run/netns/ns-lab` — which is how a
  namespace outlives every process in it.
- `docker run --rm --uts=host alpine:3.20 hostname` prints `docker-desktop`,
  the VM's hostname, against a random container ID (`cb76412e42bd` on the
  run captured here) for the default. The same flag exists for every
  namespace type; work through them and note which ones Docker will let you
  share (`--uts`, `--ipc`, `--pid`, `--network`, `--cgroupns`) and which it
  won't (`mnt`, `user`).
- Two containers with `--ipc=container:ns-a`: `apk add util-linux`, create
  a SysV segment with `ipcmk -M 1024` in one and list it with `ipcs -m` in
  the other, then repeat without the flag and watch it vanish. This is the
  namespace Postgres and Oracle containers actually care about.
- Nest it: run `docker run --rm --privileged docker:dind`, start a
  container inside it, and read `NSpid` for one of its processes from the
  B shell. `NSpid` carries one number per PID namespace the task is
  visible in, so nesting should lengthen the line to three numbers — the
  mechanism from steps 11 and 12 with the recursion made visible.
- Read `man 7 namespaces`, then `man 7 pid_namespaces` for the PID 1
  semantics this exercise skipped: PID 1 gets no default signal handlers,
  so `docker stop` on a shell that ignores `SIGTERM` waits the full 10
  seconds; and when PID 1 exits, the kernel kills the whole namespace.
  That's why `tini`/`--init` exists. For the `NSpid`/`NStgid` field
  definitions, the page you want is `man 5 proc_pid_status`, not
  `namespaces(7)`.
- The counterpart mechanism: cgroups. Namespaces control what a container
  can see; cgroups control what it can consume. The
  [cgroup memory limits](cgroup-memory-limits.md) exercise picks up
  exactly where this one stops.

## Cleanup

Only the containers this exercise created, by name — never `prune`:

```bash
docker rm -f ns-a ns-b ns-c
```

Every other container the exercise starts (`--pid=container:ns-a`, the
`--cap-add SYS_ADMIN` and `--network host` one-liners, and Session B
itself) was launched with `--rm` and removes itself. Then exit the B shell.
