2 Commits
Author SHA1 Message Date
retoorandClaude Sonnet 5.5 97fa625a50 Charts and reproducible benchmarks, Makefile, expanded .gitignore
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:24:56 +02:00
retoorandClaude Sonnet 5.5 4fed40e5cd Add COMPARISON.md, cross-link all documents, enforce with check-docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:18:42 +02:00
19 changed files with 1064 additions and 16 deletions
+32
View File
@@ -1 +1,33 @@
# build output
build/
cmake-build-*/
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
compile_commands.json
*.o
*.a
*.so
*.d
# state and scratch created by running or testing doos
.doos/
*.tmp
*.log
*.pid
*.core
core
# editors and OS
.vscode/
.idea/
*.swp
*~
.DS_Store
# local AI tooling state
.claude/
# python bytecode from docs/charts/make_charts.py
__pycache__/
*.pyc
+18 -5
View File
@@ -1,5 +1,7 @@
# AGENTS.md
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
Rules for any AI agent (or human) changing this repository. The user's global rules
also apply; this file adds the project-specific ones. Keep this file current: it is
part of every release.
@@ -11,8 +13,9 @@ part of every release.
extracted image (image mode) with a per-container upper dir, pivots into it and runs a command.
Networking is slirp4netns or pasta, ids are mapped through newuidmap when possible, cgroup v2 limits
and a watchdog enforce `--memory --pids --cpus --size`, capabilities and seccomp confine the command.
No root at runtime. Authoritative behavior: `SPEC.md`. Design notes: `docs/ARCHITECTURE.md`.
Measurements and gaps: `docs/RESEARCH.md`.
No root at runtime. Authoritative behavior: [SPEC](SPEC.md). Design notes: [ARCHITECTURE](docs/ARCHITECTURE.md).
Measurements and gaps: [RESEARCH](docs/RESEARCH.md). Positioning against other tools: [COMPARISON](COMPARISON.md).
Usage: [README](README.md). History: [CHANGELOG](CHANGELOG.md).
## Mandatory release workflow (every change, same session)
@@ -22,11 +25,12 @@ Measurements and gaps: `docs/RESEARCH.md`.
- `patch`: bug fix, docs, tests, tooling, no behavior change for users.
- `minor`: new or changed user-visible behavior (flag, command, mount, limit).
- `major`: breaking change to CLI, state layout, or container compatibility.
4. Run `scripts/release.sh <patch|minor|major> "imperative commit subject"`.
4. Run `make docs` (`charts` plus `check-docs`) (the release script does too: every document must link to every other, every relative link must resolve).
5. Run `scripts/release.sh <patch|minor|major> "imperative commit subject"`.
It bumps `VERSION`, builds, runs `tests/run.sh`, commits all changes, and creates
the annotated tag `vX.Y.Z`. It aborts on any build or test failure.
5. Verify live: run `build/doos` once against the real thing (the e2e suite does this).
6. Report honestly what passed and what did not. Do not push unless the user asks.
6. Verify live: run `build/doos` once against the real thing (the e2e suite does this).
7. Report honestly what passed and what did not. Do not push unless the user asks.
Never hand-edit `VERSION` or create tags manually. Never skip the tests to release.
@@ -42,6 +46,9 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
| A new measurement | `docs/RESEARCH.md` with machine, kernel, and what was run |
| A new known gap or a closed gap | `docs/RESEARCH.md` gaps list and `README.md` |
| These workflow rules | this file |
| A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `<!--bench:...-->` number and table in `COMPARISON.md` and `docs/RESEARCH.md`); never type those numbers by hand |
| A feature or limit that appears in the comparison tables | `COMPARISON.md` (tables, the gaps list, the conclusion) |
| A new or renamed markdown file | `scripts/check-docs.sh` DOCS list, the nav line (`**Docs:**`) of every document, `README.md` Project docs |
| Image catalog, sources, verification, pinned keys | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
| Seccomp list, capability set, `/etc` allowlist | `SPEC.md` Hardening and filesystem, `README.md`, `docs/RESEARCH.md` |
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
@@ -75,6 +82,12 @@ freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 de
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself).
## Make targets
`make help` lists them: `build`, `test`, `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
`charts` (SVGs and generated numbers from that file), `docs`, `release KIND=patch MSG="..."`, `clean`.
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/` and `docs/charts/`.
## Environment notes
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
+11
View File
@@ -1,5 +1,16 @@
# Changelog
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
## 1.1.3 - 2026-10-23
- Charts: startup time, executable size and CPU limit as SVG (light and dark palettes, doos highlighted), embedded in [COMPARISON](COMPARISON.md) and [RESEARCH](docs/RESEARCH.md).
- Reproducible measurements: `scripts/bench.sh` writes `docs/charts/bench.json`; `docs/charts/make_charts.py` builds the charts and rewrites every generated number and table in the documents from it, so text, tables and charts cannot drift apart.
- `Makefile` wrapper (`make help`), expanded `.gitignore`.
## 1.1.2 - 2026-10-23
- Docs: new [COMPARISON](COMPARISON.md) (doos against Docker, rootless Docker, Podman, bubblewrap, systemd-nspawn, LXC/Incus, Apptainer, Distrobox/Toolbx, gVisor, Kata/Firecracker) with sourced pros and cons, startup and disk measurements against Docker, Podman and bubblewrap on the same machine, a gaps list tied to code, and a conclusion.
- Docs: every document now has a navigation line and links to every other document. `scripts/check-docs.sh` enforces it (and that relative links resolve) and runs inside `scripts/release.sh`.
## 1.1.1 - 2026-10-23
- `doos apparmor` prints a profile for the exact path of the running binary (own profile name per path, so several installs coexist). A start that fails because of AppArmor now prints the install command instead of only `mount bind /: Permission denied`. Verified with a copy in `~/.local/bin`.
- `apparmor/doos` (packaging) covers more standard install paths.
+191
View File
@@ -0,0 +1,191 @@
# doos compared with Docker, Podman and others
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is:
- **[M] measured** on the development machine, described in "Measurements" below.
- **[S] sourced** from the linked documentation or articles. Vendor blogs are marked as such and their numbers are not used.
- **[D] design**: what the doos source and [SPEC](SPEC.md) do today.
- **[K] general knowledge** that this research did not re-verify.
Nothing here is a roadmap. Where doos lacks something, the gap is listed with the code that would have to change.
Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), Podman 5.7.0 (netavark, crun, overlay), bubblewrap 0.11.1, on Ubuntu 26.04, kernel 7.0.0-38, 12 cores, one machine.
## 1. At a glance
| | doos | Docker (rootful) | Docker rootless | Podman rootless |
|---|---|---|---|---|
| Needs a daemon | no [D] | yes, root daemon [S: [Docker security](https://docs.docker.com/engine/security/)] | yes, per-user daemon [K] | no [S: [rootlesscontaine.rs](https://rootlesscontaine.rs/getting-started/podman/)] |
| Runs without root | yes [D] | no [S] | yes [S] | yes [S] |
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
| Host dirs into the container (`-v`) | **no** [D] | yes [K] | yes | yes |
| Published ports | none for `slirp`; automatic for `pasta` [D] | `-p` [K] | `-p` | `-p` [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
| Default syscall filter | denylist: 48 listed calls plus rules for `clone`, `unshare`, `clone3` [D] | allowlist profile [S: [Docker seccomp](https://docs.docker.com/engine/security/seccomp/)] | same | built-in profile [K] |
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
| Binary size | <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] |
| Platforms | Linux only, tested on x86_64 only [D] | Linux, macOS, Windows (via a VM) [K] | Linux | Linux; macOS and Windows via a VM [K] |
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
## 2. Other tools, one line each
| Tool | What it is | Versus doos |
|---|---|---|
| bubblewrap (`bwrap`) | Low-level unprivileged sandbox builder, the engine behind Flatpak [S: [bubblewrap README](https://cdn.jsdelivr.net/gh/containers/bubblewrap/README.md), [Julia Evans](https://jvns.ca/blog/2022/06/28/some-notes-on-bubblewrap/)] | Smaller and faster to start [M], but you assemble every mount by hand and it has no image handling, no persistent state and no cgroup limits [K]; it maps a single id unless you add a helper [K]. doos is the "batteries included" layer on the same kernel features. |
| systemd-nspawn | Boots a whole OS tree as a container [S: [forum overview](https://forums.puri.sm/t/linux-containers-comparison-quest-for-the-best/13344)] | Rootless use is limited to a single UID/GID inside [S]. Not installed here, so not measured. |
| LXC / Incus (LXD fork) | System containers: a full distro with init and several services, close to a light VM [S: [OneUptime](https://oneuptime.com/blog/post/2026-03-18-choose-between-podman-and-lxc-lxd/view)] | Better for long-lived pet systems and multi-service setups, with API, storage pools and profiles. Heavier to set up. `lxc` binaries exist here, Incus does not, neither was run. |
| Apptainer (Singularity) | Rootless, daemonless, single-file SIF images, built for HPC (MPI, InfiniBand, GPUs) [S: [Alliance Canada](https://docs.alliancecan.ca/wiki/Apptainer), [NYU HPC](https://services.rt.nyu.edu/docs/hpc/containers/intro/)] | Different audience. It also has `--fakeroot` and signed immutable images [S], which doos lacks. Not installed here. |
| Distrobox / Toolbx | Podman- or Docker-backed "your distro inside your terminal", home directory shared on purpose, not a sandbox [S: [Arch wiki](https://wiki.archlinux.org/title/Distrobox), [comparison](https://computingforgeeks.com/distrobox-vs-toolbox/)] | Opposite goal: doos hides the host's `/home` by default. |
| gVisor | A user-space kernel that answers most syscalls itself, cutting host-kernel exposure at an I/O cost [S: [Edera](https://edera.dev/stories/kata-vs-firecracker-vs-gvisor-isolation-compared), [Northflank](https://northflank.com/blog/kata-containers-vs-firecracker-vs-gvisor)] | Stronger isolation than doos, needs its own runtime. Vendor blogs; their numbers are not used here. |
| Kata Containers / Firecracker | Each workload in its own KVM micro-VM with its own kernel [S: same sources, [MASCOTS 2020 paper](https://cristian.kleinlabs.eu/publications/mascots2020_container_runtime_security.pdf)] | The right tool for hostile or multi-tenant code. doos shares the host kernel, so a kernel bug is a doos escape. |
## 3. Measurements
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on <!--bench:bench_date-->2026-10-23<!--/bench-->. Versions: <!--bench:versions-->doos 1.1.2, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
![Horizontal bar chart of the time to start a trivial command for doos, bubblewrap, Podman and Docker, with and without network. doos is highlighted. Exact values are in the table below.](docs/charts/startup.svg)
<!-- bench:startup:begin -->
| Setup | Network | Time per run |
|---|---|---|
| bubblewrap (host /usr) | none | 8.4 ms |
| doos Alpine image | none | 13.8 ms |
| doos host mode | none | 18.7 ms |
| Podman Alpine | none | 208 ms |
| Docker Alpine (rootful) | none | 344 ms |
| bubblewrap (host /usr) | default | 7.4 ms |
| doos Alpine image | default | 52.1 ms |
| doos host mode | default | 56.9 ms |
| doos Alpine, pasta | default | 64.6 ms |
| Podman Alpine | default | 241 ms |
| Docker Alpine (rootful) | default | 436 ms |
<!-- bench:startup:end -->
How to read this honestly:
- doos is fast partly because it does less: no image store database, no registry code, no layer unpacking per run, no container metadata service. The extra time over bubblewrap is the id map helpers (`newuidmap`, `newgidmap`), the overlay mounts, a hardening filter and the network helper process.
- Docker here is a rootful daemon, so its numbers include a client-to-daemon round trip. Rootless Docker was not measured.
- Run-to-run spread is real. An earlier ad-hoc run of the same loop measured rootful Docker at 590 ms (no network) and 456 ms (default network); the run charted above measured 344 ms and 436 ms. Read the order of magnitude, not a ranking between close values.
- Startup is one number among many. It matters for scripts that start hundreds of short-lived sandboxes. It does not matter for a service that starts once.
![Horizontal bar chart of executable sizes on a logarithmic axis for bubblewrap, crun, doos, the docker CLI and podman. doos is highlighted. Exact values are in the table below.](docs/charts/size.svg)
<!-- bench:sizes:begin -->
| Executable | Size |
|---|---|
| bubblewrap | 79 KiB (80,424 bytes) |
| crun | 472 KiB (483,392 bytes) |
| doos | 552 KiB (565,680 bytes) |
| docker CLI | 29.9 MiB (31,374,336 bytes) |
| podman | 41.2 MiB (43,180,896 bytes) |
<!-- bench:sizes:end -->
Disk, measured with `du` (which cannot read overlayfs' mode-000 work directories, so true usage is slightly higher):
| Item | Size |
|---|---|
| doos Alpine image, extracted once and shared | 8.3 MiB |
| doos Ubuntu base image, extracted once and shared | 98 MiB |
| one doos container on top of an image | a few KiB of metadata plus its own writes |
| four containers (one Alpine, three Ubuntu) on shared images | 20 KiB together |
| doos host-mode container after its first create (skeleton, `/etc` allowlist, dpkg state) | 4 MiB, created in 0.5 s |
| Podman storage on this machine (many unrelated images) | 26.3 GB images, 1.8 GB containers |
The Podman figure is this machine's accumulated state, not a like-for-like comparison. What the comparison does show is the model: doos extracts an image once and every container adds only its own writes.
## 4. doos versus Docker
**Where doos is better**
- No daemon and no root. With Docker's default install, anyone who can talk to the daemon controls the machine [S]. doos has no such trust boundary: it can only do what the invoking user can do, plus what the `newuidmap` helper allows.
- Tiny and auditable: <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
- Instant: <!--bench:doos_range-->14 to 65 ms<!--/bench--> against <!--bench:docker_range-->344 to 436 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->208 to 241 ms<!--/bench--> for Podman [M].
- Safe defaults a user does not have to remember: Docker's default capability set, a seccomp filter, an allowlist of host `/etc`, a memory and pid cap, a written-data cap [D][M].
- A "host mode" that reuses the host's installed tools (compilers, `apt` state) for throwaway shells with no image download [D].
- A size cap that freezes instead of filling the disk [D][M]. Docker's writable-layer size limit needs the overlay2 driver on XFS with project quotas [S: [Fabian Lee](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)].
**Where Docker is better**
- Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry.
- Host directories and devices into the container (`-v`, `--device`, GPUs). doos has no bind-mount option [D].
- Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D].
- Networking: user-defined networks, DNS between containers, published ports on any interface [K].
- Compose, Swarm and the Kubernetes ecosystem [K].
- macOS and Windows support through a VM [K].
- Strongest default syscall posture: an allowlist, which denies unknown new syscalls. The doos filter is a denylist, so a new kernel syscall is allowed until it is added [D][S].
## 5. doos versus Podman
Podman is the closest comparison, since both are daemonless and rootless.
**Where doos is better**
- Smaller, faster to start [M] and one binary with no storage database. Podman's rootless setup brings a store under `~/.local/share/containers` with its own metadata [K].
- Opinionated limits and hardening out of the box, with a stated, measured effect (OOM kill, pids, cpus, freeze at a size cap) [M].
- Two network backends with different, documented trade-offs, including a host-like mode that keeps host abstract unix sockets out of the container [M].
- Image verification is explicit: sha256 plus signatures checked against fingerprints pinned in the source [D][M].
- Frozen-at-the-cap behaviour with a never-frozen rescue shell [D][M].
**Where Podman is better**
- OCI compatibility end to end: pull, push, build with Buildah, pods, `kube play`, Quadlet and systemd integration [K]. It can also run images made for Docker unchanged.
- Docker-compatible CLI and socket, so existing tooling mostly works [K].
- Bind mounts, volumes, secrets, `exec`, `logs`, `stop`, restart policies [K].
- Years of hardening, a security team, and a large user base finding the sharp edges. doos has one test machine.
- Podman documents rootless caveats: ports below 1024, `/etc/subuid` setup, cgroup v2 only, and a rootless port forwarder that does not preserve client source IPs [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)]. doos shares the subuid and cgroup v2 requirements [D]. Binding ports below 1024 in doos was not tested.
**What both share:** the same kernel features (user, mount, pid, network namespaces, overlayfs, cgroup v2, seccomp), the same pasta/slirp4netns options, and the same weakness: they share the host kernel [S].
## 6. Security, side by side
- **Shared kernel.** doos, Docker, Podman, bubblewrap, nspawn and LXC all run container processes on the host kernel. A kernel vulnerability reachable through allowed syscalls is a host vulnerability. gVisor reduces that exposure at a performance cost; Kata and Firecracker remove it by giving each workload its own kernel [S].
- **Rootless is the real difference from rootful Docker.** A bug that gives root inside a rootless doos or Podman container yields the invoking user's rights on the host, not root [D][S].
- **doos filter style.** A denylist built at runtime with an architecture check, x32 guard and `clone3` handling [D]. Docker and Podman ship allowlist profiles [S]. An allowlist is the more conservative design.
- **Missing layers.** Docker applies an AppArmor profile (`docker-default`) on this machine [M: `docker info` lists apparmor]. doos installs no AppArmor or SELinux profile of its own; its only profile exists to *permit* user namespaces on Ubuntu 24.04+ [D].
- **Supply chain.** doos pins publisher signing keys for Alpine and Ubuntu and checks digests for Debian [D][M]. Registry-based tools rely on registry trust, content trust or external signing tools [K].
Full list of what doos does not cover is at the end of [RESEARCH](docs/RESEARCH.md); the exact hardening is in [SPEC](SPEC.md) under Hardening.
## 7. What doos lacks, and which code would change
These are the gaps that matter when comparing, each next to where it lives. They are descriptions, not promises.
| Gap | Where |
|---|---|
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) |
| No host directory bind mounts, no devices, no GPU | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
| No background containers, `stop`, `exec` by name, logs | lifetime model in `Sandbox::start` / `Sandbox::watch` (container lives with its first command) |
| No user-defined networks, no `-p` | `src/network.cpp` |
| No Compose or Kubernetes integration | not present |
| Environment, user, working-directory flags missing | `applyLimitsAndEnv` in `src/sandbox.cpp` |
| Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there |
| seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` |
| Test coverage: one machine | `tests/run.sh` |
## 8. Conclusion
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->552 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->14 to 65 ms<!--/bench--> [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
Choose by the job:
| If you need | Use |
|---|---|
| A quick, isolated shell or script runner for one user, host tools or a distro image, with limits, no daemon | **doos** |
| OCI images, Dockerfiles or Containerfiles, registries, CI that matches production | **Podman** (rootless, daemonless) or **Docker** |
| Compose, Kubernetes parity, Docker ecosystem tooling | **Docker** or **Podman** with its Docker compatibility |
| Sandbox one application with exact control over mounts | **bubblewrap** |
| A full OS with init and several services, long lived | **Incus / LXC** or **systemd-nspawn** |
| Shared HPC cluster, MPI, GPUs, signed single-file images | **Apptainer** |
| A desktop environment tied to your home directory | **Distrobox / Toolbx** |
| Running code you do not trust at all, or multi-tenant workloads | **Kata / Firecracker** (or gVisor as a middle step), not any shared-kernel container |
Honest limits of this comparison: one machine, 10-run averages, no rootless Docker, no systemd-nspawn, Incus or Apptainer measurements, and several Docker and Podman capabilities listed as general knowledge rather than re-verified. The conclusion about when to use doos rests on what the doos tests and measurements show ([RESEARCH](docs/RESEARCH.md)), not on a claim that it beats mature tools on features. On features it does not.
Back to [README](README.md) for usage, [SPEC](SPEC.md) for the exact behavior, [ARCHITECTURE](docs/ARCHITECTURE.md) for how it works, [AGENTS](AGENTS.md) for the rules, and [CHANGELOG](CHANGELOG.md) for history.
+35
View File
@@ -0,0 +1,35 @@
# Thin wrapper over the commands used to build, test and update the project. CMake does the building,
# scripts/ and docs/charts/ do the rest. `make help` lists the targets.
.DEFAULT_GOAL := help
BUILD ?= build
DOOS := $(BUILD)/doos
.PHONY: help build test check-docs bench charts docs release clean
help: ## list the targets
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | column -t -s "$$(printf '\t')"
build: ## configure and compile into build/
cmake -S . -B $(BUILD) >/dev/null
cmake --build $(BUILD)
test: build ## end-to-end suite (real downloads, needs network, pasta, gpg, cgroup v2)
tests/run.sh $(DOOS)
check-docs: ## every document links to every other, every relative link resolves
scripts/check-docs.sh
bench: build ## re-measure startup, binary sizes and cpu limit into docs/charts/bench.json
scripts/bench.sh
charts: ## rebuild docs/charts/*.svg and the generated numbers in COMPARISON.md from bench.json
python3 docs/charts/make_charts.py
docs: charts check-docs ## charts plus the docs check
release: ## KIND=patch|minor|major MSG="subject": bump, build, test, commit, tag
@test -n "$(KIND)" -a -n "$(MSG)" || { echo 'usage: make release KIND=patch MSG="subject"'; exit 2; }
scripts/release.sh $(KIND) "$(MSG)"
clean: ## remove build/
rm -rf $(BUILD)
+15 -6
View File
@@ -1,5 +1,7 @@
# doos
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime.
```
@@ -11,7 +13,7 @@ doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
doos apparmor # profile text for this binary (Ubuntu 24.04+)
```
Version: see `VERSION` (currently 1.1.1). Release history: `CHANGELOG.md`.
Version: see `VERSION` (currently 1.1.3). Release history: `CHANGELOG.md`.
User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime.
@@ -166,12 +168,19 @@ run with Docker's default capability set and a seccomp filter. Memory, pids, cpu
`--net host` removes network isolation and is opt-in (`--net pasta` is the safer host-like choice).
What is still not covered is listed at the end of `docs/RESEARCH.md`.
## Development
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`.
## Project docs
| File | Content |
|---|---|
| `SPEC.md` | Behavior contract and limits |
| `docs/ARCHITECTURE.md` | Code map and flows |
| `docs/RESEARCH.md` | What works unprivileged, measurements, gaps |
| `AGENTS.md` | Rules for AI agents, release workflow |
| `CHANGELOG.md` | Release history |
| [`SPEC.md`](SPEC.md) | Behavior contract and limits |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Code map and flows |
| [`docs/RESEARCH.md`](docs/RESEARCH.md) | What works unprivileged, measurements, remaining limits |
| [`COMPARISON.md`](COMPARISON.md) | doos next to Docker, Podman, bubblewrap, nspawn, LXC/Incus, Apptainer, Distrobox, gVisor/Kata, with pros, cons, measurements and a conclusion |
| [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow |
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
+3 -1
View File
@@ -1,6 +1,8 @@
# doos specification
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together.
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md).
## CLI
+1 -1
View File
@@ -1 +1 @@
1.1.1
1.1.3
+3 -1
View File
@@ -1,6 +1,8 @@
# Architecture
How doos works internally. Behavior contract is `SPEC.md`; this explains the code.
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md).
## File map
+8
View File
@@ -1,5 +1,7 @@
# Research: user-level containers with overlayfs
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
## What works unprivileged
@@ -258,6 +260,10 @@ delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 chec
| Size accounting | 80 MB written by container uid 5 in a mode-700 directory (invisible to the host user) against `--size 64m`: frozen; cleanup from a second attach thawed it |
| Attach time (5 runs) | host or image, slirp: about 40 ms each; pasta: about 65 ms; `--net none`: about 10 ms |
![Horizontal bar chart of CPU-seconds used by four busy loops in one container, without a limit and with --cpus 0.5. The limited container is highlighted. Values are in the sentence below.](charts/cpu.svg)
The chart above is generated from `docs/charts/bench.json` (`make bench`, then `make charts`); it shows <!--bench:cpu_free-->16.3<!--/bench--> CPU-seconds without a limit and <!--bench:cpu_half-->2.3<!--/bench--> with `--cpus 0.5`; the startup and size charts live in [COMPARISON](../COMPARISON.md).
### What changed in behavior (and why it is not a loss)
- Hardening can be switched off per container (`--hardening relaxed`), so nothing that needs the denied calls is lost, it is opt-in now.
@@ -278,3 +284,5 @@ delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 chec
- Without `gpg` installed, signature checks are skipped with a printed note.
- pasta is not signalled by doos (it drops uid); it exits by itself within about a second after the container's namespace is gone. Measured: no pasta process remained after 1.5 s.
- seccomp is a denylist. A new kernel syscall is allowed until it is added to the list.
For how doos stacks up against Docker, Podman and others, with these measurements next to theirs, see [COMPARISON](../COMPARISON.md). Behavior contract: [SPEC](../SPEC.md). Code map: [ARCHITECTURE](ARCHITECTURE.md). Usage: [README](../README.md).
+121
View File
@@ -0,0 +1,121 @@
{
"meta": {
"date": "2026-10-23",
"kernel": "7.0.0-38-generic",
"cpus": 12,
"runs": 10,
"doos": "1.1.2",
"podman": "podman version 5.7.0",
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
"bubblewrap": "bubblewrap 0.11.1",
"src_lines": 3445,
"shared_libs": 6
},
"startup": [
{
"id": "bwrap_none",
"label": "bubblewrap (host /usr)",
"group": "none",
"kind": "other",
"ms": 8.4
},
{
"id": "bwrap_net",
"label": "bubblewrap (host /usr)",
"group": "default",
"kind": "other",
"ms": 7.4
},
{
"id": "doos_host_none",
"label": "doos host mode",
"group": "none",
"kind": "doos",
"ms": 18.7
},
{
"id": "doos_alpine_none",
"label": "doos Alpine image",
"group": "none",
"kind": "doos",
"ms": 13.8
},
{
"id": "doos_host_slirp",
"label": "doos host mode",
"group": "default",
"kind": "doos",
"ms": 56.9
},
{
"id": "doos_alpine_slirp",
"label": "doos Alpine image",
"group": "default",
"kind": "doos",
"ms": 52.1
},
{
"id": "doos_alpine_pasta",
"label": "doos Alpine, pasta",
"group": "default",
"kind": "doos",
"ms": 64.6
},
{
"id": "podman_none",
"label": "Podman Alpine",
"group": "none",
"kind": "other",
"ms": 207.8
},
{
"id": "podman_net",
"label": "Podman Alpine",
"group": "default",
"kind": "other",
"ms": 241.2
},
{
"id": "docker_none",
"label": "Docker Alpine (rootful)",
"group": "none",
"kind": "other",
"ms": 343.9
},
{
"id": "docker_net",
"label": "Docker Alpine (rootful)",
"group": "default",
"kind": "other",
"ms": 435.6
}
],
"sizes": [
{
"id": "bwrap",
"bytes": 80424
},
{
"id": "crun",
"bytes": 483392
},
{
"id": "doos",
"bytes": 565680
},
{
"id": "docker",
"bytes": 31374336
},
{
"id": "podman",
"bytes": 43180896
}
],
"cpu": {
"wall_seconds": 4.2,
"busy_loops": 4,
"unlimited": 16.26,
"half_cpu": 2.29
}
}
+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 200" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
<title id="t">CPU time used by four busy loops</title>
<desc id="d">Two bars, CPU seconds consumed in 4.2 seconds of wall time. Without a limit 16.3, with --cpus 0.5 2.3.</desc>
<style>
.bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
.t2 { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
.lab { fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
.val { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
.tick { fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }
.grid { stroke: var(--grid); stroke-width: 1; }
.axis { stroke: var(--axis); stroke-width: 1; }
.acc { fill: var(--accent); }
.oth { fill: var(--other); }
:root { --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
--grid:#e1e0d9; --axis:#c3c2b7; --accent:#2a78d6; --other:#898781; }
@media (prefers-color-scheme: dark) {
:root { --surface:#1a1a19; --ink:#f0efec; --ink2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --accent:#3987e5; --other:#898781; }
}
</style>
<rect class="bg" x="0" y="0" width="760" height="200" rx="8"/>
<text class="t1" x="20" y="28">CPU time used by 4 busy loops in one container (CPU-seconds)</text>
<text class="t2" x="20" y="46">4.2 s of wall time. Half a CPU allows about 2.1 CPU-seconds; measured 2.3.</text>
<rect class="oth" x="20" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="38" y="65">no limit</text>
<rect class="acc" x="116" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="134" y="65">--cpus 0.5</text>
<line class="grid" x1="170.0" y1="84" x2="170.0" y2="146"/>
<text class="tick" x="170.0" y="162" text-anchor="middle">0</text>
<line class="grid" x1="270.0" y1="84" x2="270.0" y2="146"/>
<text class="tick" x="270.0" y="162" text-anchor="middle">4</text>
<line class="grid" x1="370.0" y1="84" x2="370.0" y2="146"/>
<text class="tick" x="370.0" y="162" text-anchor="middle">8</text>
<line class="grid" x1="470.0" y1="84" x2="470.0" y2="146"/>
<text class="tick" x="470.0" y="162" text-anchor="middle">12</text>
<line class="grid" x1="570.0" y1="84" x2="570.0" y2="146"/>
<text class="tick" x="570.0" y="162" text-anchor="middle">16</text>
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
<text class="tick" x="670.0" y="162" text-anchor="middle">20</text>
<text class="lab" x="160" y="105.0" text-anchor="end">no limit</text>
<path class="oth" d="M170.0,92.0 h402.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-402.5 z"><title>no limit: 16.3 CPU-seconds</title></path>
<text class="val" x="584.5" y="105.0">16.3 CPU-s</text>
<text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text>
<path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path>
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text>
<line class="axis" x1="170" y1="84" x2="170" y2="146"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Builds the SVG charts and the generated numbers in COMPARISON.md / RESEARCH.md from bench.json.
python3 docs/charts/make_charts.py (or: make charts)
Input : docs/charts/bench.json (written by scripts/bench.sh, i.e. make bench)
Output: docs/charts/startup.svg, size.svg, cpu.svg
the blocks between <!-- bench:NAME:begin --> and <!-- bench:NAME:end --> in the markdown documents
and inline values written as <!--bench:KEY-->text<!--/bench-->
Design rules (the dataviz method): one form per job, doos in the accent hue and everything else in the neutral
gray (emphasis), bars at most 24 px with a 4 px rounded data end, direct value labels at the bar tip, a legend,
hairline solid grid, text in ink tokens (never the series color), light and dark palettes selected separately.
Colors were checked with the dataviz validator: contrast, CVD separation and the normal-vision floor pass in
both modes; its "chroma floor" flag on the gray is expected, the gray is the de-emphasis neutral by design.
Standard library only.
"""
import json
import math
import re
import sys
from pathlib import Path
from xml.sax.saxutils import escape
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
DATA = HERE / "bench.json"
# ---- palette (dataviz reference instance) ---------------------------------------------------------------
LIGHT = dict(surface="#fcfcfb", ink="#0b0b0b", ink2="#52514e", muted="#898781", grid="#e1e0d9", axis="#c3c2b7",
accent="#2a78d6", other="#898781")
DARK = dict(surface="#1a1a19", ink="#f0efec", ink2="#c3c2b7", muted="#898781", grid="#2c2c2a", axis="#383835",
accent="#3987e5", other="#898781")
STYLE = f"""
.bg {{ fill: var(--surface); }}
.t1 {{ fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }}
.t2 {{ fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }}
.lab {{ fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }}
.val {{ fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }}
.tick {{ fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }}
.grid {{ stroke: var(--grid); stroke-width: 1; }}
.axis {{ stroke: var(--axis); stroke-width: 1; }}
.acc {{ fill: var(--accent); }}
.oth {{ fill: var(--other); }}
:root {{ --surface:{LIGHT['surface']}; --ink:{LIGHT['ink']}; --ink2:{LIGHT['ink2']}; --muted:{LIGHT['muted']};
--grid:{LIGHT['grid']}; --axis:{LIGHT['axis']}; --accent:{LIGHT['accent']}; --other:{LIGHT['other']}; }}
@media (prefers-color-scheme: dark) {{
:root {{ --surface:{DARK['surface']}; --ink:{DARK['ink']}; --ink2:{DARK['ink2']}; --muted:{DARK['muted']};
--grid:{DARK['grid']}; --axis:{DARK['axis']}; --accent:{DARK['accent']}; --other:{DARK['other']}; }}
}}
"""
BAR_H = 18 # <= 24 px
ROW = 30
RADIUS = 4 # rounded data end only
def bar_path(x, y, w, h, r=RADIUS):
"""Bar growing from x to the right: square at the baseline, rounded at the data end."""
w = max(w, 1.0)
r = min(r, w, h / 2)
return (f"M{x:.1f},{y:.1f} h{w - r:.1f} a{r},{r} 0 0 1 {r},{r} v{h - 2 * r:.1f} "
f"a{r},{r} 0 0 1 {-r},{r} h{-(w - r):.1f} z")
def svg_open(w, h, title, desc):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" role="img" '
f'aria-labelledby="t d" style="max-width:100%;height:auto">\n<title id="t">{escape(title)}</title>\n'
f'<desc id="d">{escape(desc)}</desc>\n<style>{STYLE}</style>\n'
f'<rect class="bg" x="0" y="0" width="{w}" height="{h}" rx="8"/>\n')
def legend(x, y, items):
out, cx = [], x
for cls, text in items:
out.append(f'<rect class="{cls}" x="{cx}" y="{y - 9}" width="12" height="12" rx="2"/>')
out.append(f'<text class="t2" x="{cx + 18}" y="{y + 1}">{escape(text)}</text>')
cx += 18 + 7 * len(text) + 22
return "\n".join(out)
def fmt_ms(v):
return f"{v:.0f} ms" if v >= 100 else f"{v:.1f} ms".replace(".0 ms", " ms")
def fmt_bytes(b):
if b < 1 << 20:
return f"{b / 1024:.0f} KiB"
return f"{b / (1 << 20):.1f} MiB"
# ---- chart 1: startup time -----------------------------------------------------------------------------
def startup_chart(d):
rows = [r for r in d["startup"] if r["ms"] is not None]
iv = inline_values(d)
groups = [("none", "No network"), ("default", "Default network")]
left, right, top = 200, 90, 78
width = 760
plot_w = width - left - right
vmax = max(r["ms"] for r in rows)
axis_max = math.ceil(vmax / 100.0) * 100
step = 100 if axis_max <= 600 else 200
scale = plot_w / axis_max
y = top
body = []
for key, name in groups:
grp = sorted([r for r in rows if r["group"] == key], key=lambda r: r["ms"])
if not grp:
continue
body.append(f'<text class="t1" x="{left - 190 + 12}" y="{y}">{name}</text>')
y += 14
y0 = y
for t in range(0, axis_max + 1, step):
x = left + t * scale
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(grp) * ROW}"/>')
for r in grp:
cls = "acc" if r["kind"] == "doos" else "oth"
w = r["ms"] * scale
by = y + (ROW - BAR_H) / 2
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{escape(r["label"])}</text>')
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{escape(r["label"])}: {fmt_ms(r["ms"])}</title></path>')
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{fmt_ms(r["ms"])}</text>')
y += ROW
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
for t in range(0, axis_max + 1, step):
body.append(f'<text class="tick" x="{left + t * scale:.1f}" y="{y + 14}" text-anchor="middle">{t}</text>')
y += 38
height = int(y - 10)
m = d["meta"]
out = svg_open(width, height, "Time to start a trivial command",
f"Horizontal bars, milliseconds, lower is better. doos: {iv['doos_range']}; Podman: {iv['podman_range']}; "
f"Docker: {iv['docker_range']}; bubblewrap: {iv['bwrap_range']}. Exact values are in the table in COMPARISON.md.")
out += '<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>\n'
out += (f'<text class="t2" x="20" y="46">{m["runs"]} runs each, average, warm cache, {m["date"]}, kernel {escape(m["kernel"])}, '
f'{m["cpus"]} cores. Docker is a rootful daemon.</text>\n')
out += legend(20, 64, [("acc", "doos"), ("oth", "other tools")]) + "\n"
out += "\n".join(body) + "\n</svg>\n"
return out
# ---- chart 2: binary size (log scale) --------------------------------------------------------------------
def size_chart(d):
names = {"bwrap": "bubblewrap", "crun": "crun (OCI runtime)", "doos": "doos", "docker": "docker CLI",
"podman": "podman"}
rows = sorted([r for r in d["sizes"] if r["bytes"] is not None], key=lambda r: r["bytes"])
left, right, top, width = 170, 100, 96, 760
plot_w = width - left - right
lo, hi = math.log10(50 * 1024), math.log10(100 * (1 << 20))
sx = lambda b: (math.log10(b) - lo) / (hi - lo) * plot_w
ticks = [(100 * 1024, "100 KiB"), (1 << 20, "1 MiB"), (10 << 20, "10 MiB"), (100 << 20, "100 MiB")]
y0 = top + 8
body = []
for b, lab in ticks:
x = left + sx(b)
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(rows) * ROW}"/>')
body.append(f'<text class="tick" x="{x:.1f}" y="{y0 + len(rows) * ROW + 16}" text-anchor="middle">{lab}</text>')
y = y0
for r in rows:
cls = "acc" if r["id"] == "doos" else "oth"
w = sx(r["bytes"])
by = y + (ROW - BAR_H) / 2
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{names.get(r["id"], r["id"])}</text>')
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{names.get(r["id"], r["id"])}: {fmt_bytes(r["bytes"])}</title></path>')
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{fmt_bytes(r["bytes"])}</text>')
y += ROW
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
height = int(y + 62)
out = svg_open(width, height, "Size of the main executable",
"Horizontal bars on a logarithmic axis: " +
", ".join(f"{names.get(r['id'], r['id'])} {fmt_bytes(r['bytes'])}" for r in rows) + ".")
out += '<text class="t1" x="20" y="28">Size of the main executable (log scale, smaller is smaller)</text>\n'
out += '<text class="t2" x="20" y="46">The docker CLI excludes dockerd and containerd.</text>\n'
out += '<text class="t2" x="20" y="62">doos excludes the helpers it calls (slirp4netns, pasta, newuidmap, curl, tar).</text>\n'
out += legend(20, 82, [("acc", "doos"), ("oth", "other tools")]) + "\n"
out += "\n".join(body) + f'\n<text class="t2" x="{left}" y="{y + 38}">Axis is logarithmic: each gridline is 10x the previous one.</text>\n</svg>\n'
return out
# ---- chart 3: cpu limit ----------------------------------------------------------------------------------
def cpu_chart(d):
c = d["cpu"]
if c["unlimited"] is None or c["half_cpu"] is None:
return None
rows = [("no limit", c["unlimited"], "oth"), ("--cpus 0.5", c["half_cpu"], "acc")]
left, right, top, width = 170, 90, 78, 760
plot_w = width - left - right
axis_max = math.ceil(c["unlimited"] / 4.0) * 4
scale = plot_w / axis_max
y0 = top + 8
body = []
for t in range(0, axis_max + 1, 4):
x = left + t * scale
body.append(f'<line class="grid" x1="{x:.1f}" y1="{y0 - 2}" x2="{x:.1f}" y2="{y0 + len(rows) * ROW}"/>')
body.append(f'<text class="tick" x="{x:.1f}" y="{y0 + len(rows) * ROW + 16}" text-anchor="middle">{t}</text>')
y = y0
for lab, v, cls in rows:
w = v * scale
by = y + (ROW - BAR_H) / 2
body.append(f'<text class="lab" x="{left - 10}" y="{by + BAR_H / 2 + 4:.1f}" text-anchor="end">{lab}</text>')
body.append(f'<path class="{cls}" d="{bar_path(left, by, w, BAR_H)}"><title>{lab}: {v:.1f} CPU-seconds</title></path>')
body.append(f'<text class="val" x="{left + w + 8:.1f}" y="{by + BAR_H / 2 + 4:.1f}">{v:.1f} CPU-s</text>')
y += ROW
body.append(f'<line class="axis" x1="{left}" y1="{y0 - 2}" x2="{left}" y2="{y}"/>')
ideal = 0.5 * c["wall_seconds"]
height = int(y + 54)
out = svg_open(width, height, "CPU time used by four busy loops",
f"Two bars, CPU seconds consumed in {c['wall_seconds']} seconds of wall time. Without a limit {c['unlimited']:.1f}, "
f"with --cpus 0.5 {c['half_cpu']:.1f}.")
out += '<text class="t1" x="20" y="28">CPU time used by 4 busy loops in one container (CPU-seconds)</text>\n'
out += (f'<text class="t2" x="20" y="46">{c["wall_seconds"]} s of wall time. Half a CPU allows about {ideal:.1f} CPU-seconds; '
f'measured {c["half_cpu"]:.1f}.</text>\n')
out += legend(20, 64, [("oth", "no limit"), ("acc", "--cpus 0.5")]) + "\n"
out += "\n".join(body) + "\n</svg>\n"
return out
# ---- generated markdown ---------------------------------------------------------------------------------
def startup_table(d):
rows = [r for r in d["startup"] if r["ms"] is not None]
lines = ["| Setup | Network | Time per run |", "|---|---|---|"]
order = {"none": 0, "default": 1}
for r in sorted(rows, key=lambda r: (order[r["group"]], r["ms"])):
net = "none" if r["group"] == "none" else "default"
lines.append(f"| {r['label']} | {net} | {fmt_ms(r['ms'])} |")
return "\n".join(lines)
def size_table(d):
names = {"bwrap": "bubblewrap", "crun": "crun", "doos": "doos", "docker": "docker CLI", "podman": "podman"}
lines = ["| Executable | Size |", "|---|---|"]
for r in sorted([r for r in d["sizes"] if r["bytes"] is not None], key=lambda r: r["bytes"]):
lines.append(f"| {names.get(r['id'], r['id'])} | {fmt_bytes(r['bytes'])} ({r['bytes']:,} bytes) |")
return "\n".join(lines)
def inline_values(d):
ms = lambda ids: [r["ms"] for r in d["startup"] if r["id"].startswith(ids) and r["ms"] is not None]
rng = lambda xs: f"{min(xs):.0f} to {max(xs):.0f} ms" if xs else "n/a"
size = {r["id"]: r["bytes"] for r in d["sizes"] if r["bytes"] is not None}
v = {
"doos_range": rng(ms("doos")),
"podman_range": rng(ms("podman")),
"docker_range": rng(ms("docker")),
"bwrap_range": rng(ms("bwrap")),
"doos_size": fmt_bytes(size["doos"]) if "doos" in size else "n/a",
"docker_size": fmt_bytes(size["docker"]) if "docker" in size else "n/a",
"podman_size": fmt_bytes(size["podman"]) if "podman" in size else "n/a",
"bench_date": d["meta"]["date"],
"src_lines": f'{d["meta"].get("src_lines", 0):,}',
"shared_libs": str(d["meta"].get("shared_libs", "n/a")),
"runs": str(d["meta"]["runs"]),
"versions": f'doos {d["meta"]["doos"]}, {d["meta"]["docker"].replace("Docker version ", "Docker ").split(",")[0]}, '
f'{d["meta"]["podman"].replace("podman version ", "Podman ")}, {d["meta"]["bubblewrap"].replace("bubblewrap ", "bubblewrap ")}',
"cpu_free": f'{d["cpu"]["unlimited"]:.1f}' if d["cpu"]["unlimited"] is not None else "n/a",
"cpu_half": f'{d["cpu"]["half_cpu"]:.1f}' if d["cpu"]["half_cpu"] is not None else "n/a",
}
return v
def patch_markdown(path, blocks, inline):
s = path.read_text()
orig = s
for name, text in blocks.items():
pat = re.compile(rf"(<!-- bench:{name}:begin -->\n).*?(\n<!-- bench:{name}:end -->)", re.S)
s = pat.sub(lambda m: m.group(1) + text + m.group(2), s)
s = re.sub(r"<!--bench:(\w+)-->.*?<!--/bench-->",
lambda m: f"<!--bench:{m.group(1)}-->{inline.get(m.group(1), 'n/a')}<!--/bench-->", s)
if s != orig:
path.write_text(s)
return True
return False
def main():
if not DATA.exists():
sys.exit("no bench.json: run `make bench` first (it needs the built binary)")
d = json.loads(DATA.read_text())
(HERE / "startup.svg").write_text(startup_chart(d))
(HERE / "size.svg").write_text(size_chart(d))
cpu = cpu_chart(d)
if cpu:
(HERE / "cpu.svg").write_text(cpu)
blocks = {"startup": startup_table(d), "sizes": size_table(d)}
inline = inline_values(d)
changed = [p.name for p in (ROOT / "COMPARISON.md", ROOT / "docs" / "RESEARCH.md") if patch_markdown(p, blocks, inline)]
print("charts: startup.svg size.svg" + (" cpu.svg" if cpu else "") + (f"; updated {', '.join(changed)}" if changed else "; docs already current"))
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
<title id="t">Size of the main executable</title>
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 552 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
<style>
.bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
.t2 { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
.lab { fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
.val { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
.tick { fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }
.grid { stroke: var(--grid); stroke-width: 1; }
.axis { stroke: var(--axis); stroke-width: 1; }
.acc { fill: var(--accent); }
.oth { fill: var(--other); }
:root { --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
--grid:#e1e0d9; --axis:#c3c2b7; --accent:#2a78d6; --other:#898781; }
@media (prefers-color-scheme: dark) {
:root { --surface:#1a1a19; --ink:#f0efec; --ink2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --accent:#3987e5; --other:#898781; }
}
</style>
<rect class="bg" x="0" y="0" width="760" height="316" rx="8"/>
<text class="t1" x="20" y="28">Size of the main executable (log scale, smaller is smaller)</text>
<text class="t2" x="20" y="46">The docker CLI excludes dockerd and containerd.</text>
<text class="t2" x="20" y="62">doos excludes the helpers it calls (slirp4netns, pasta, newuidmap, curl, tar).</text>
<rect class="acc" x="20" y="73" width="12" height="12" rx="2"/>
<text class="t2" x="38" y="83">doos</text>
<rect class="oth" x="88" y="73" width="12" height="12" rx="2"/>
<text class="t2" x="106" y="83">other tools</text>
<line class="grid" x1="214.5" y1="102" x2="214.5" y2="254"/>
<text class="tick" x="214.5" y="270" text-anchor="middle">100 KiB</text>
<line class="grid" x1="364.0" y1="102" x2="364.0" y2="254"/>
<text class="tick" x="364.0" y="270" text-anchor="middle">1 MiB</text>
<line class="grid" x1="512.0" y1="102" x2="512.0" y2="254"/>
<text class="tick" x="512.0" y="270" text-anchor="middle">10 MiB</text>
<line class="grid" x1="660.0" y1="102" x2="660.0" y2="254"/>
<text class="tick" x="660.0" y="270" text-anchor="middle">100 MiB</text>
<text class="lab" x="160" y="123.0" text-anchor="end">bubblewrap</text>
<path class="oth" d="M170.0,110.0 h25.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-25.0 z"><title>bubblewrap: 79 KiB</title></path>
<text class="val" x="207.0" y="123.0">79 KiB</text>
<text class="lab" x="160" y="153.0" text-anchor="end">crun (OCI runtime)</text>
<path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path>
<text class="val" x="322.3" y="153.0">472 KiB</text>
<text class="lab" x="160" y="183.0" text-anchor="end">doos</text>
<path class="acc" d="M170.0,170.0 h150.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-150.4 z"><title>doos: 552 KiB</title></path>
<text class="val" x="332.4" y="183.0">552 KiB</text>
<text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text>
<path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path>
<text class="val" x="590.5" y="213.0">29.9 MiB</text>
<text class="lab" x="160" y="243.0" text-anchor="end">podman</text>
<path class="oth" d="M170.0,230.0 h429.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-429.0 z"><title>podman: 41.2 MiB</title></path>
<text class="val" x="611.0" y="243.0">41.2 MiB</text>
<line class="axis" x1="170" y1="102" x2="170" y2="254"/>
<text class="t2" x="170" y="292">Axis is logarithmic: each gridline is 10x the previous one.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+90
View File
@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
<title id="t">Time to start a trivial command</title>
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 14 to 65 ms; Podman: 208 to 241 ms; Docker: 344 to 436 ms; bubblewrap: 7 to 8 ms. Exact values are in the table in COMPARISON.md.</desc>
<style>
.bg { fill: var(--surface); }
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
.t2 { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
.lab { fill: var(--ink); font: 400 13px system-ui, -apple-system, "Segoe UI", sans-serif; }
.val { fill: var(--ink2); font: 400 12px system-ui, -apple-system, "Segoe UI", sans-serif; }
.tick { fill: var(--muted); font: 400 11px system-ui, -apple-system, "Segoe UI", sans-serif; }
.grid { stroke: var(--grid); stroke-width: 1; }
.axis { stroke: var(--axis); stroke-width: 1; }
.acc { fill: var(--accent); }
.oth { fill: var(--other); }
:root { --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781;
--grid:#e1e0d9; --axis:#c3c2b7; --accent:#2a78d6; --other:#898781; }
@media (prefers-color-scheme: dark) {
:root { --surface:#1a1a19; --ink:#f0efec; --ink2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --accent:#3987e5; --other:#898781; }
}
</style>
<rect class="bg" x="0" y="0" width="760" height="502" rx="8"/>
<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-23, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
<rect class="acc" x="20" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="38" y="65">doos</text>
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
<text class="t2" x="106" y="65">other tools</text>
<text class="t1" x="22" y="78">No network</text>
<line class="grid" x1="200.0" y1="90" x2="200.0" y2="242"/>
<line class="grid" x1="294.0" y1="90" x2="294.0" y2="242"/>
<line class="grid" x1="388.0" y1="90" x2="388.0" y2="242"/>
<line class="grid" x1="482.0" y1="90" x2="482.0" y2="242"/>
<line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/>
<line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/>
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
<path class="oth" d="M200.0,98.0 h3.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.9 z"><title>bubblewrap (host /usr): 8.4 ms</title></path>
<text class="val" x="215.9" y="111.0">8.4 ms</text>
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
<path class="acc" d="M200.0,128.0 h9.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.0 z"><title>doos Alpine image: 13.8 ms</title></path>
<text class="val" x="221.0" y="141.0">13.8 ms</text>
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
<path class="acc" d="M200.0,158.0 h13.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-13.6 z"><title>doos host mode: 18.7 ms</title></path>
<text class="val" x="225.6" y="171.0">18.7 ms</text>
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
<path class="oth" d="M200.0,188.0 h191.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-191.3 z"><title>Podman Alpine: 208 ms</title></path>
<text class="val" x="403.3" y="201.0">208 ms</text>
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
<path class="oth" d="M200.0,218.0 h319.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-319.3 z"><title>Docker Alpine (rootful): 344 ms</title></path>
<text class="val" x="531.3" y="231.0">344 ms</text>
<line class="axis" x1="200" y1="90" x2="200" y2="242"/>
<text class="tick" x="200.0" y="256" text-anchor="middle">0</text>
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
<text class="tick" x="388.0" y="256" text-anchor="middle">200</text>
<text class="tick" x="482.0" y="256" text-anchor="middle">300</text>
<text class="tick" x="576.0" y="256" text-anchor="middle">400</text>
<text class="tick" x="670.0" y="256" text-anchor="middle">500</text>
<text class="t1" x="22" y="280">Default network</text>
<line class="grid" x1="200.0" y1="292" x2="200.0" y2="474"/>
<line class="grid" x1="294.0" y1="292" x2="294.0" y2="474"/>
<line class="grid" x1="388.0" y1="292" x2="388.0" y2="474"/>
<line class="grid" x1="482.0" y1="292" x2="482.0" y2="474"/>
<line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/>
<line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/>
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
<path class="oth" d="M200.0,300.0 h3.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.0 z"><title>bubblewrap (host /usr): 7.4 ms</title></path>
<text class="val" x="215.0" y="313.0">7.4 ms</text>
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
<path class="acc" d="M200.0,330.0 h45.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-45.0 z"><title>doos Alpine image: 52.1 ms</title></path>
<text class="val" x="257.0" y="343.0">52.1 ms</text>
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
<path class="acc" d="M200.0,360.0 h49.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-49.5 z"><title>doos host mode: 56.9 ms</title></path>
<text class="val" x="261.5" y="373.0">56.9 ms</text>
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
<path class="acc" d="M200.0,390.0 h56.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-56.7 z"><title>doos Alpine, pasta: 64.6 ms</title></path>
<text class="val" x="268.7" y="403.0">64.6 ms</text>
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
<path class="oth" d="M200.0,420.0 h222.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-222.7 z"><title>Podman Alpine: 241 ms</title></path>
<text class="val" x="434.7" y="433.0">241 ms</text>
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
<path class="oth" d="M200.0,450.0 h405.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-405.5 z"><title>Docker Alpine (rootful): 436 ms</title></path>
<text class="val" x="617.5" y="463.0">436 ms</text>
<line class="axis" x1="200" y1="292" x2="200" y2="474"/>
<text class="tick" x="200.0" y="488" text-anchor="middle">0</text>
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text>
<text class="tick" x="388.0" y="488" text-anchor="middle">200</text>
<text class="tick" x="482.0" y="488" text-anchor="middle">300</text>
<text class="tick" x="576.0" y="488" text-anchor="middle">400</text>
<text class="tick" x="670.0" y="488" text-anchor="middle">500</text>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Reproducible measurements behind COMPARISON.md and the charts. Writes JSON (default docs/charts/bench.json).
# Tools that are not installed or not usable are skipped and recorded as null, never guessed.
# Usage: scripts/bench.sh [output.json] Environment: BENCH_RUNS (default 10)
set -u
cd "$(dirname "$0")/.."
OUT=${1:-docs/charts/bench.json}
RUNS=${BENCH_RUNS:-10}
DOOS=$(readlink -f build/doos)
[ -x "$DOOS" ] || { echo "bench: build first (make build)" >&2; exit 1; }
export DOOS_HOME=$(mktemp -d)
trap '"$DOOS" delete --yes ha >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT
have() { command -v "$1" >/dev/null 2>&1; }
now() { date +%s%N; }
# avg_ms LABEL CMD... -> prints average ms per run, or "null" when the command fails
avg_ms() {
timeout 180 "$@" >/dev/null 2>&1 || { echo null; return; } # warm-up, may pull an image
local s e i; s=$(now)
for i in $(seq "$RUNS"); do timeout 60 "$@" >/dev/null 2>&1 || { echo null; return; }; done
e=$(now); awk -v s="$s" -v e="$e" -v n="$RUNS" 'BEGIN{printf "%.1f", (e-s)/1e6/n}'
}
note() { echo "bench: $*" >&2; }
# ---- setup doos containers (host mode needs nothing, image mode downloads Alpine once)
"$DOOS" image add alpine >/dev/null 2>&1 || note "alpine image unavailable, image-mode rows will be null"
"$DOOS" get_or_create --net none hn >/dev/null 2>&1
"$DOOS" get_or_create hs >/dev/null 2>&1
"$DOOS" get_or_create --image alpine --net none an >/dev/null 2>&1
"$DOOS" get_or_create --image alpine as >/dev/null 2>&1
"$DOOS" get_or_create --image alpine --net pasta ap >/dev/null 2>&1
BW_BASE="--unshare-user --unshare-pid --unshare-ipc --unshare-uts --die-with-parent --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --proc /proc --dev /dev"
rows=()
add() { rows+=("{\"id\":\"$1\",\"label\":\"$2\",\"group\":\"$3\",\"kind\":\"$4\",\"ms\":$5}"); }
echo "bench: startup, $RUNS runs each" >&2
if have bwrap; then
add bwrap_none "bubblewrap (host /usr)" none other "$(avg_ms bwrap --unshare-all $BW_BASE true)"
add bwrap_net "bubblewrap (host /usr)" default other "$(avg_ms bwrap $BW_BASE true)"
fi
add doos_host_none "doos host mode" none doos "$(avg_ms "$DOOS" attach hn -- true)"
add doos_alpine_none "doos Alpine image" none doos "$(avg_ms "$DOOS" attach an -- true)"
add doos_host_slirp "doos host mode" default doos "$(avg_ms "$DOOS" attach hs -- true)"
add doos_alpine_slirp "doos Alpine image" default doos "$(avg_ms "$DOOS" attach as -- true)"
add doos_alpine_pasta "doos Alpine, pasta" default doos "$(avg_ms "$DOOS" attach ap -- true)"
if have podman; then
add podman_none "Podman Alpine" none other "$(avg_ms podman run --rm --network none alpine true)"
add podman_net "Podman Alpine" default other "$(avg_ms podman run --rm alpine true)"
fi
if have docker; then
add docker_none "Docker Alpine (rootful)" none other "$(avg_ms docker run --rm --network none alpine true)"
add docker_net "Docker Alpine (rootful)" default other "$(avg_ms docker run --rm alpine true)"
fi
# ---- binary sizes
size_of() { local p; p=$(command -v "$1" 2>/dev/null) && stat -L -c %s "$p" || echo null; }
sizes=()
for t in bwrap crun doos docker podman; do
if [ "$t" = doos ]; then b=$(stat -c %s "$DOOS"); else b=$(size_of "$t"); fi
sizes+=("{\"id\":\"$t\",\"bytes\":$b}")
done
# ---- cpu limit: 4 busy loops for 4 seconds, CPU seconds consumed (user+sys)
cpu_run() {
/usr/bin/time -f "%U %S" "$DOOS" attach "$1" -- sh -c 'for i in 1 2 3 4; do (timeout 4 sh -c "while :; do :; done" &); done; sleep 4.2' 2>&1 >/dev/null | tail -1 | awk '{printf "%.2f", $1+$2}'
}
echo "bench: cpu limit" >&2
"$DOOS" get_or_create hc >/dev/null 2>&1; "$DOOS" get_or_create --cpus 0.5 hc5 >/dev/null 2>&1
cpu_free=$(cpu_run hc); cpu_half=$(cpu_run hc5)
"$DOOS" delete --yes hc >/dev/null 2>&1; "$DOOS" delete --yes hc5 >/dev/null 2>&1
ver() { "$@" 2>/dev/null | head -1 | tr -d '"\\' ; }
json_join() { local IFS=,; echo "$*"; }
cat > "$OUT" <<JSON
{
"meta": {
"date": "$(date +%F)",
"kernel": "$(uname -r)",
"cpus": $(nproc),
"runs": $RUNS,
"doos": "$(cat VERSION)",
"podman": "$(have podman && ver podman --version || echo none)",
"docker": "$(have docker && ver docker --version || echo none)",
"bubblewrap": "$(have bwrap && ver bwrap --version || echo none)",
"src_lines": $(cat src/*.cpp src/*.hpp | wc -l),
"shared_libs": $(ldd "$DOOS" | wc -l)
},
"startup": [$(json_join "${rows[@]}")],
"sizes": [$(json_join "${sizes[@]}")],
"cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": ${cpu_free:-null}, "half_cpu": ${cpu_half:-null}}
}
JSON
echo "bench: wrote $OUT" >&2
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Checks the documentation set: every markdown file links to every other one, and every relative link resolves.
# Usage: scripts/check-docs.sh (exit 1 on any problem, lists them all)
set -u
cd "$(dirname "$0")/.."
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md)
bad=0
fail() { echo "docs: $*"; bad=$((bad+1)); }
# no markdown file may exist outside the checked set (so a new doc cannot dodge the rule)
while IFS= read -r f; do
f=${f#./}
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md') # prompt.md: the original task statement, not documentation
for f in "${DOCS[@]}"; do
[ -f "$f" ] || { fail "$f is missing"; continue; }
dir=$(dirname "$f")
# every other document must be linked
for g in "${DOCS[@]}"; do
[ "$f" = "$g" ] && continue
rel=$(realpath --relative-to="$dir" "$g")
grep -qE "\]\(${rel//./\\.}(#[^)]*)?\)" "$f" || fail "$f does not link to $g (expected ]($rel))"
done
# every relative link must point at something that exists
while IFS= read -r target; do
case "$target" in http://*|https://*|mailto:*|\#*) continue ;; esac
path=${target%%#*}
[ -z "$path" ] && continue
[ -e "$dir/$path" ] || fail "$f links to missing $target"
done < <(grep -oE '\]\([^)]+\)' "$f" | sed -E 's/^\]\(//; s/\)$//')
done
[ "$bad" -eq 0 ] && echo "docs: ${#DOCS[@]} documents, all cross-linked, all links resolve" || { echo "docs: $bad problem(s)"; exit 1; }
+2
View File
@@ -22,6 +22,8 @@ if grep -rl $'\xe2\x80\x94' --exclude-dir=build --exclude-dir=.git . | grep -q .
echo "em dash found, remove it first" >&2; exit 1
fi
python3 docs/charts/make_charts.py
scripts/check-docs.sh
echo "$new" > VERSION
cmake -S . -B build >/dev/null
cmake --build build
+10 -2
View File
@@ -12,6 +12,7 @@ out() { "$DOOS" attach t -- sh -c "$1" 2>&1; }
trap '"$DOOS" delete --yes t >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT
check "list empty" "$DOOS" list
check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
check "get_or_create creates" "$DOOS" get_or_create t
[ "$("$DOOS" get_or_create t)" = "exists t" ] && ok "get_or_create idempotent" || bad "get_or_create idempotent"
check "invalid name rejected" bash -c "! '$DOOS' get_or_create 'Bad Name'"
@@ -186,8 +187,15 @@ echo "--- network examples"
curl -sS -m 2 -o /dev/null http://127.0.0.1:8077/ >/dev/null 2>&1 && bad "slirp: no inbound path" || ok "slirp: no inbound path"
wait %% 2>/dev/null
"$DOOS" get_or_create --net pasta pa1 >/dev/null 2>&1; "$DOOS" get_or_create --net pasta pa2 >/dev/null 2>&1
"$DOOS" attach pa1 -- timeout 12 python3 -m http.server 8066 --bind 0.0.0.0 >/dev/null 2>&1 & sleep 5
[ "$("$DOOS" attach pa2 -- curl -sS -m 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:8066/ 2>/dev/null)" = "200" ] && ok "pasta: container to container via loopback" || bad "pasta: container to container via loopback"
"$DOOS" attach pa1 -- timeout 25 python3 -m http.server 8066 --bind 0.0.0.0 >/dev/null 2>&1 &
# pasta notices a newly bound port after a few seconds: poll instead of sleeping a fixed time
c2c=000
for _ in $(seq 1 18); do
sleep 1
c2c=$("$DOOS" attach pa2 -- curl -sS -m 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:8066/ 2>/dev/null)
[ "$c2c" = "200" ] && break
done
[ "$c2c" = "200" ] && ok "pasta: container to container via loopback" || bad "pasta: container to container via loopback"
wait 2>/dev/null
"$DOOS" delete --yes pa1 >/dev/null; "$DOOS" delete --yes pa2 >/dev/null; "$DOOS" delete --yes nets >/dev/null; "$DOOS" delete --yes iso >/dev/null