Skip to content

Architecture

Karthik Vinayan edited this page Jun 24, 2026 · 1 revision

Architecture

dcon is a thin translation layer with no daemon of its own. It is one ~5.3 MB static Go binary that speaks Docker on the front and shells out to Apple's container CLI on the back. Every command you run (run, ps, build, compose up) ends up as one or more container invocations; dcon's job is to translate the arguments going in and re-render the output coming out in Docker's format.

This page covers the pipeline, where the time actually goes, how the warm pool and compose parallelism fit, what dcon doctor checks, the daemonless state model, and the source layout. For the warm pool's full behaviour see Warm Pool; for the measured numbers and methodology see Benchmarks & Comparison; for the full command list see Command Parity.

The translation pipeline

flowchart LR
    A["you / CI / Makefile<br/>docker run · compose up · build"] -->|docker-style args| B["dcon<br/>(Go / cobra, ~5.3 MB static)"]
    B -->|container args| C["Apple container CLI"]
    C --> D["container-apiserver<br/>(launchd, on-demand) + plugins"]
    D -->|Virtualization.framework| E["per-container<br/>Linux microVM"]
Loading

Reading it left to right:

  1. You / CI type a Docker command (or a script / Makefile does).
  2. dcon (cobra-based) parses it, translates Docker flags to their container equivalents, and shells out. There is no dcon background process: the binary runs, talks to container, and exits.
  3. Apple container CLI is the front end to the real engine.
  4. container-apiserver runs under launchd, on demand. It (plus its plugins) drives the boot.
  5. Virtualization.framework is what creates the VM.
  6. Each container gets its own Linux microVM, not a shared VM with namespaces. This is the source of both dcon's stronger isolation and its higher cold-start cost.

How dcon drives container

All backend calls funnel through internal/runtime (runtime.go). It locates the binary (DCON_CONTAINER_BIN env override, then PATH, then the well-known /usr/local/bin/container) and exposes a few execution helpers:

Helper Use
Run interactive / streaming commands (run, exec, logs, build) — stdio inherited
RunWith same, but with redirected streams (cp to stdout, export -o -)
Capture capture stdout, still stream stderr so progress stays visible
CaptureSilent capture both streams, fold stderr into the error — used when dcon must inspect output without leaking container's native formatting
CaptureJSON CaptureSilent + json.Unmarshal into a model

Set DCON_DEBUG=1 (or =true) to echo every underlying container <args> line to stderr, so you can see exactly what dcon translated your command into.

Where the ~700 ms goes

The finding from profiling on the real machine (Apple silicon Mac16,12, macOS 26, Apple container 1.0.0): dcon adds essentially zero overhead. The cost is Apple's per-container microVM cold boot.

dcon's Go is free

container run --rm alpine echo …   ~700 ms
dcon    run --rm alpine echo …     ~690 ms

Within noise, identical. The Go translation layer does not show up in the timing; there is no point optimizing dcon's code path for cold start.

The cost is in start, not create

The lifecycle splits cleanly:

Step Cost What happens
container create ~40 ms allocate the container record
container start ~650 ms the boot: kernel + init + vminitd + network

Pre-creating containers does not help: the ~650 ms lives entirely in start. There is no cheap window to exploit before the boot.

Apple serializes boots; you cannot parallelize them

The apiserver serializes VM boots. Four concurrent container run -d come out only ~11 % faster than running them sequentially. Throwing concurrency at the boot path does almost nothing.

This shapes everything downstream:

The only lever on start latency is to pre-pay the boot off the critical path. You cannot make a boot faster, and you cannot run many boots in parallel. You can only do them ahead of time. That is the warm pool.

The warm floor

exec into an already-running VM costs ~60–90 ms. That is the warm-pool floor, bounded by the container CLI's own Swift startup (~70 ms), not by dcon. So ~90 ms is roughly as fast as this backend gets per workload.

How the warm pool fits

The warm pool follows from the profiling above: since the ~650 ms boot can't be made faster or parallelized, dcon pays it in the background, ahead of time.

A pool member is a pre-booted, single-use microVM. A simple eligible run execs the workload into a ready VM (~90 ms) instead of cold-booting (~700 ms). Each member is handed out exactly once and then destroyed, so every run still gets a pristine, never-used microVM: the per-container isolation is identical to a cold run. The boot cost simply moved off your critical path.

That ~90 ms beats a shared-VM engine's start while keeping per-container isolation, which an always-warm shared VM can't claim.

Eligibility, commands (dcon warm / warm ls / warm prune), env knobs (DCON_WARM, DCON_WARM_DEPTH, DCON_WARM_TTL), the cold==warm correctness handling (ENTRYPOINT/CMD resolution), and the daemonless replenish model are all detailed on the Warm Pool page. The short version: only flags that exec can faithfully reproduce keep a run on the fast path; anything bound at VM-boot time (bind mounts, ports, resource limits, custom networking, --name, --entrypoint, …) transparently takes the normal cold path.

