rgoussu@goussu: ~/library/platform/exercises
~/library/platform/exercises cat build-your-own-container-runtime-subject.md

Build your own container runtime — subject

# The program spec for a minimal container runtime — run-command semantics, namespaces, pivot_root, cgroup v2 limits, veth networking, and OCI image pulling, with acceptance per stage.

Subjectsaved 2026-08-08source #exercise#containers#linux#platform#subject

Brief

Write mycontainer, a command-line program that runs an arbitrary command inside a container of your own making: isolated hostname and process view, its own root filesystem, bounded memory and CPU, its own network, and eventually a real image pulled straight from a registry. No Docker, no containerd, no runc — only syscalls (Go's syscall/golang.org/x/sys is the classic vehicle; any language that can clone with namespace flags works). You are building the proof that a container is an ordinary Linux process wearing kernel features.

Instructions

Command-line contract

mycontainer run [--mem <MB>] [--cpu <fraction>] <image|rootfs> <cmd> [args…]
  • run creates the container and executes <cmd> inside it, wiring the child's stdin/stdout/stderr to yours.
  • The parent waits for the child and propagates its exit code as its own.
  • Until the image stage, <image|rootfs> may be a path to a pre-extracted rootfs directory; from stage 5 it is an image reference like alpine:latest.

Stage 1 — namespaces (UTS, then PID)

  • run must re-execute itself (the fork/exec-self trick: a hidden child subcommand) so namespace setup happens in the parent and in-namespace setup in the child.
  • Create the child with new UTS and PID namespaces (CLONE_NEWUTS | CLONE_NEWPID); inside, set a container hostname before exec'ing <cmd>.

Stage 2 — a root of one's own (mount namespace + pivot_root)

  • Obtain an Alpine mini-root filesystem tarball and extract it once; this is your rootfs.
  • Add a new mount namespace. In the child: mark mount propagation private, bind-mount the rootfs onto itself, pivot_root into it (fallback: chroot, but know what you give up), unmount and remove the old root, and mount a fresh /proc so process tooling works.

Stage 3 — cgroup v2 resource limits

  • Create a per-container group under /sys/fs/cgroup/ (e.g. mycontainer/<container-id>/), enable the memory and cpu controllers, and add the child's PID to cgroup.procs before it execs the user command.
  • --mem writes memory.max (and set memory.swap.max to 0 so the limit is real); --cpu writes cpu.max as a quota/period pair.
  • Remove the group on exit.

Stage 4 — networking (net namespace + veth)

  • Add a new network namespace. From the parent: create a veth pair, move one end into the child's namespace, assign addresses on a small private subnet (e.g. 10.0.0.0/24), bring both ends up, set the default route inside.
  • On the host: enable IP forwarding and add a NAT/masquerade rule so container traffic reaches the outside world. Provide DNS inside (write a resolv.conf in the rootfs).

Stage 5 — real OCI images

Implement enough of the OCI distribution API to run mycontainer run alpine:latest sh with nothing pre-extracted:

  1. Auth: request a pull token from the registry's token endpoint (for Docker Hub: auth.docker.io, service registry.docker.io, scope repository:library/<image>:pull).
  2. Manifest: fetch the manifest (handle the manifest-list/index case by selecting your architecture), using the OCI/Docker v2 media-type Accept headers.
  3. Layers: download each layer blob by digest, verify the digest, and extract the gzipped tarballs in order into a per-image rootfs — or mount them as overlayfs lowerdirs with a fresh upperdir per container for true copy-on-write.
  4. Cache pulled layers by digest so the second run of the same image downloads nothing.

Stage 6 — UX hardening

Argument parsing with helpful errors, --mem/--cpu defaults, cleanup of cgroup, veth, and mounts on every exit path (including the child crashing), and a --help that lets a colleague use the tool without reading the source.

Constraints

  • Do not shell out to docker, podman, runc, or ctr for any container work (calling ip/iptables for host-side network plumbing is acceptable; extracting tarballs with your language's stdlib is preferred over tar).
  • Root is assumed; rootless operation is a stretch goal, not a requirement.
  • Keep each stage's work on its own commit(s) so the build-up stays reviewable.

Acceptance

Mapped to the exercise's milestones:

  1. Namespaces — inside: hostname shows the container name; after stage 2, ps shows the command as PID 1. Outside, in a second terminal: the host's hostname is unchanged while the container runs.
  2. Rootfs — inside: / lists the Alpine tree, ls /proc shows only container PIDs, and no path (.., /proc/1/root…) escapes to the host filesystem.
  3. Cgroups — run a memory hog (e.g. a loop appending to a slice, or dd/stress) with --mem 100: the process is OOM-killed near 100 MB and the host stays healthy; with --cpu 0.5, a busy loop is pinned at ~50% of one core (top on the host confirms).
  4. Network — inside: ping <host-veth-ip> and ping 8.8.8.8 succeed and a DNS name resolves; the container's interfaces are invisible from the host's default namespace.
  5. Imagesmycontainer run alpine:latest sh works on a clean cache directory: token fetched, manifest resolved for your architecture, layer digests verified, filesystem correct (a file added by a later layer wins over earlier ones). Second run: zero blob downloads.
  6. UXecho $? after mycontainer run alpine sh -c 'exit 42' prints 42; after any run, no leftover cgroup directory, veth interface, or mount remains.

Related