Skip to content

feat(compose): install app images into dockerd's containerd - #147

Draft
mike-sul wants to merge 7 commits into
mainfrom
feat/containerd-install
Draft

mike-sul wants to merge 7 commits into
mainfrom
feat/containerd-install

Conversation

@mike-sul

@mike-sul mike-sul commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On a dockerd that uses the containerd image store, composectl install goes through docker load, which keeps a second compressed copy of every app image in containerd's content store next to the one in composectl's store (about 380 MB extra for a 380 MB image, plus the unpacked snapshots). This PR streams the images from the store straight into dockerd's containerd under an install lease, unpacks them, and deletes the lease so containerd's GC drops the compressed layers. The store copy stays the only persistent compressed one and remains the source for offline repair and reinstall after aktualizr-lite's image prune.

Design note with the decision record, the rejected options and the board measurements: docs/containerd-install-plan-2026-10-08.md.

What changes

  • Discovery (pkg/compose/docker_client.go): the containerd socket, namespace and snapshotter are read from /info. On a local unix socket a raw GET through the client's transport is decoded into the few fields needed, since the bundled Docker v25 client has no Containerd field in its Info type. The snapshotter comes from the driver-type entry of DriverStatus instead of the overlayfs hardcode. The server version is compared numerically with a leading v stripped (the Arduino build reports v29.5.2).
  • Install into containerd (pkg/compose/ctrd_install.go): per image, layers above the first missing chain-ID snapshot are written with content.WriteBlob, then config, manifest (no layer labels, so only the lease holds the layers) and index; Image.Unpack on the platform manifest; syncfs on the snapshotter root before the image records are created; records for the pinned digest ref and the tag ref, with rollback of the first record if the second fails; lease deleted synchronously. Leases left by an interrupted install are deleted by the next install, so a broken snapshot is never reused. A flock on <StoreRoot>/.install.lock serializes install, uninstall, rm, prune, update cancel and update complete's prune.
  • Host gate: the route is used only with the containerd image store, on a local unix socket, and at least Docker 29.5.2, the oldest version whose docker image ls lists images whose layers are not in containerd. If containerd cannot be reached on such a daemon the install fails rather than falling back to docker load. Other daemons keep docker load.
  • Storage precheck (pkg/compose/storage.go): the bytes now land on up to four paths (store, compose root, content root, snapshotter root). check sizes each term, merges the ones on the same filesystem by st_dev and checks each against its own free space; check --format json gains a storage array (path, required, free, ok) for aktualizr-lite. An image whose config or manifest cannot be read is sized conservatively from the app tree. update init shares the sizing and counts only the remaining bytes of partly fetched blobs.
  • go.mod: importing the root package of the containerd v1.7.15 client adds 17 indirect modules and makes golang.org/x/sys direct. No new direct library; no Go bump (directive stays 1.22.12).

What is lost

docker save of an installed image no longer yields its layers on the containerd route, since the compressed layers are not in the content store. aktualizr-lite's size reporting should read the per-filesystem storage array.

Review and verification

  • Reviewed in four rounds by an independent GPT model acting as an adversarial reviewer (Codex, read-only, high effort). Round 1: three blockers (tcp hosts could not install, check --local failed on a missing config, merge gates undemonstrated) and four should-fix items. Round 2: degraded sizing undercounted, record rollback defeated by cancellation. Round 3: snapshot dedupe without chain IDs undercounted. Round 4: ready to merge, no remaining code findings. All findings were fixed except two that were rejected with reasons (the inherited length of UninstallApps; the CLI prints in the install check).
  • make check, make test-unit, and make test-e2e pass in the dev container in all four cells of the matrix: CONTAINERD_SNAPSHOTTER false/true × stock dockerd / dockerd with the digest-identity patch. The e2e target now runs packages serially (-p 1) because the integration and pkg/compose tests share the daemons.
  • On the board (Docker v29.5.2, containerd 2.3.3, overlayfs, ext4 eMMC): install of a 17-layer, 208 MB image took 3m12s with snapshots 0 → 17 and content blobs 4 → 7 (index, manifest, config, no layers); docker image ls --digests, check --install, ps and run worked with no pull; an update sharing base layers took 2m30s; uninstall --prune and rm returned the board to 0 images, 0 snapshots, 4 blobs and no composectl leases.

