Skip to content

fix(image): pin the image a container runs to its digest - #5125

Open
ekalinin wants to merge 1 commit into
containerd:mainfrom
ekalinin:fix/image-in-use-digest
Open

fix(image): pin the image a container runs to its digest#5125
ekalinin wants to merge 1 commit into
containerd:mainfrom
ekalinin:fix/image-in-use-digest

Conversation

@ekalinin

@ekalinin ekalinin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

nerdctl images marks an image as in use by resolving the image name recorded on the container. A name is a mutable reference: container.Image(ctx) looks it up in the image store as it is now, not as it was when the container was created. Once a tag is moved, the U indicator lands on the wrong row.

 $ nerdctl tag alpine:3.13 mytag:latest
 $ nerdctl run -d --name c1 mytag:latest sleep infinity
 $ nerdctl tag nginx:alpine mytag:latest
 $ nerdctl images
 IMAGE           ID              DISK USAGE    CONTENT SIZE    EXTRA
 alpine:3.13     09538a1f51d3    5.9MB         2.7MB
 nginx:alpine    0168606be231    22.4MB        9.1MB           U

Both answers are wrong. The container still runs alpine, containerd still holds its snapshot and its layers, so alpine cannot be removed - yet it is shown as free, while nginx is shown as busy although nothing ever ran it. A container whose tag was removed altogether fails to resolve and drops out of the in-use set entirely.

The fix

Record the image target digest on the container at creation time, in a new nerdctl/image-digest label, and use it for the in-use lookup. The digest comes from the image nerdctl has already resolved, so nothing extra is fetched, and the label is read from the metadata the listing has already loaded (WithoutRefreshedMetadata), so the lookup costs no additional round trip.

Compatibility

Containers created before this label existed, or created outside nerdctl (ctr, kubelet, another client), have no such label. For those the previous behavior is kept as a fallback: they are still resolved by name. No state migration is needed and no existing container changes behavior for the worse.

An unparsable label value falls back the same way rather than dropping the container from the in-use set. Dropping it would be the more harmful failure: the image would look free, and anything built on this lookup would offer to reclaim space that is actually held.

Why it matters beyond the indicator

