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 aforkafter it, exactly as in the Go and Rust versions.ctypescalls libc functions thatosdoes 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
chrootinstead ofpivot_root. The container-security chapter of Security in Depth lists what real runtimes add and why each piece matters.