Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On a dockerd that uses the containerd image store,
composectl installgoes throughdocker 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
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 noContainerdfield in itsInfotype. The snapshotter comes from thedriver-typeentry ofDriverStatusinstead of theoverlayfshardcode. The server version is compared numerically with a leadingvstripped (the Arduino build reportsv29.5.2).pkg/compose/ctrd_install.go): per image, layers above the first missing chain-ID snapshot are written withcontent.WriteBlob, then config, manifest (no layer labels, so only the lease holds the layers) and index;Image.Unpackon the platform manifest;syncfson 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.lockserializes install, uninstall, rm, prune,update cancelandupdate complete's prune.docker image lslists images whose layers are not in containerd. If containerd cannot be reached on such a daemon the install fails rather than falling back todocker load. Other daemons keepdocker load.pkg/compose/storage.go): the bytes now land on up to four paths (store, compose root, content root, snapshotter root).checksizes each term, merges the ones on the same filesystem byst_devand checks each against its own free space;check --format jsongains astoragearray (path,required,free,ok) for aktualizr-lite. An image whose config or manifest cannot be read is sized conservatively from the app tree.update initshares the sizing and counts only the remaining bytes of partly fetched blobs.golang.org/x/sysdirect. No new direct library; no Go bump (directive stays 1.22.12).What is lost
docker saveof 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-filesystemstoragearray.Review and verification
check --localfailed 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 ofUninstallApps; the CLI prints in the install check).make check,make test-unit, andmake test-e2epass in the dev container in all four cells of the matrix:CONTAINERD_SNAPSHOTTERfalse/true × stock dockerd / dockerd with the digest-identity patch. The e2e target now runs packages serially (-p 1) because the integration andpkg/composetests share the daemons.docker image ls --digests,check --install,psandrunworked with no pull; an update sharing base layers took 2m30s;uninstall --pruneandrmreturned 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
installreturned. All recovered through the nextinstall: stale lease deleted, torn snapshots dropped, image re-unpacked or kept, container ran,check --installclean.docker image prune -awith 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:
installloses it;check --installreports it and the next install rewrites it. Asyncfson the compose root would close it.Still unproven
Commits
Each commit builds and passes
go veton 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