Skip to content

feat(image): adopt Docker v29 output for images list - #5093

Merged
AkihiroSuda merged 1 commit into
containerd:mainfrom
ekalinin:feat/images-docker-v29-output
Aug 4, 2026
Merged

AkihiroSuda merged 1 commit into
containerd:mainfrom
ekalinin:feat/images-docker-v29-output

Conversation

@ekalinin

@ekalinin ekalinin commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Make the default nerdctl images output match Docker v29: a collapsed view with
IMAGE, ID, DISK USAGE, CONTENT SIZE and EXTRA columns, an "In Use" (U)
indicator, and <untagged> for dangling images. Multi-platform images are collapsed
into a single row with aggregated disk and content size.

Details that follow Docker's behavior:

  • DISK USAGE is the content store blobs plus the unpacked snapshots (Docker's
    Total), CONTENT SIZE is the blobs alone. Both are formatted with 3 significant
    digits, like Docker.
  • In use is resolved by image target digest, the way Docker matches containers to
    images, so every reference to a used target is flagged, not only the one the
    container was created from.
  • The "In Use" legend is only printed when the output is a terminal, so piped and
    redirected output stays clean.
  • Rows are ordered by image reference with untagged images last, rather than by
    creation time.
  • The ID column is the OCI digest of the image target, which matches Docker's ID
    with the containerd image store (it differs only from the legacy graphdriver
    image ID). The help text and docs are updated accordingly.

The new view is used only for the bare command. Passing --format, --quiet,
--no-trunc, --digests or --names falls back to the legacy table (REPOSITORY,
TAG, IMAGE ID, CREATED, PLATFORM, SIZE, BLOB SIZE), so existing scripts and
templates keep working, including their creation-time ordering. This mirrors Docker's
own shouldUseTree fallback.

Since the default output now matches Docker, the images tests also run against the
Docker target; only the nerdctl-specific --names subtest stays gated. Tests that
assert on the default images output for untagged images (image prune/remove and
build-without-tag) are updated to expect <untagged>.

The expanded per-platform --tree view is left for a follow-up.

Closes #5027

Comment thread cmd/nerdctl/image/image_list_test.go Resolved
@ekalinin
ekalinin force-pushed the feat/images-docker-v29-output branch 3 times, most recently from 21a312d to 97dbfbd Compare July 24, 2026 09:28
@AkihiroSuda AkihiroSuda added this to the v2.4.0 milestone Jul 25, 2026
@AkihiroSuda
AkihiroSuda requested a review from a team August 1, 2026 17:19
Comment thread cmd/nerdctl/image/image_list.go Resolved
Comment thread cmd/nerdctl/image/image_list.go Resolved

@AkihiroSuda AkihiroSuda left a comment

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.

Thanks

@ekalinin
ekalinin force-pushed the feat/images-docker-v29-output branch from 97dbfbd to a22cd95 Compare August 3, 2026 15:25
Make the default `nerdctl images` output match Docker v29: a collapsed
view with IMAGE, ID, DISK USAGE, CONTENT SIZE and EXTRA columns, an
"In Use" (U) indicator, and <untagged> for dangling images.
Multi-platform images are collapsed into a single row with aggregated
disk and content size. Like Docker, the "In Use" legend is only printed
when the output is a terminal, so piped and redirected output stays
clean, and rows are ordered by image reference with untagged images
last rather than by creation time.

In-use is resolved by image target digest, the way Docker matches
containers to images, so every reference to a used target is flagged,
not only the one the container was created from.

The new view is used only for the bare command. Passing --format,
--quiet, --no-trunc, --digests or --names falls back to the legacy table
(REPOSITORY, TAG, IMAGE ID, CREATED, PLATFORM, SIZE, BLOB SIZE), so
existing scripts and templates keep working, including their
creation-time ordering. This mirrors Docker's own shouldUseTree
fallback.

Since the default output now matches Docker, the images tests also run
against the Docker target; only the nerdctl-specific --names subtest
stays gated. Tests that assert on the default `images` output for
untagged images (image prune/remove and build-without-tag) are updated
to expect <untagged>.

The expanded per-platform `--tree` view is left for a follow-up.

Closes containerd#5027

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
@ekalinin
ekalinin force-pushed the feat/images-docker-v29-output branch from a22cd95 to 4dc5675 Compare August 3, 2026 20:21
@ekalinin

ekalinin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed. Both threads above are addressed (DISK USAGE is now Total = content + unpacked, and the ID note is updated). On top of that, two more Docker-compat fixes that I found while re-reading the diff:

1. "In Use" is now resolved by image target digest instead of image name

moby matches containers to images by digest (daemon/containerd/image_list.go#L338):

  if c.ImageManifest != nil && c.ImageManifest.Digest == target.Digest {

nerdctl was keying the in-use set on container.Image().Name(), so only the reference the container had been created from got the U flag. With nerdctl tag alpine:latest myalias:v1 and a container running off alpine:latest, the myalias:v1 row stayed empty even though it points at the same target. Now every reference to a used target is flagged. The "In use" integration test tags the image under a second name and asserts that all rows pointing at that target are marked, so it also covers the Docker target (where those names are collapsed into a single row).

2. Row ordering in the new view now follows docker/cli

docker/cli's tree view sorts by image name lexicographically and places untagged images last, while nerdctl kept its creation-time ordering. The new view is now sorted by the rendered IMAGE value with <untagged> last. This is applied to the new view only: the legacy table keeps the creation-time ordering that --format consumers (and the CheckCreatedTime subtest) rely on. Added a unit test for the comparator.


One known divergence left, which I'd rather fix separately - let me know if you want it here

CONTENT SIZE sums the descriptor sizes declared in the manifest without checking that the blobs are actually present locally, and skips attestation manifests entirely. moby walks only the present content (walkPresentChildren, ImageManifest.PresentContentSize) and adds it to TotalSize before the pseudo-image / attestation check, so attestation content is counted.

So nerdctl can over-report for partially pulled images - which is not exotic here, given the lazy-pulling snapshotters (stargz, nydus, SOCI, overlaybd) - and under-report when attestations are present. This math predates this PR (it is the same code behind today's BLOB SIZE column), but the new view now surfaces it in two columns instead of one, so it is worth calling out. I'll open a separate issue unless you prefer it folded in.

PTAL.

@AkihiroSuda AkihiroSuda left a comment

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.

Thanks

@AkihiroSuda
AkihiroSuda merged commit f40e074 into containerd:main Aug 4, 2026
63 of 68 checks passed
ekalinin added a commit to ekalinin/nerdctl that referenced this pull request Aug 4, 2026
Expand the Docker v29 view added in containerd#5093 with `--tree`, matching
`docker image ls --tree`: the same IMAGE, ID, DISK USAGE, CONTENT SIZE and
EXTRA columns, plus one row per platform present in the content store,
prefixed with the branch glyphs docker/cli uses.

A platform row carries its own manifest digest as the ID, its own sizes, and
the "U" flag only when a container actually runs that platform, resolved via
the container platform label. The image row keeps aggregating its platforms
and reuses the existing target-digest lookup, so the default view is
unchanged. The label is normalized before matching: platforms.DefaultString
does not normalize, so on arm64 it carries a variant while the manifest
platform normalizes to a bare linux/arm64, and a raw comparison would never
match.

Unlike docker/cli, which computes its column widths itself and separates the
images with a blank line, the rows go through a tabwriter shared with the
header, where a blank line would terminate the column block and misalign
every following group. The groups are therefore separated by the glyphs alone.

The flag combinations docker rejects in shouldUseTree are rejected here too,
with the same messages. They are validated in pkg/cmd/image, where the options
are consumed, so that library callers are covered as well: the tree branch
takes precedence over the formatter, so Tree together with Format used to
print unaligned rows with no header. The CLI validates too, so that the error
still surfaces before a containerd connection is attempted.

Also read the platform of a multi-platform image from its index descriptor
rather than from the image config. The config may be less specific: alpine
ships linux/arm/v6 and linux/arm/v7 manifests whose configs both declare a
bare "linux/arm", which normalizes to linux/arm/v7 and collapsed the two onto
a single key, dropping one platform. That also left it out of the aggregated
sizes of the default view.

The expected per-platform content sizes are declared in testutil, and the
integration test runs against docker as well, only diverging where docker
does: `docker pull` has no --all-platforms, and its message for the digests
conflict names its internal flag.

Closes containerd#5005

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
ekalinin added a commit to ekalinin/nerdctl that referenced this pull request Aug 5, 2026
Expand the Docker v29 view added in containerd#5093 with `--tree`, matching
`docker image ls --tree`: the same IMAGE, ID, DISK USAGE, CONTENT SIZE and
EXTRA columns, plus one row per platform the image declares, prefixed with the
branch glyphs docker/cli uses. Like docker, a platform that an index lists but
that was never pulled is shown with zero sizes. The collapsed and the legacy
views keep describing only what is in the store, and so does EnsureAllContent,
which would otherwise reach out for platforms the user never asked for.

A platform row carries its own manifest digest as the ID, its own sizes, and
the "U" flag only when a container actually runs that platform, resolved via
the container platform label. The image row keeps aggregating its platforms
and reuses the existing target-digest lookup, so the default view is
unchanged. The label is normalized before matching: platforms.DefaultString
does not normalize, so on arm64 it carries a variant while the manifest
platform normalizes to a bare linux/arm64, and a raw comparison would never
match.

Unlike docker/cli, which computes its column widths itself and separates the
images with a blank line, the rows go through a tabwriter shared with the
header, where a blank line would terminate the column block and misalign
every following group. The groups are therefore separated by the glyphs alone.

The flag combinations docker rejects in shouldUseTree are rejected here too,
with the same messages. They are validated in pkg/cmd/image, where the options
are consumed, so that library callers are covered as well: the tree branch
takes precedence over the formatter, so Tree together with Format used to
print unaligned rows with no header. The CLI validates too, so that the error
still surfaces before a containerd connection is attempted.

Also read the platform of a multi-platform image from its index descriptor
rather than from the image config. The config may be less specific: alpine
ships linux/arm/v6 and linux/arm/v7 manifests whose configs both declare a
bare "linux/arm", which normalizes to linux/arm/v7 and collapsed the two onto
a single key, dropping one platform. That also left it out of the aggregated
sizes of the default view.

The expected per-platform content sizes are declared in testutil, and the
integration test runs against docker as well, only diverging where docker
does: `docker pull` has no --all-platforms, and its message for the digests
conflict names its internal flag. DISK USAGE is only asserted to cover CONTENT
SIZE, because which platforms are unpacked depends on what the rest of the
suite did with the shared image store.

Closes containerd#5005

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
@ekalinin
ekalinin deleted the feat/images-docker-v29-output branch August 6, 2026 11:20
ekalinin added a commit to ekalinin/nerdctl that referenced this pull request Aug 7, 2026
Expand the Docker v29 view added in containerd#5093 with `--tree`, matching
`docker image ls --tree`: the same IMAGE, ID, DISK USAGE, CONTENT SIZE and
EXTRA columns, plus one row per platform the image declares, prefixed with the
branch glyphs docker/cli uses. Like docker, a platform that an index lists but
that was never pulled is shown with zero sizes. The collapsed and the legacy
views keep describing only what is in the store, and so does EnsureAllContent,
which would otherwise reach out for platforms the user never asked for.

A platform row carries its own manifest digest as the ID, its own sizes, and
the "U" flag only when a container actually runs that platform. The image row
keeps aggregating its platforms and reuses the existing target-digest lookup,
so the default view is unchanged.

Matching a container to a platform is done on the full platform form, not on
the name the row displays: an index can carry several windows/amd64 manifests
that differ only by OSVersion, and keying on the displayed name would let a
container on one build flag all of them. The container label is normalized
first, since platforms.DefaultString does not normalize: on arm64 it carries a
variant while the manifest platform normalizes to a bare linux/arm64, and a raw
comparison would never match. The rows are sorted on that same full form, so
the ones that render identically keep a stable order.

Unlike docker/cli, which computes its column widths itself and separates the
images with a blank line, the rows go through a tabwriter shared with the
header, where a blank line would terminate the column block and misalign
every following group. The groups are therefore separated by the glyphs alone.

The flag combinations docker rejects in shouldUseTree are rejected here too,
with the same messages. They are validated in pkg/cmd/image, where the options
are consumed, so that library callers are covered as well: the tree branch
takes precedence over the formatter, so Tree together with Format used to
print unaligned rows with no header. The CLI validates too, so that the error
still surfaces before a containerd connection is attempted.

Also read the platform of a multi-platform image from its index descriptor
rather than from the image config. The config may be less specific: alpine
ships linux/arm/v6 and linux/arm/v7 manifests whose configs both declare a
bare "linux/arm", which normalizes to linux/arm/v7 and collapsed the two onto
a single key, dropping one platform. That also left it out of the aggregated
sizes of the default view.

The expected per-platform content sizes are declared in testutil, and the
integration test runs against docker as well, only diverging where docker
does: `docker pull` has no --all-platforms, and its message for the digests
conflict names its internal flag. DISK USAGE is only asserted to cover CONTENT
SIZE, because which platforms are unpacked depends on what the rest of the
suite did with the shared image store.

Closes containerd#5005

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
ekalinin added a commit to ekalinin/nerdctl that referenced this pull request Aug 24, 2026
Expand the Docker v29 view added in containerd#5093 with `--tree`, matching
`docker image ls --tree`: the same IMAGE, ID, DISK USAGE, CONTENT SIZE and
EXTRA columns, plus one row per platform the image declares, prefixed with the
branch glyphs docker/cli uses. Like docker, a platform that an index lists but
that was never pulled is shown with zero sizes. The collapsed and the legacy
views keep describing only what is in the store, and so does EnsureAllContent,
which would otherwise reach out for platforms the user never asked for.

A platform row carries its own manifest digest as the ID, its own sizes, and
the "U" flag only when a container actually runs that platform. The image row
keeps aggregating its platforms and reuses the existing target-digest lookup,
so the default view is unchanged.

Matching a container to a platform is done on the full platform form, not on
the name the row displays: an index can carry several windows/amd64 manifests
that differ only by OSVersion, and keying on the displayed name would let a
container on one build flag all of them. The container label is normalized
first, since platforms.DefaultString does not normalize: on arm64 it carries a
variant while the manifest platform normalizes to a bare linux/arm64, and a raw
comparison would never match. The rows are sorted on that same full form, so
the ones that render identically keep a stable order.

Unlike docker/cli, which computes its column widths itself and separates the
images with a blank line, the rows go through a tabwriter shared with the
header, where a blank line would terminate the column block and misalign
every following group. The groups are therefore separated by the glyphs alone.

The flag combinations docker rejects in shouldUseTree are rejected here too,
with the same messages. They are validated in pkg/cmd/image, where the options
are consumed, so that library callers are covered as well: the tree branch
takes precedence over the formatter, so Tree together with Format used to
print unaligned rows with no header. The CLI validates too, so that the error
still surfaces before a containerd connection is attempted.

Also read the platform of a multi-platform image from its index descriptor
rather than from the image config. The config may be less specific: alpine
ships linux/arm/v6 and linux/arm/v7 manifests whose configs both declare a
bare "linux/arm", which normalizes to linux/arm/v7 and collapsed the two onto
a single key, dropping one platform. That also left it out of the aggregated
sizes of the default view.

The expected per-platform content sizes are declared in testutil, and the
integration test runs against docker as well, only diverging where docker
does: `docker pull` has no --all-platforms, and its message for the digests
conflict names its internal flag. DISK USAGE is only asserted to cover CONTENT
SIZE, because which platforms are unpacked depends on what the rest of the
suite did with the shared image store.

Closes containerd#5005

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

nerdctl images: adopt Docker v29 output

2 participants