Power-cut trials on the board (2026-10-13)

Eleven sysrq-b cuts with nothing synced, on the Arduino UNO Q with the real composectl, each followed by a reboot and a recovery with no manual step: three during unpack (two left torn snapshots, metadata ahead of the disk), three between the first image record and the lease delete (probe-triggered), five right after install returned. All recovered through the next install: stale lease deleted, torn snapshots dropped, image re-unpacked or kept, container ran, check --install clean. docker image prune -a with a stopped container keeps the image; without one it removes it and the reinstall from the store brings it back. Details in the design note.

Two pre-existing limits the trials made visible, both listed as follow-ups in the note:

  • The compose bundle is untarred without a sync on both install routes, so a cut inside the writeback window after install loses it; check --install reports it and the next install rewrites it. A syncfs on the compose root would close it.
  • A cut between containerd's GC commit and its unlinks leaves the compressed layers as orphan files that containerd only removes at its next content cleanup (the next image removal on the device). Bounded to one install's layers and self-healing.

Still unproven

  • Real power cuts and erofs on a deployed fleet (deployment gate).
  • The precheck on a host where the store, content root and snapshotter root are on separate or nearly full mounts.

Commits

Each commit builds and passes go vet on its own: design note, discovery, install, storage sizing, locks in the CLI, integration tests, then the note update with the power-cut trials.

🤖 Generated with Claude Code

mike-sul and others added 7 commits October 13, 2026 11:05
On a dockerd with the containerd image store, every app image is kept
compressed twice: in composectl's store and in containerd's content
store after docker load. The note records the decided route: stream
the images from the store into containerd under an install lease and
drop the compressed layers after unpack. The store copy stays as the
source for offline repair and reinstall after aktualizr-lite's image
prune. It also sets the install lock, the crash recovery rule (syncfs
before the image records), the host gate, the per-filesystem storage
precheck, the options deferred or rejected, and the evidence: the rig
checks in the dev container, the write and crash measurements on the
board, and the Docker 29.5.2 listing check that sets the minimum
version and found the v-prefixed version string.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
The install into dockerd's containerd needs the containerd socket, the
namespace and the snapshotter that dockerd uses. The Docker v25 client
bundled with composectl has no Containerd field in its Info type, and
bumping the Docker module is out of scope, so on a local socket `/info`
is read with a raw GET through the client's own transport and decoded
into the few fields composectl needs. On a tcp host that raw request
would not reach the daemon (the client's address and TLS live in its
request path), so the typed Info is used there; it has no Containerd
field, and such a host is never an install target anyway.

The snapshotter now comes from the `driver-type` entry of DriverStatus
instead of the `overlayfs` hardcode and `DriverStatus[0][1]`, so a
daemon on another snapshotter (erofs, for example) is detected too.
The server version is compared numerically with a leading v stripped:
the Arduino build of Docker reports `v29.5.2`.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
On a dockerd that uses the containerd image store, `docker load` keeps a
second compressed copy of every app image next to the one in
composectl's store. Streaming the images straight into dockerd's
containerd under a short lease, unpacking them and then deleting the
lease lets containerd's GC drop the compressed layers, so the store
copy is the only persistent one (about 380 MB less per 380 MB image).
Layers whose snapshot already exists are not streamed, which makes an
update that shares base layers with the installed version cheaper than
`docker load`, which sends every blob.

