Repository navigation
Does devpod record the image it built for a workspace #447
Description
Question
Does devpod record the image it built for a workspace, the way it records the volume names it substituted?
This decides whether the largest straggler is reclaimable at all, and it is a fact about devpod rather than a judgement, which is why it is research and why it blocks the images decision.
The stakes: 86.5 GB of reclaimable images and 13.88 GB of build cache measured on one host, against 4.00 GB of stale clones that --prune could give back. Two orders of magnitude apart, and docs/cleanup.md documents that nothing reclaims them, deliberately: images devpod builds carry no devlaunch or devpod label, so any list dl printed would be a guess, and docker image prune -a is not scoped to devlaunch at all.
That argument is sound against guessing. It is not an argument against reclaiming, and principle 2 says the bytes have an owner. What breaks the deadlock is the precedent already in the codebase for volumes: the names are read from devpod's own record, never guessed from a pattern. So:
- Does
~/.devpod(or wherever devpod keeps per-workspace state) record the image tag or id it built or pulled for a workspace? Read the real layout on this machine, do not infer it from docs. Noterust/devlaunch-core/tests/devpod_layout.rsand devpod's on-disk layout is spelled in four places, and its module is missing #394, which says devpod's on-disk layout is spelled in four places. - If it does: is the record present for a workspace devpod failed partway through, and is it stable across the devpod versions this repo supports (0.8 asks for a pty on
ssh --command, 0.26 does not, so version spread is real here)? - If it does not: can
dlcause a label to be attached at build time through anything devpod exposes?dlcurrently passes--id --ide --init-env --mount --workspace-env --dotfiles --dotfiles-script --devcontainer-path --recreate --resetand no build options at all. Is there a supported route, and is a prebuild's tag affected? - Either way: what does an image being shared between workspaces, or between a workspace and something else on the machine, do to the answer? An image is not a volume; deleting one that another workspace still needs costs a rebuild, not data.
Findings onto a research/devpod-image-record branch, linked from this ticket. Answer the question; do not decide the policy, which is the blocked ticket's job.
Activity
blooop commentedon Aug 25, 2026
agent-decided (principle 2, no straggler data): the record exists.
Findings:
docs/research/devpod-image-record.mdonresearch/devpod-image-record. Knowledge only. The policy is #450's, and nothing below picks it.devpod records the image reference per workspace, in one field of a file
dlalready parses.workspace_result.json→.ContainerDetails.Config.Image. That is the same documentflows::lifecycle::devcontainer_volumesopens forSubstitutionContext.LocalWorkspaceFolderandSubstitutionContext.DevContainerID, through the sameclients::devpod_home::sole_workspace_resultcontexts walk. So the volume precedent transfers exactly: the name is read from devpod's own record, not guessed from a pattern, and reading one more key off an already-parsed document is not an architecture change.workspace.jsoncarries no image reference at all.Measured 2026-08-25 in this repo's devcontainer, docker 29.7.2-2, thirteen throwaway workspaces on a scratch
DEVPOD_HOME,pixi run devpod(v0.26.1) plus 0.8.11 and 0.16.6 out ofpixi exec. Not inferred from devpod's docs.The field is uniform across how the image got there.
{"image": "alpine:3.20"}recordsalpine:3.20; a Dockerfile build recordsbld-c745b:devpod-9f56be24be1c0b2903995ed4cbfe8a9e, i.e.<folder>-<5 hex>:devpod-<32 hex>; aprebuildRepositoryhit records the pulled referencelocalhost:5000/dl447pre:devpod-a647918106520274321fd35ad936b5a4, with the same 32-hex tag the publishingdevpod buildused. And the record separates the cases without pattern-matching the tag: a non-nullMergedConfig.imagemeans devpod pulled a reference the devcontainer named (alpine:3.20, not devlaunch's to touch), null with aconfig.buildbeside it means the reference is one devpod derived.Stable across every version
dlhas ever pinned. 0.8.11, 0.16.6 and 0.26.1 all write the same four top-level keys, the sameContainerDetails.Configshape and the same tag form. The floor isdevpod >=0.26.1inpyproject.tomlandconda.recipe/recipe.yaml, so the field predates it by the whole supported range. Incidental but load-bearing for anyone reading source: the pinned devpod is not built fromloft-sh/devpod, whose newest release is v0.6.15;stringsover the binary gives its module path asgithub.com/skevetter/devpod.Four places the record is not, all measured. These are facts, not arguments.
workspace.jsonand no result, while the image it built stands (fail-4e13f:devpod-9fddd6eb…). Same on 0.8.11. Identical shape toCreateRecord::NeverCompleted.devpod deleteremoves the container and the whole workspace directory and leaves the image, so the only record naming it is destroyed at the moment it stops being needed. Anything acting on the reference reads it before the delete, which is the orderingflows::lifecyclealready uses.docker image prunewithout-adoes not touch it. That is the mechanism behind the 86.5 GB.devpod build, which is whatpixi run devcontainer-prebuildruns, creates and deletes its own temporary workspace, leaving a local image no record ever named.Of the 14
:devpod-tagged images the session produced, 10 were named by a surviving record and 4 were not. That ratio is an artifact of a session that provoked every failure mode; what it fixes is the shape, that a read of the records is a lower bound and never the whole set.A label at build time: one route, and it moves the prebuild tag.
devpod up0.26.1 exposes no build options at all, no--label,--build-arg,--tagor--repository; the image-adjacent flags are--devcontainer-image(replaces the image, skipping the build),--fallback-imageand--prebuild-repository.--extra-devcontainer-path, the one flag that looks like a per-launch injection point, merges runtime config and drops the build half: an override carrying bothcontainerEnvandbuild.options, on a firstupof a fresh folder, applied the env (MergedConfig.containerEnv.DL_OVERRIDE = "yes") and producedConfig.Labels: nullwith an unchanged tag. What does work isbuild.optionsin the repository's owndevcontainer.json, which reaches the docker build and lands on the image. It is also an input to the tag hash: the same folder readdevpod-605e9efe…with the label option anddevpod-fd10a682…without it. Since the prebuild lookup is for that exact tag, adding one to.devcontainer/devcontainer.jsonwould missghcr.io/blooop/devlaunch-devcontaineruntil the prebuild workflow republished under the new hash, and a label whose value varies per workspace or per launch could never match a published prebuild at all. Absentbuild.options, every image devpod builds carriesConfig.Labels: null; the three labels devpod does write (dev.containers.id,devcontainer.metadata,devpod.user) go on thedocker runline and live on the container only, sodocs/cleanup.md's claim is right as measured.Sharing, and it is three different cases. Two workspaces over one folder recorded the identical reference, and after one was rebuilt the other's record still named the old tag while its container still ran on it, so one tag was "superseded" and "in use" at once. Two folders with byte-identical Dockerfiles produced two tags and two image ids whose
RootFS.Layersare the same digests, so ids can share nearly all their bytes and a per-image byte count is not additive. And an unforceddocker rmirefuses while any container refers to the image, running or stopped, so the only images it can take at all are those with no container.Cost of being wrong, for the record: a rebuild, not data. Deleting the image made the next
uprebuild under the same tag, because the tag doubles as devpod's build cache key. One wrinkle worth carrying into #450: the rebuild resolved the base afresh and produced a different image id, so removal is recoverable but not byte-identical. Same "the hash covers the recipe, not what the recipe pulls" argumentCLAUDE.mdmakes about the prebuild tag, from the other end.blooop commentedon Aug 25, 2026
Source corroboration, and two caveats the running binary could not supply. A parallel read of devpod's own Go source agrees with every measurement above; the findings doc now carries the citations in a new section 6. Nothing here changes the answer, so the ticket stays closed. Three items are worth surfacing for #450.
The field is structurally a tag, never an image id.
ContainerDetailsConfig(pkg/devcontainer/config/container_details.go:23-34) is the unmarshal target fordocker inspect(pkg/docker/helper.go:324-334), and it has no member for docker's top-level.Imagedigest. The container behind the record quoted above was runningsha256:a44bf1cf424e…and the record kept only the tag. So the reference has to be resolved through docker to become an id, and the tag can have moved in between. Also: devpod's own non-test code never reads the field back. The only reader is its e2e suite (e2e/tests/up/dockerfile_build.go:86,103,132,150), which uses it to detect whether a rebuild changed the image.The file is undocumented.
workspace_result.jsonappears nowhere in devpod'sdocs/tree. Its shape has in fact been unchanged since loft-shv0.5.0, and the JSON keyImageunderContainerDetails.Configgoes back to loft-shv0.1.0, but that is a track record and not a published contract.clients::devpod_homeis already the module that absorbs exactly this risk for the volume names.Only the docker driver was established. The kubernetes driver builds its
ContainerDetailsby hand (pkg/driver/kubernetes/find.go:49-58) and the custom driver unmarshals whatever a provider prints (pkg/driver/custom/custom.go:67). Whether either populatesConfig.Imagewas not traced. Moot fordlas it stands, and it would stop being moot if that changed.The rest lines up exactly. The single write is
provider2.SaveWorkspaceResult(...)atcmd/up.go:452, at the tail ofdevPodUpafter every failing branch has already returned, which is the code behind "a failed create has no result".GetImageName(pkg/devcontainer/build/options.go:221-226) isbasename + "-" + hash(folder)[:5] + ":" + prebuildHash, andCalculatePrebuildHash(pkg/devcontainer/config/prebuild.go:33-72) hashes architecture, a normalized config, the Dockerfile content and a.dockerignore-aware context hash, withConfigBuildOptions.Optionsinside the normalized config, which is whybuild.optionsmoves the tag. The prebuild lookup is that same string (pkg/devcontainer/build.go:341). The buildx command builder (pkg/driver/docker/build.go:95-105) never emits--labeland never passesBuildOptions.Labels, which is why the built image has none, and that is inherited from loft-sh rather than a fork behaviour.--extra-devcontainer-pathcannot reach a build for two independent reasons:AddConfigToImageMetadata(pkg/devcontainer/config/metadata.go:18-30) copies into anImageMetadatawith noDockerfileContainerand noImageContainer, and its call site (pkg/devcontainer/build.go:46-55) runs after the build returned. And the delete path runs plaindocker rmwith no-v(pkg/docker/helper.go:150-157); a grep ofpkg/,cmd/andproviders/forrmior image pruning finds nothing, so devpod never removes an image anywhere.One correction to the coverage figure in the comment above: nine of thirteen images were named by a surviving record, not ten of fourteen.
proj-d3c2f:devpod-099e…on that daemon came from the source-reading run, not from these workspaces.