The same lookup backs the ACTIVE and RECLAIMABLE columns of nerdctl system df (follow-up, #3942). There a misattributed container is not a single letter in a column: the unique layers of an image that is actually in use get counted as reclaimable space, which is exactly the number a user acts on when deciding what to delete.

The same digest is what nerdctl inspect should report

Added after review feedback. Docker names the image a container was created from by ID, and with the containerd image store that ID is the digest of the image target, pinned on the container at creation:

// moby daemon/containerd/image.go
imgV1 := dockerOciImageToDockerImagePartial(image.ID(img.Target.Digest), ociImage)
// moby daemon/create.go
imgID = img.ID()
ctr, err = daemon.newContainer(opts.params.Name, platform, ..., imgID, opts.managed)
// moby daemon/inspect.go
Image: ctr.ImageID.String(),

That is the value this label records. nerdctl was filling Container.Image with the containerd image name, which a retag moves just the same as the in-use lookup above, so ContainerFromNative now reports the pinned digest there. The reference the user asked for stays in Config.Image, as it does in Docker; the containers with no label keep the name.

Tests

  • TestPinnedImageDigest covers the four label states: pinned, absent, empty, unparsable.
  • TestImages/In use survives a retag reproduces the scenario end to end and asserts both sides: the image the container runs keeps U, the image the tag now points at does not.
  • TestContainerFromNativeImage covers both inspect fields, pinned and not.
  • TestContainerInspectConfigImage asserts end to end that Image is a digest and Config.Image is not.

The in-use lookup was introduced in #5093.

@ekalinin
ekalinin force-pushed the fix/image-in-use-digest branch from 3a458da to a31ddac Compare August 6, 2026 12:46
@AkihiroSuda AkihiroSuda added this to the v2.4.0 milestone Aug 18, 2026
Comment thread pkg/labels/labels.go
// ImageDigest is the digest of the image target the container was created from. The image name
// stored by containerd can be retagged to point at something else, so it is not enough to tell
// which image a container actually uses.
ImageDigest = Prefix + "image-digest"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Docker stores the image digest in:

@ekalinin ekalinin Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, and it turns out the two are the same value.

I have pushed that: ContainerFromNative now reports the pinned digest in Image, and keeps the reference the user asked for in Config.Image, as Docker does. Containers created before the label existed, or created outside nerdctl, still fall back to the name.

Happy to split the inspect part into its own PR if you would rather keep this one to the in-use lookup.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The label should no longer be required?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is still required: the label is where the digest is stored, and Container.Image is the view built from it.

The container record containerd keeps has no field for an image digest — WithImage writes the name:

// containerd client/container_opts.go
func WithImage(i Image) NewContainerOpts {
	return func(ctx context.Context, client *Client, c *containers.Container) error {
		c.Image = i.Name()
		return nil
	}
}

So without the label ContainerFromNative has nothing to read but that name, which is the mutable reference this PR is about.

The one way to drop the label would be to write a digested reference into c.Image instead (alpine:3.13@sha256:...). That field is passed through verbatim to the IMAGE column of nerdctl ps (pkg/cmd/container/list.go), and is keyed on by name elsewhere (usedImages[container.Image] in pkg/imgutil/imgutil.go), so it would change what every container shows, not only the ones a retag affects. A label leaves that alone.

Happy to move it somewhere better if there is one.

`nerdctl images` marks an image as in use by resolving the image name stored on the
container, which follows the tag wherever it points now. After `nerdctl tag` moves a tag
onto another image, the container gets attributed to an image it never ran: the U
indicator lands on the wrong row.

Record the image target digest on the container at creation time, in a new
nerdctl/image-digest label, and use it for the in-use lookup. Containers created before
this label existed, or created outside nerdctl, are still resolved by name; an unparsable
value falls back the same way rather than dropping the container from the set.

That digest is also what `nerdctl inspect` now reports as Image, where Docker reports the
image ID: with the containerd image store that ID is the digest of the image target
(moby daemon/containerd/image.go, image.ID(img.Target.Digest)), pinned on the container
when it is created. nerdctl used to report the image name there, which a retag moves just
the same. The reference the user asked for stays in Config.Image, as it does in Docker.

This also matters for the ACTIVE and RECLAIMABLE columns of `nerdctl system df`, which
build on the same lookup.

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
@ekalinin
ekalinin force-pushed the fix/image-in-use-digest branch from a31ddac to 9d1ff92 Compare August 19, 2026 18:56
c.Config = &Config{
Labels: n.Labels,
Image: c.Image,
// Docker keeps the reference the user asked for here, and the digest in Image above.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Docker seems just storing the digest here

$ docker run --name foo hello-world
$ docker container inspect foo | jq .[0].Image
"sha256:5dd0d3e6e255913fc30f90b9f2b1d359cc2cbdb48090cc4b65f1676e203243cc"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fields, on the same container:

$ docker run --name foo hello-world
$ docker container inspect foo | jq '{Image: .[0].Image, ConfigImage: .[0].Config.Image}'
{
  "Image": "sha256:5dd0d3e6e255913fc30f90b9f2b1d359cc2cbdb48090cc4b65f1676e203243cc",
  "ConfigImage": "hello-world"
}

The digest goes in Image, which is what the line above this one now does; Config.Image keeps the reference, and that is the line this comment landed on. Let me know if you meant something else.

Also worth recording: that digest is the target descriptor, not an ID derived separately.

$ docker image inspect hello-world | jq -r '.[0].Id, .[0].Descriptor.digest'
sha256:5dd0d3e6e255913fc30f90b9f2b1d359cc2cbdb48090cc4b65f1676e203243cc
sha256:5dd0d3e6e255913fc30f90b9f2b1d359cc2cbdb48090cc4b65f1676e203243cc

(docker 29.5.2, containerd snapshotter.) So the Image.Target().Digest this PR records at creation is the same value Docker reports.

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.

2 participants