Repository navigation
Docker images and build cache: what does dl reclaim #450
Description
Question
Docker images and the build cache: what, if anything, does dl reclaim, and under what surface?
The biggest straggler by two orders of magnitude. On the host docs/cleanup.md was written from: 86.5 GB of reclaimable images, 13.88 GB of build cache, against 4.00 GB of stale clones that --prune had to give back.
The current policy is a deliberate refusal, and the reasoning is good: images devpod builds carry no devlaunch or devpod label, so any list dl printed would be a guess at which are ours; docker image prune -a is not scoped to devlaunch at all and would take images built by everything else on the machine; and deleting them is a decision with the user's own containers on the other side of it. So dl points at docker system df and offers no flag.
Principle 2 does not accept "nothing reclaims this" as an end state, and principle 1 does not accept guessing. Resolve the two against the answer to the record question:
- If devpod records what it built, the volume precedent applies directly and this becomes reading a record rather than guessing a pattern. Then the question is only surface and safety: what to offer, whether it belongs on
--pruneor somewhere of its own, and what happens when an image is shared by a workspace that still exists. - If it does not, the choice is between an architecture change (make
dlcause a label to exist at build time, so the record is ours) and a written acceptance that these bytes are the user's to manage, which principle 2 permits only if it is argued rather than inherited.
Weigh, explicitly:
- An image is not a volume. Deleting an image another workspace needs costs a rebuild, not data, so principle 1's bar is genuinely lower here than anywhere else on this map. Say so if it changes the answer.
- The build cache is not per-workspace at all and may not be attributable even in principle. It can be a separate verdict from images.
- The prebuild path.
.devcontainer/devcontainer.jsonnames aprebuildRepositoryand the tag is a hash of build config, context and architecture. A reclaim that deletes a pulled prebuild costs a pull, and one that deletes thelatestcache target costs every future build its layer cache. - Whatever is decided,
docs/cleanup.md's existing paragraph is a reasoned position, not an oversight. Superseding it means replacing the argument, not just the behaviour.
How the answer gets tested: if a reclaim path is added, the record-reading is unit-tested against a real captured devpod record, and the refusal-to-guess is asserted by a test that no docker command runs when no record names an image.
Activity
blooop commentedon Aug 25, 2026
breadcrumb: the run on this ticket died on an API spend limit before establishing anything. No resolution, nothing recorded, no code touched. Claim released.
A resume starts clean from #447's findings, whose crux is unchanged: the record read is a safe floor that does not reach the bulk of the 86.5 GB, because a rebuild leaves the old tagged image unnamed.
blooop commentedon Aug 25, 2026
agent-decided (principle 2 for the record, principle 1 for the gap):
dlreclaims every image devpod's own record names for it and nothing else, ever, and it gains no way to enumerate docker's images at all. The build cache gets a different verdict: nothing, deliberately, as the second member of principle 2's shared-cache exception.Three things follow, and the third is the one the ticket said not to defer.
1. What is reclaimed: the recorded reference, on two occasions
The read is #447's field,
workspace_result.json->.ContainerDetails.Config.Image, off the documentdevcontainer_volumesalready parses. #451 put a copy of that document's volume fields under devlaunch's cache at the tail of a completedup; this is the same copy with the image reference in it, so there is no second mechanism and no second file format.dl <ws> rm,dl <ws> --rmand--purgeremove the workspace's recorded image the way they already remove its volumes: read beforedevpod deletedestroys the record, unforced, best-effort, silent on success, cannot fail the delete. Same bargain, same reasons, one artifact over.--prune, every recorded reference that is not the current reference of a workspace devpod lists. Two populations arrive here. A workspace devpod has forgotten, which is Keep devlaunch's own copy of the volume names, and reclaim from it #456's case exactly. And a reference a rebuild moved off: on a completedup, when the record's reference differs from the one the marker holds, the old one is appended and the new one stored. That is the answer to the ticket's crux, and it is worth being precise about why it is not a pattern: the superseded tag was named by devpod's own record at the moment it was current, and all devlaunch does is decline to throw the name away. Nothing is synthesized at either read.One predicate, stated once and evaluated in both places: recorded, and not the current reference of a workspace devpod lists. The map's second precedent is why it is one predicate rather than two lists that can disagree.
The launch path never runs docker. It records. Removal happens at a delete the user asked for, or inside a
--pruneplan the user confirms, which matters because of the wrinkle in section 3: a rebuild is recoverable but not byte-identical, so somebody should get to say no.MergedConfig.imageis the boundary, and it is read rather than matched. Non-null means the devcontainer named the reference and devpod pulled it:alpine:3.20is in the same field and is not devlaunch's to touch, at any tag. Null with aconfig.buildbeside it means devpod derived it. Only the second is ever a candidate. A pulled prebuild reads as derived, and stays a candidate: the record does not separate "built here" from "pulled from a prebuildRepository", and inventing a third test by comparing the reference against a configured repository string that may itself have moved would make a wrong answer possible in a direction the type claimed impossible. Cost of being wrong there is a pull, the plan names the reference, and the user has the veto.What that structurally cannot touch, worth stating because the ticket asked.
build.cacheFrom'sghcr.io/blooop/devlaunch-devcontainer:latestis never a recorded reference: the record holds what the container ran from, andlatestis a cache source. So the tag whose loss costs every future build its layer cache is not in the candidate set by construction, not by a guard.No label at build time. Refused, on #447's measurement:
build.optionsis the only route that reaches the build, it is an input toCalculatePrebuildHash, so a label would missghcr.io/blooop/devlaunch-devcontaineruntil the prebuild workflow republished, and a per-workspace label value could never match a published prebuild at all. The record is the label. There was never a need to make one.2. The build cache: nothing, deliberately, and not for the images' reason
Separate verdict, and the difference is not a matter of degree.
Nothing names a buildkit cache record. They are content-addressed and shared across every build on the daemon, so they are not attributable to a workspace even in principle, which is a stronger statement than "devlaunch has no record of them". There is nothing to read, so the record route does not exist here rather than being a lower bound.
What decides it is that the build cache already has an owner that bounds it, and it is the only artifact on this map that does. Measured in this repo's devcontainer, buildkit v0.32.2, default policy, values scaled to the disk:
A named volume is never garbage-collected and a tagged image is never garbage-collected: that is why both are stragglers. A build cache record is evicted by its owner on a policy that ships on by default. So it is re-derivable content, referenced by nothing, under an eviction policy already running, which is exactly the property principle 2 names as its one legitimate exception. The 13.88 GB in
docs/cleanup.mdsat under that host's reserved space, meaning buildkit was keeping it on purpose rather than leaking it.And a
dlthat pruned it would repeatdocker image prune -a's scope error one artifact over: it would delete entries devlaunch never caused and slow every other build on the machine.So the pointer sharpens instead of a flag appearing.
docker builder pruneanddocker buildx duare the reclaim and the measurement, andbuilder.gcindaemon.jsonis where the bound lives. Two honest caveats for the row: the policy above is this container's daemon, not the host's, and principle 2's exception list is written as "the pixi download cache and nothing else", so this claims a second member by the stated property. If that census was meant as a closed list, the row in #455 is where to say so.3. The gap, decided rather than deferred
After section 1, the images a record cannot name are three populations, and none of them gets a mechanism.
upof the same workspace and config reuses that exact tag and the record then names it. The residual is only a workspace whoseupnever once completed. Same residual shape Orphaned volumes with no surviving record #451 wrote for volumes, same reason.pixi run devcontainer-prebuild's two local tags. Nodlpath creates them, the publishing workflow runs on ephemeral runners, and locally they are onedocker rmi. Row with a reason.Why the pattern route is refused as a design and not merely left unbuilt. Five reasons, and the last one is the shape of the answer.
<folder>-<5 hex>:devpod-<32 hex>is devpod's signature, not devlaunch's. Every devcontainer opened by a baredevpod up, by VS Code through the devpod provider, or by another tool on the same daemon produces that shape. Subtracting what devlaunch recorded narrows the candidate set towards images whose owner devlaunch cannot see, which is Orphaned volumes with no surviving record #451's direction argument unchanged.uprebuilds under the same tag and resolves its base afresh, producing a different image id. The hash pins the recipe, not what the recipe pulls. So an image can be the only extant copy of a resolution that no recipe reproduces, and a bulk delete lands that cost on workspaces devlaunch cannot see. It is still not principle 1 data loss: the work is in the clone, the recipe exists, and I am not going to inflate it into one. But it is not free, and "costs a rebuild" is the wrong bar to reason with. That asymmetry is precisely why the record route removes at a delete the user asked for or inside a plan the user confirms, and why a list of names nobody can attribute does not get offered.RootFS.Layersshare nearly all of their disk, so per-image sizes are not additive and no total is meaningful. The offer would be names with no sizes and no attribution, which is Orphaned volumes with no surviving record #451's "better to print no list than a list nobody can check". One concession, because it genuinely is weaker here: the folder basename prefix makes these names far more legible thandind-var-lib-docker-<hash>, so a user often does recognize their own project. It does not flip the answer. The basename says which folder, not which tool opened it and not whether anything still wants it, anddocker imagesalready shows the user that same list. Alldlwould add is the implication that it knows these are its own.clients/docker.rsgainsremove_imageand no listing verb. A pattern sweep needs to enumerate docker's images; with no enumeration in the module, the pattern route is not expressible rather than merely not written, and the only string that can reachdocker image rmis one a devpod record supplied. That is the same trick Orphaned volumes with no surviving record #451 used on volume names, applied to the module surface instead of the value.docker image prune -aand a hand-writtendocker rmiare still what clears the historical population, and the docs can point at them far more precisely than today.4. The type shape
The two distinctions in play are not the same kind of thing, and only one of them is a type.
Read versus inferred is not a type. Same answer as #451: no
Provenanceenum and nofrom_patternflag, because either one makes an inferred reference representable and then relies on nobody building one. References exist only as the return of a parse over bytes from a devpod record or devlaunch's copy of one. There is nothing to tag.Derived versus named is a type, because the arms differ in removability.
The parse returns
Option<RecordedImage>,Nonefor absent, unreadable or empty, so an empty reference has no representation and "nothing was recorded" keeps the meaningdevcontainer_volumesalready gives it. Removal takes aReclaimableImagewith no constructor outside theDerivedarm of one match, which is the private-constructor recipe and is what makes aNamedreference unable to reachdocker image rmby any route, including a future careless one.Which occasion, on the occasion. Three arms, no fourth, so a pattern arm added later is a compile error at every match: read live before a delete took the record away, superseded by a rebuild, workspace devpod no longer lists. Deliberately not merged with #456's two-arm volume occasion, because an arm that cannot happen for one artifact is worse than two enums.
One place I am declining to be precise. The superseded list is a plain list and not
Option<NonEmpty<_>>. Volumes needed the option because empty had two meanings; this list is devlaunch's own append log, where empty unambiguously means no supersession has been observed. Precision without a payoff is a cost.5. What survives in
docs/cleanup.mdSuperseding a reasoned paragraph means saying which reasons die. Four sentences, and two of them stay.
dlprinted would be a guess." The premise is right and Does devpod record the image it built for a workspace #447 re-measured it (Config.Labels: null; devpod's three labels are on the container). The conclusion is wrong, and the interesting part is why: it looked for provenance on the image, where there is none, instead of in devpod's record of the container, where it has been since loft-sh v0.1.0. A record is not a label and it is not a guess.docker image prune -ais not scoped to devlaunch at all." Survives untouched, and it is now load-bearing rather than incidental: it is why the reclaim isdocker image rm <recorded reference>and never a prune verb.docker rmirefuses while any container refers to the image, running or stopped. So the design leans on that refusal, exactly as the volume sweep leans on docker refusing a held volume, and the one sharing hazard Does devpod record the image it built for a workspace #447 found (a rebuilt workspace's old tag still running another workspace's container) is caught by docker rather than by devlaunch's judgement. The design does not lean on it for anything else: it is a backstop under a name that was recorded, not a substitute for having recorded it.dlflag that removes an image." This is what changes, and only for recorded references. There is still no flag that names or removes an image devlaunch has no record of, anddocker system dfstays the pointer for those.docs/cleanup.md's closing disclaimer sentence stays a sentence with no docker command behind it. Its wording narrows: the images that may still hold disk are the ones no record named.clients/docker.rs's module header needs the same treatment rather than an edit. "Nothing here removes an image, images are shared, they are expensive to rebuild, and which workspace owns one is genuinely ambiguous" becomes: ownership is not ambiguous where devpod wrote it down, sharing is docker's to refuse rather than devlaunch's to guess at, and expensive-to-rebuild is why the removal is unforced, plan-gated and never a prune verb. One verb becomes two, and the absence of a third is the load-bearing part.6. Build work
Not #454. #455 carries the rows either way, three of them: images, build cache, and the images no record names.
One new ticket, and it should be sequenced into #456 rather than beside it. The marker, the write occasion and the read are #456's; adding the image reference later versions one small file twice for no reason. So: widen #456 to store the image reference alongside the volume names, and a new build ticket for the reclaim itself. It must contain:
.ContainerDetails.Config.Imageplus theMergedConfig.image/config.builddiscriminator, in the same parse the volume names come from.RecordedImage,ReclaimableImagewith no constructor outside theDerivedarm,Nonefor absent or empty.up, a reference that differs from the stored one appends the old and stores the new, and runs no docker command.docker image rmof the current reclaimable reference, best-effort, cannot fail the delete, silent on success. A refusal keeps the reference in the marker, so it degrades into the prune-time candidate set instead of being lost.--prune: candidates are every stored reference that is not the current reference of a workspace devpod lists, as one predicate in one place. Printed as its own group in the plan, because a live workspace's superseded image going away must not read as its current one going away.--prunestill never deletes a workspace.verdict_cacheand the same reason as Keep devlaunch's own copy of the volume names, and reclaim from it #456.clients/docker.rsgainsremove_imageand no listing verb, and the header's argument is replaced as in section 5.--ls --size: per-image sizes are not additive. Count and references only.{"image": ...}, build, prebuild). The refusal the ticket asks for, by construction: no path from a string toremove_imageoutside theDerivedarm, so the test asserts the absence of a path. ANamedreference such asalpine:3.20runs no docker command. Anupthat never completed runs no docker command, unchanged from today. A scratchXDG_CACHE_HOMEprune finds no markers and removes no image, which is the map's hard constraint. A reference another container holds is a reported refusal and the marker keeps it. Andlatestis never a candidate, because it is never a recorded reference.