Compose parallelism

compose up is dcon's other place to overlap work, implemented in cmd/compose.go on top of internal/compose's Project.Levels().

The model is dependency-level concurrency:

  • Project.Levels() topologically groups services into levels. depends_on ordering is preserved across levels.
  • Within a level (services with no ordering constraint between them) dcon brings them up concurrently, each in its own goroutine.
  • A semaphore caps the fan-out at DCON_COMPOSE_PARALLEL (default 8; <= 0 means unlimited) so a wide stack can't try to boot dozens of microVMs at once. The cap and default live in parallelLimit().
  • A level fully completes (wg.Wait()) before the next level starts; the first error in a level aborts the bring-up.

Scope

Because Apple serializes VM boots (see above), a pure-boot stack gains only modestly from this, roughly the same ~11–16 % ceiling that concurrent run -d hits. The compose parallelism does not dodge boot serialization.

It pays off on build- and pull-heavy stacks (compose up --build): the network- and builder-bound steps for independent services overlap, and those are not serialized the way boots are. Don't expect large speedups on a stack that is just booting prebuilt images.

dcon doctor

dcon doctor (also dcon system doctor) is a one-shot setup diagnostic, implemented in cmd/doctor.go. It probes the environment and prints an aligned report with / ! / lines, each non-OK line carrying a remediation hint. It exits non-zero on a hard failure, so it's CI-friendly.

What it checks, in order:

# Check OK means If not
1 Apple container CLI present, with version fail: install from apple/container releases or brew install --cask container; remaining probes are skipped
2 Backend services container system status reports running fail: dcon system start
3 Guest kernel a kernel exists under the app-support kernels/ dir warn: dcon system kernel set --recommended (read-only commands work without it; booting containers needs it)
4 Image builder container builder status shows running warn: starts on first dcon build, or dcon builder start
5 docker drop-in docker on PATH resolves to this dcon binary warn: alias docker=dcon or make link-docker
6 Warm pool informational: ready-member count + mode (off / auto / manual) always OK

Check 1 is the gate: if the backend binary isn't found, dcon stops there because every other probe would be meaningless. The formatting half (renderChecks) is unit-tested separately from the integration half (gatherChecks).

Daemonless design and state

dcon runs no persistent process of its own. Each invocation is a short-lived CLI that talks to the Apple backend and exits. The only always-available background piece is Apple's apiserver (launchd, on-demand), not dcon.

The warm pool is the one feature that needs cross-invocation state, and it stays daemonless:

  • State file: ~/Library/Application Support/dcon/pool.json, a flock-guarded JSON file listing only the available pool members. Claiming a member pops one atomically, which is safe across concurrent processes.
  • Background work (boot / destroy / replenish) runs as detached (setsid) processes that outlive the CLI that spawned them. No long-running supervisor.
  • Leak safety: members are label-tagged dcon.pool=1, so any VM orphaned by a crash is reaped by dcon warm prune. If a claimed VM turns out to be already gone, the run transparently falls back to a cold boot.

Note the two distinct app-support roots: dcon's own state lives under …/Application Support/dcon/, while Apple's backend state (kernels, etc., which doctor reads) lives under …/Application Support/com.apple.container/ via runtime.AppRoot().

Source layout

Path Responsibility
main.go entrypoint; wires up the cobra command tree
cmd/ one file per command group (run, compose.go, images.go, warm.go, doctor.go, …)
internal/runtime/ locate and drive the container binary (runtime.go)
internal/dockerfmt/ JSON models + Docker-style table/template rendering (ps/stats tables, --format)
internal/compose/ compose parser + service→container translation; Project.Levels() for dependency ordering
internal/pool/ the warm pool: state file, claim/replenish, eligibility
scripts/ install.sh, bump-version.sh, coverage, bench.sh
.github/workflows/ CI (test + coverage) and the tagged Release pipeline

runtime is the only package that knows how to invoke container; dockerfmt is the only package that knows Docker's output shapes; compose and pool are self-contained features built on those two primitives.

Development & releases

make build        # build ./dcon (version injected via ldflags)
make test         # go test ./...
make cover        # coverage summary
make bench        # dcon vs docker benchmark → scripts/bench-results.md
make vet fmt      # static checks + formatting

CI runs vet + race tests + coverage + a cross-build on every push/PR, and auto-refreshes the coverage badge on main. Coverage is currently ~45.8 %.

Cutting a release is one command:

scripts/bump-version.sh patch     # or: minor | major | vX.Y.Z

It tags and pushes. The Release workflow then builds darwin/arm64 + darwin/amd64 binaries, generates checksums and notes, and publishes a GitHub Release, which install.sh consumes for the curl … | bash one-liner.


See also: Warm Pool · Benchmarks & Comparison · Command Parity · Home · the README and the cookbook.