The manifest is written without layer labels so that only the lease
holds the layers; the snapshotter's filesystem is synced before the
image records are created, because a power cut after the records
otherwise leaves an image that looks installed but cannot run. Leases
left by an interrupted install are deleted synchronously by the next
install, which also drops the snapshots that only they hold, so a
broken snapshot is never reused. If the second image record cannot be
written, the first is deleted again on a context that survives the
caller's cancellation, so an image is either fully recorded and
reported loaded, or absent. Installs, uninstalls, removals and prunes
are serialized by a flock on `<StoreRoot>/.install.lock`.

The route is used only on a daemon with the containerd image store, on
a local unix socket and at least Docker 29.5.2, the oldest version on
which `docker image ls` lists images whose layers are not in
containerd. If containerd cannot be reached on such a daemon the
install fails instead of falling back to `docker load`, which would
quietly bring the second copy back. Other daemons keep `docker load`.

Importing the root package of the containerd v1.7.15 client adds 17
indirect modules and makes golang.org/x/sys direct; `go mod tidy` was
run with the go directive kept at 1.22.12.

Design note: docs/containerd-install-plan-2026-10-08.md.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
`check` compared the store and runtime sizes of the missing blobs with
the free space of the store root only. With the install into containerd
the bytes land on up to four paths: the store, the compose root, the
content store (the compressed layers streamed during the install) and
the snapshotter root (the unpacked snapshots), which may be on separate
filesystems. The new sizing in pkg/compose computes the four terms, adds
up the ones that share an st_dev and checks each filesystem against its
own free space; `check --format json` reports them in a new `storage`
array next to the unchanged fields, so aktualizr-lite can read it. On a
`docker load` daemon the single term at the store root stays as before.

An image whose manifest or config cannot be read (an interrupted fetch,
checked locally) is sized from the layers in the app tree: every layer
is counted as streamed and, since its chain ID is unknown, as a new
snapshot, so the estimate never drops below the exact one. `check`
keeps reporting the missing blob instead of failing.

`update init` used the same calculation separately and added the store
size to the runtime total; it now uses the shared sizing, counts only
the bytes a partly fetched blob still needs in the store, and keeps the
per-filesystem needs in the update record. The numbers are reported
only: init still does not reject an update on size.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
Every operation that writes to or deletes from dockerd's containerd has
to be serialized with an install, otherwise an uninstall or a prune can
delete what a concurrent install is counting on. rm, uninstall and
prune now take the same flock as install, and so do `update cancel`
around its image and blob removal and `update complete` around its
final store prune (its uninstall and remove calls lock on their own).
prune also deletes the leases that an interrupted install left behind,
which hold compressed layers and snapshots that nothing else references.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
The containerd install route only runs against the dockerd with the
containerd image store, so the existing tests do not reach it. The new
tests skip on a graphdriver dockerd and cover: an install from the
store of an image pinned by a manifest and one pinned by an index,
status, run, check --install and uninstall afterwards; an update that
shares base layers with the installed version; a reinstall after
`/images/prune` removed the images; an install killed after its lease
and first snapshot exist, then recovered by the next install; a second
composectl waiting for the install lock; `update cancel` after the
install; and the `storage` array of `check`, including an app whose
image config is missing from the store.

The retry after a base snapshot disappears between the scan and the
unpack is tested in pkg/compose through the synchronous progress
callback, so that the removal happens at a known point with no data
race. That test shares the daemons and the store with the integration
tests, so the e2e target runs the packages one at a time.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
The note listed cuts during unpack and during the lease deletion as
the remaining unproven part of the crash recovery rule. They are now
demonstrated on the board with the real composectl: three cuts during
unpack, two of which left torn snapshots (metadata ahead of the disk),
three cuts between the first image record and the lease delete, and
five right after install returned, all recovered by the next install
with no manual step. The prune case with a stopped container is
recorded as well.

The trials surfaced two pre-existing limits that the note now states
so they are not mistaken for regressions: the compose bundle is not
synced when install returns, and a cut between containerd's GC commit
and its unlinks leaves the compressed layers as orphan files until the
next content cleanup. Both are listed as follow-ups.

Signed-off-by: Mike Sul <mike.sul@foundries.io>
Assisted-by: Claude Code:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant