Files
doos/docs/ARCHITECTURE.md
T

3.6 KiB

Architecture

How doos works internally. Behavior contract is SPEC.md; this explains the code.

File map

File Class / role
src/spec.hpp spec:: constants: caps, mount lists, device list, paths. Single source of truth with SPEC.md.
src/util.{hpp,cpp} SysError (errno-carrying exception), Fd (RAII fd), FileLock (bounded flock), fsutil (mkdirs, read/write, bounded removeTree).
src/network.{hpp,cpp} NetMode + Network (parse, resolv.conf per mode), SlirpSession (owns the slirp4netns process).
src/container.{hpp,cpp} Container (one container's paths, meta, pid file, layout), Store (list, get_or_create, remove).
src/sandbox.{hpp,cpp} Sandbox: start a container (clone + mounts + pivot_root + init) or join a running one (setns).
src/cli.{hpp,cpp} Cli: argument parsing and the four commands.
src/main.cpp Catches exceptions, prints doos: <message>, exit 1.
tests/run.sh End-to-end suite.
scripts/release.sh Bump, build, test, commit, tag.
apparmor/doos Profile granting userns on Ubuntu 24.04+.

attach flow

Cli::cmdAttach
  Store::getOrCreate           lock, create layout + meta if missing
  Sandbox::run
    FileLock (start-or-join decision)
    runningPid()?  yes -> join()   no -> start()

start():
  clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])      child = container pid 1
  parent writes /proc/<child>/{setgroups,uid_map,gid_map}  0 -> invoking uid, size 1
  slirp mode: SlirpSession.start(child)                    waits for ready-fd
  recordPid(child)  (pid + starttime, defeats pid reuse)
  lock.unlock()     joiners must not wait for container lifetime
  write 'G' on sync pipe                                    child continues
  parent: ignore SIGINT/SIGQUIT, forward SIGTERM/SIGHUP, waitpid(child)
  on exit: pid file cleared, slirp stopped (RAII)

child (containerInit, pid 1):
  PDEATHSIG=SIGKILL, close inherited lock fd, wait for 'G'
  setupRoot -> sethostname -> loopback up (isolated netns) -> rlimits/env/no_new_privs
  fork worker -> exec command
  pid 1 loop: forward signals, reap all, on worker exit kill(-1, SIGKILL), exit with its code

setupRoot order

  1. MS_REC|MS_PRIVATE on / so nothing propagates to the host.
  2. Bind the container rootfs/ onto itself (pivot_root needs a mountpoint).
  3. Write resolv.conf into the /etc upper dir (matches network mode every start).
  4. Overlay mounts (lowerdir=<host dir>,upperdir,workdir,userxattr, nosuid, nodev).
  5. Compat dirs: recreate host symlinks (usr-merge) or overlay real dirs.
  6. Bind private dirs (/root, /var), mount tmpfs (/tmp, /run).
  7. /dev: tmpfs, bind host device nodes, new devpts, /dev/shm, symlinks.
  8. /proc, then read-only remount and masks from spec.hpp.
  9. chdir(rootfs), pivot_root(".", "."), lazy unmount of old root.

join flow

Open /proc/<pid>/ns/{user,mnt,uts,ipc,net,pid} and /proc/<pid>/root first, fork, setns user first (grants capability for the rest), fchdir+chroot to the container root, fork again (pid ns applies to children), apply env and limits, exec. In host network mode the net namespace is not joined.

Lifetime model

The namespaces live as long as the first attach command. When it exits, pid 1 kills the rest and the container is stopped. State (upper dirs, /root, /var) persists on disk until delete --yes. There is no daemon.

Error handling

All failures throw (SysError or std::runtime_error); main prints one line. Inside the child, errors print doos: <message> and exit 125. A parent-side failure after clone kills the child (Reaper), so no half-built container survives.