Frontend Labs
⋯

39. A container in 80 lines: namespaces with os.unshare

A container is a process with its own namespaces, a cgroup, and a root filesystem (OS Ch 44). Since 3.12, Python's os module has unshare() and setns(), so you can build one without C. This is the Python twin of the Go container runtime and the Rust minibox.

# labs/minibox.py: run a command in new UTS, PID, mount, and IPC namespaces with a memory limit
# Usage (as root, Linux): python3 minibox.py /path/to/rootfs /bin/sh
import ctypes
import os
import socket
import sys
from pathlib import Path

libc = ctypes.CDLL(None, use_errno=True)
MS_REC, MS_PRIVATE = 0x4000, 1 << 18

def mount(src: str, target: str, fstype: str, flags: int = 0) -> None:
    if libc.mount(src.encode(), target.encode(), fstype.encode(), flags, None) != 0:
        e = ctypes.get_errno()
        raise OSError(e, f"mount {target}: {os.strerror(e)}")

def limit_memory(name: str, max_bytes: int) -> None:
    cg = Path("/sys/fs/cgroup") / name                   # cgroup v2
    cg.mkdir(exist_ok=True)
    (cg / "memory.max").write_text(str(max_bytes))
    (cg / "pids.max").write_text("64")
    (cg / "cgroup.procs").write_text(str(os.getpid()))   # move ourselves (and future children) in

def main(rootfs: str, cmd: list[str]) -> int:
    limit_memory("minibox", 256 * 2**20)
    os.unshare(os.CLONE_NEWUTS | os.CLONE_NEWPID | os.CLONE_NEWNS | os.CLONE_NEWIPC)
    pid = os.fork()                                      # the child is PID 1 in the new PID namespace
    if pid == 0:
        try:
            socket.sethostname("minibox")                # only visible in our UTS namespace
            mount("none", "/", "", MS_REC | MS_PRIVATE)  # don't propagate our mounts to the host
            os.chroot(rootfs)
            os.chdir("/")
            os.makedirs("/proc", exist_ok=True)
            mount("proc", "/proc", "proc")               # a /proc that shows only our PID namespace
            os.execvp(cmd[0], cmd)                       # replace the child with the command
        except Exception as e:
            print("minibox child:", e, file=sys.stderr)
            os._exit(127)
    _, status = os.waitpid(pid, 0)
    return os.waitstatus_to_exitcode(status)

if __name__ == "__main__":
    if len(sys.argv) < 3:
        sys.exit("usage: minibox.py ROOTFS CMD [ARGS...]")
    sys.exit(main(sys.argv[1], sys.argv[2:]))

Try it inside the privileged lab container (docker run --privileged --cgroupns=host ...):

mkdir -p /tmp/rootfs && docker export $(docker create alpine) | tar -C /tmp/rootfs -xf -   # on the host
# inside the lab container, with /tmp/rootfs mounted:
python3 labs/minibox.py /tmp/rootfs /bin/sh -c 'hostname; echo pid=$$; ps; cat /proc/self/cgroup'
minibox
pid=1
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh -c hostname; echo pid=$$; ps; cat /proc/self/cgroup
    2 root      0:00 ps
0::/minibox

What to notice:

  • os.unshare(CLONE_NEWPID) does not move the caller into the new PID namespace; the next child does. That is why there is a fork after it, exactly as in the Go and Rust versions.
  • ctypes calls libc functions that os does not wrap (mount). It is Python's FFI in one line: powerful, and with no type safety at all; a wrong argument type crashes the interpreter.
  • This is a teaching container: no user namespace, no network namespace, no seccomp filter, no capability dropping, and chroot instead of pivot_root. The container-security chapter of Security in Depth lists what real runtimes add and why each piece matters.

Generated from real-life-python-guide.md · 8 Oct 2026 16:36