Skip to content

Resolve fallback from /manifests to /blobs permanently poisons image cache with wrong media type #13007

Description

@jzhn

Description

When resolving an image by digest, dockerResolver.Resolve() tries HEAD /v2/<repo>/manifests/<digest> first, then falls back to HEAD /v2/<repo>/blobs/<digest> if the manifests endpoint fails. If the manifests request fails due to a transient error (timeout, connection reset, 5xx, etc.), the blobs fallback can silently succeed — but returns Content-Type: application/octet-stream instead of the actual manifest media type.

This wrong media type is cached permanently in containerd's metadata store. The image record becomes poisoned: containerd no longer recognizes the content as a manifest list/index, cannot resolve platforms or child manifests, and never self-heals. The only recovery is manual deletion of the image and content records.

Steps to reproduce the issue

  • Set up a CNCF Distribution v3 registry
  • Push a multi-platform image (manifest list / OCI index)
  • Configure containerd to pull from this registry
  • Simulate a transient failure on HEAD /v2/<repo>/manifests/<digest> (e.g., inject a network timeout or 503 response)
  • Trigger a pull of the image by digest
  • Observe: ctr -n k8s.io images ls shows application/octet-stream media type and only the manifest list JSON size (e.g., 513 bytes) instead of the full image size
  • The image cannot be used — no platforms are resolved

Describe the results you received and expected

When successfully reproduced, the issue would cause containerd cache pollution that it will indefinitely not be able to run an image.

k8s events

  Normal   Pulled                  67s (x6 over 116s)    kubelet                  spec.containers{my-container}: Container image "my-image:<my-tag>@<my-sha>" already present on machine
  Warning  Failed                  13s (x10 over 116s)   kubelet                  spec.containers{my-container}: Error: failed to create containerd container: error unpacking image: unexpected media type application/octet-stream for <my-sha>: not found

containerd state

ctr -n k8s.io images ls | grep my-image
 my-image:<my-tag>@<my-sha>                     application/octet-stream                                  <my-sha> 513.0 B   -                                                                                                                   io.cri-containerd.image=managed

Expected Behavior

It is debatable if CNCF distribution project should instead return the real Content-Type instead of application/octet-stream at GET/HEAD /blobs endpoint. But containerd should be robust enough to never get into indefinite bad state because of the GET /blobs fallback.

What version of containerd are you using?

2.1.5

Any other relevant information

Suspected root cause

resolver.go Resolve()

if dgst != "" {
    paths = append(paths, []string{"manifests", dgst.String()})
    // fallback to blobs on not found.
    paths = append(paths, []string{"blobs", dgst.String()})
}

The comment even says "fallback to blobs on not found" — but the code actually falls back on any error, not just 404. The outer loop still tries all paths, and a transient failure on /manifests/ still causes the fallback to /blobs/, which returns application/octet-stream.

Issue exists in 2.1.5 but should be reproduciable on main.

Show configuration if it is related to CRI plugin.

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions