Skip to content

standalone: run containers without a daemon (DOCKER_STANDALONE=1) - #7324

Open
ericcurtin wants to merge 4 commits into
docker:masterfrom
ericcurtin:standalone-containerd-library
Open

ericcurtin wants to merge 4 commits into
docker:masterfrom
ericcurtin:standalone-containerd-library

Conversation

@ericcurtin

Copy link
Copy Markdown
Contributor

What

Adds a daemonless mode to the CLI: with DOCKER_STANDALONE=1, docker runs containers itself, using the containerd libraries and an OCI runtime directly. No dockerd and no containerd daemon are involved. Both rootful and rootless are supported.

$ export DOCKER_STANDALONE=1
$ docker run --rm alpine echo hello
hello
$ docker run -d --name web -p 8080:80 nginx
$ docker ps && docker logs web && docker exec web nginx -v && docker stop web

With the variable unset nothing changes: the CLI talks to the Engine exactly as before.

Why

The CLI already contains everything needed to describe containers; what it lacks is something to run them. containerd's libraries (content store, metadata store, snapshotters, image puller, OCI spec generation and the runtime v2 shim) are usable in-process, so a single static binary can be a complete container engine. That makes docker useful where a daemon is unwanted or unavailable: CI runners, containers, minimal images, and rootless setups where no system service can be installed.

How

The backend is an implementation of client.APIClient injected with command.WithInitializeClient, so every existing command, flag and output format keeps working unchanged for the supported subset. No command code was modified.

Without a daemon, the two jobs a daemon does are handled per container:

  • State lives in a containerd metadata database (bbolt) next to the content store, snapshots and volumes. A session is opened for the duration of an operation so consecutive commands can each take the lock. Sessions are reference-counted: containerd's shim manager calls back into the container store while an operation already holds one, and an flock is not re-entrant (this was a real deadlock, found by dumping goroutines).
  • Supervision uses containerd's own per-container shim, containerd-shim-runc-v2, started through core/runtime/v2. It daemonizes itself, so containers survive the CLI exiting and later commands reconnect to it. The manager that started a container is remembered for waiting on it, because rediscovering shims goes through containerd's loader, which reaps shims of already-exited containers and discards their exit status.
  • Logs, attach and exit status are handled by one helper process per container (this binary re-executed in a logging mode). It writes a json-file log, serves attach clients, forwards their input to the container's stdin, and records the exit status from the shim's TaskExit event. Container start waits until an attaching client is registered so docker run never drops output, and stdin EOF is propagated with the task's CloseIO because the shim holds its own writer on the stdin FIFO.

Nothing runs when no container is running; a running container has two helper processes (shim + logger), plus a network helper when a netns is used.

Rootless

The CLI re-executes itself in a new user namespace, mapping the caller's /etc/subuid//etc/subgid ranges with newuidmap/newgidmap, plus a new mount namespace so image layers can be mounted. /run is replaced by a tmpfs with the host's entries bound into it and the engine's shim socket directory bound at /run/containerd/s, which shims older than containerd 2.4 hardcode. Containers are networked with pasta (slirp4netns as a fallback) and get a cgroup when the user's systemd instance can create transient units, so --memory, --cpus, --pids-limit and docker pause work in a systemd user session.

Rootful uses the CNI bridge when the plugins are installed, else pasta, and overlayfs (native as a fallback).

Testing

A 49-case matrix covering output and exit codes, stdin/-i/-t, env/workdir/user/hostname/entrypoint, read-only rootfs, cgroup limits, capabilities, DNS and egress, all network modes, bind mounts, named/anonymous volumes, tmpfs, the detached lifecycle (ps/logs/exec/top/inspect/pause/rename/stop/restart/kill/rm), images (ls/tag/inspect/history/rmi), volumes, networks and info passes 49/49 rootful and 49/49 rootless from fresh state, with no leaked helper processes.

Unit tests cover the pure helpers (reference handling, registry auth decoding, spec/signal/name helpers, mount and volume resolution, port mapping, pasta arguments, resolv.conf/hosts generation, metadata round-trip, status formatting).

golangci-lint run is clean, the full go test ./... passes, and go mod tidy && go mod vendor is idempotent.

Notes for reviewers

  • The vendor growth is the bulk of the diff (containerd v2 and dependencies, ~3 MB of vendor/); the new code is ~8.6k lines under internal/standalone/, of which ~440 are generated "not supported" stubs for the client.APIClient methods that do not apply, and ~900 are tests.
  • containerd 2.4 requires go 1.26.6, so the go directive moved from 1.26.0; the Dockerfiles already use 1.26.8.
  • Two upstream behaviours worth knowing, both handled here: the shim ignores task stdin when stdout uses a binary:// log URI (so FIFO I/O is used), and it keeps its own writer on the stdin FIFO (so EOF needs CloseIO).
  • Only one existing file's behaviour changed: docker image inspect --format now falls back to marshalling the typed response when a client provides no raw response (separate commit; it also fixes templates against fake clients in tests).
  • docker build is out of scope (it needs BuildKit). Swarm, plugins, checkpoints, events and user-defined networks report that they are not supported. cp/commit/export/save/load and restart policies are not implemented yet; all limitations are listed in docs/standalone.md.

Adds github.com/containerd/containerd/v2 and its dependencies, which the
standalone (daemonless) backend added in a following commit uses to store
images, unpack them into snapshots and supervise containers:

    github.com/containerd/containerd/api v1.12.0
    github.com/containerd/containerd/v2 v2.4.0
    github.com/containerd/fifo v1.1.0
    github.com/containerd/go-cni v1.1.14
    github.com/containerd/ttrpc v1.2.9
    github.com/containerd/typeurl/v2 v2.3.0
    github.com/opencontainers/runtime-spec v1.3.0
    go.etcd.io/bbolt v1.5.0

containerd 2.4 requires go 1.26.6, so the 'go' directive is updated
accordingly; the Dockerfiles already build with go 1.26.8.

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
"docker image inspect --format" uses the raw API response so that templates
can refer to the JSON field names, and falls back to the typed value when no
raw response is available. The raw response is only produced by the HTTP
client, so with any other client.APIClient implementation (the standalone
backend added in a following commit, or a fake in tests) templates such as
"{{.Id}}" failed with a template error instead of using the field.

Marshal the typed response in that case, which is what the template expects.

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
Setting DOCKER_STANDALONE=1 makes the CLI run containers itself, with no
Docker Engine and no containerd daemon. The CLI embeds the containerd
libraries and drives an OCI runtime (runc or crun) directly, both rootful
and rootless.

The backend is an implementation of client.APIClient, injected with
command.WithInitializeClient, so every existing command, flag and output
format keeps working unchanged for the supported subset.

Without a daemon, the two jobs a daemon does are handled per container:

  - State lives in a containerd metadata database (bbolt) next to the
    content store, snapshots and volumes. A session is opened for the
    duration of an operation, so consecutive commands can each take the
    database lock. Sessions are reference-counted because containerd's shim
    manager calls back into the container store while an operation holds
    one, and the lock is not re-entrant.

  - Supervision uses containerd's own per-container shim,
    containerd-shim-runc-v2, started through core/runtime/v2. It daemonizes
    itself, so containers survive the CLI exiting and later commands
    reconnect to it. The manager that started a container is kept for
    waiting on it: rediscovering shims goes through containerd's loader,
    which reaps the shims of containers that already exited and discards
    their exit status.

  - Logs, attach and exit status are handled by a helper process per
    container (this binary re-executed in a logging mode). It writes a
    json-file log, serves attach clients, forwards their input to the
    container's stdin and records the exit status from the shim's TaskExit
    event. Container start waits for an attaching client to be registered,
    so "docker run" never misses output, and stdin EOF is propagated with
    the task's CloseIO because the shim holds its own writer on the FIFO.

Rootless mode re-executes the CLI in a new user namespace, mapping the
caller's /etc/subuid and /etc/subgid ranges with newuidmap/newgidmap, and in
a new mount namespace so image layers can be mounted. /run is replaced by a
tmpfs with the host's entries bound into it, with the engine's shim socket
directory bound at /run/containerd/s, which shims older than containerd 2.4
hardcode. Containers are networked with pasta (slirp4netns as a fallback),
and get a cgroup when the user's systemd instance can create transient
units. Rootful mode uses the CNI bridge when the plugins are installed.

Commands that cannot work without a daemon or a builder (build, swarm,
plugins, checkpoints, events) report that they are not supported, rather
than failing to connect.

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
Describes what DOCKER_STANDALONE=1 does, how state and supervision work
without a daemon, the requirements, the rootful and rootless differences,
the configuration environment variables, the supported commands, the
limitations and the common failure modes.

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
@ericcurtin
ericcurtin requested review from a team and thaJeztah as code owners September 22, 2026 15:44
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 4.89796% with 233 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/standalone/unsupported.go 0.00% 210 Missing ⚠️
cmd/docker/docker.go 0.00% 12 Missing ⚠️
internal/standalone/standalone_other.go 0.00% 9 Missing ⚠️
cli/command/image/inspect.go 60.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!


// killTask sends a signal to the container's init process (or all processes).
func killTask(ctx context.Context, task runtime.Task, sig int, all bool) error {
err := task.Kill(nsCtx(ctx), uint32(sig), all)

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Review did not complete — The agent ran but did not post a review. View logs for details. Re-request a review from docker-agent to retry.

@ericcurtin

ericcurtin commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Treat this as an RFC, it's not as much code as it looks, some of it is vendoring, some advantages:

  • No daemons anywhere, uses containerd as a library. Today docker.sock access is root-equivalent, which is the single biggest Docker security footgun (CI runners, Compose files mounting the socket, etc.). A fork/exec model removes that privilege boundary entirely.
  • Rootless becomes the natural mode rather than a bolt-on: each user's containers run in their own user namespace with their own storage, no shared daemon to mediate.
  • Smaller attack surface: one fewer long-lived process with a network/IPC API, one fewer place for a privilege-escalation bug.
  • No single point of failure. A crash, OOM, or upgrade of dockerd currently affects every container on the host (live-restore mitigates but is imperfect). Containers become independent process trees under their shims.
  • Fewer moving parts and no version skew between dockerd and containerd; one binary, one release.
  • Cleaner systemd integration: containers can be genuine systemd units with proper cgroup delegation, restart policies, and dependency ordering, instead of a daemon that fights systemd for cgroup ownership.
  • Simpler in ephemeral environments (CI, dev containers, nested containers) where standing up a daemon is awkward or impossible.
  • One fewer IPC hop: today it's CLI → dockerd (gRPC/HTTP) → containerd (gRPC) → shim → runc. Dropping the middle layer shaves latency and memory, and there's no idle daemon consuming resources when nothing's running.
    dockerd duplicates a lot of what containerd already has (image store, snapshotters, content store, lease/GC). Docker has already been migrating to the containerd image store; going all the way removes the parallel implementation.
  • One shared image/content store with Kubernetes CRI, nerdctl, and BuildKit on the same host, rather than Docker's separate graphdriver world.
  • The same containerd stack everywhere means bug fixes and features (lazy-pulling snapshotters, sandboxed shims like gVisor/Kata, WASM shims) land once and Docker inherits them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants