6.1 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, Fd (RAII fd), FileLock (bounded flock), parseSize/formatSize, runProcess (fork/exec, capped capture), fsutil (mkdirs, read/write, bounded removeTree, mirrorDirs skeleton, copyTree, copyFiles, diskUsage with hardlink dedupe). |
src/idmap.{hpp,cpp} |
IdMap (single or subuid mapping via newuidmap/newgidmap, subuid range lookup), UserNsJob (run a function as root of a user namespace with the same map: image extraction, deleting subuid-owned trees). |
src/image.{hpp,cpp} |
Catalog (alpine, ubuntu resolution from publishers), ImageStore (add with download, sha256 check, extraction; list; delete). |
src/cgroup.{hpp,cpp} |
Cgroup: one cgroup v2 leaf per running container under the user's delegated cgroup, used for cgroup.freeze. |
src/network.{hpp,cpp} |
NetMode + Network (parse, resolv.conf per mode), SlirpSession (owns the slirp4netns process). |
src/container.{hpp,cpp} |
Options (what the user asked for), Container (paths, meta, pid file, layout, one-time seeding, usage), Store (list, get_or_create with on-demand image fetch, remove). |
src/sandbox.{hpp,cpp} |
Sandbox: start a container (clone + id map + mounts + pivot_root + init), watch it (size cap), or join a running one (setns). |
src/cli.{hpp,cpp} |
Cli: argument parsing, the four container commands and image list/add/delete. |
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
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
slirp mode: SlirpSession.start(child) waits for ready-fd
Cgroup::create + add(child) best effort; without it the size cap only warns
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, watch(child)
watch: poll(pidfd, 10 s); each wake scans usage -> warn 80%, freeze 100%, thaw <95%
on exit: pid file cleared, slirp stopped, cgroup removed (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
Common start: MS_REC|MS_PRIVATE on /, then resolv.conf is written into the container's /etc location
(Container::etcDir: private/etc in host mode, the root upper layer in image mode), and on a single-id
container with apt present an APT::Sandbox::User "root" override is written next to it.
Host mode:
- Bind the container
rootfs/onto itself (pivot_root needs a mountpoint). - Overlay mounts for
/usr,/optand real compat dirs (lowerdir=<host dir>,upperdir,workdir,userxattr). - Compat symlinks recreated (usr-merge).
- Bind the private dirs
/root,/var,/etc; then the overlay for/var/lib/dpkginside the private/var.
Image mode:
- One overlay on
rootfs/: lower =images/NAME/rootfs, upper =layers/root/upper. - Mountpoints (
/proc /dev /tmp /run ...) created inside the merged tree.
Both modes then: tmpfs /tmp and /run; /dev (tmpfs, bound host nodes, new devpts, /dev/shm, symlinks);
/proc with read-only remounts and masks from spec.hpp; chdir(rootfs), pivot_root(".", "."), lazy
unmount of the old root.
Seeding (host mode, once per container)
Container::prepareLayout runs on create and as a heal on every later load; each step leaves a
seeded.<name> marker so it runs once and prints what it did and what it skipped:
- Directory skeleton of every overlaid host dir into its upper layer (
fsutil::mirrorDirs). - Regular files of
/var/lib/dpkg,triggers,alternativescopied into the upper (copyFiles), then empty lock files. /etccopied intoprivate/etc(copyTree).
Reasons are in SPEC.md (host root-owned files appear as nobody).
Images
ImageStore::add: lock-free staging dir images/.tmp-NAME, download with curl (https only, size and time caps),
sha256 check, tar -x -p --numeric-owner executed by UserNsJob with the image's id mode (--no-same-owner
for single), usage measured, meta written, atomic rename into place. Any failure removes the staging dir
from inside the id map, so a failed add leaves nothing. Store::getOrCreate calls it when a catalog image is
missing, and pins the container's id mode to the image's.
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.