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…]
runcreates 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 likealpine:latest.
Stage 1 — namespaces (UTS, then PID)
runmust re-execute itself (the fork/exec-self trick: a hiddenchildsubcommand) 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_rootinto it (fallback:chroot, but know what you give up), unmount and remove the old root, and mount a fresh/procso process tooling works.
Stage 3 — cgroup v2 resource limits
- Create a per-container group under
/sys/fs/cgroup/(e.g.mycontainer/<container-id>/), enable thememoryandcpucontrollers, and add the child's PID tocgroup.procsbefore it execs the user command. --memwritesmemory.max(and setmemory.swap.maxto 0 so the limit is real);--cpuwritescpu.maxas 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.confin 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:
- Auth: request a pull token from the registry's token endpoint (for Docker Hub:
auth.docker.io, serviceregistry.docker.io, scoperepository:library/<image>:pull). - Manifest: fetch the manifest (handle the manifest-list/index case by selecting your architecture), using the OCI/Docker v2 media-type Accept headers.
- 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.
- 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, orctrfor any container work (callingip/iptablesfor host-side network plumbing is acceptable; extracting tarballs with your language's stdlib is preferred overtar). - 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:
- Namespaces — inside:
hostnameshows the container name; after stage 2,psshows the command as PID 1. Outside, in a second terminal: the host's hostname is unchanged while the container runs. - Rootfs — inside:
/lists the Alpine tree,ls /procshows only container PIDs, and no path (..,/proc/1/root…) escapes to the host filesystem. - 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 (topon the host confirms). - Network — inside:
ping <host-veth-ip>andping 8.8.8.8succeed and a DNS name resolves; the container's interfaces are invisible from the host's default namespace. - Images —
mycontainer run alpine:latest shworks 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. - UX —
echo $?aftermycontainer run alpine sh -c 'exit 42'prints 42; after any run, no leftover cgroup directory, veth interface, or mount remains.
Related
- Build your own container runtime — the exercise note this is the subject of.
- Containers from Scratch (Liz Rice) — the seed demo this spec grows out of.
- Coding Challenges — Build Your Own Docker — the same ground as a challenge series.