Skip to content

Use content digest for pulled service images - #13998

Open
benjick wants to merge 2 commits into
docker:mainfrom
benjick:pull-content-digest
Open

Use content digest for pulled service images#13998
benjick wants to merge 2 commits into
docker:mainfrom
benjick:pull-content-digest

Conversation

@benjick

@benjick benjick commented Aug 3, 2026

Copy link
Copy Markdown

What I did

pullServiceImage returned the pulled image's raw inspect ID, while getImageSummaries resolves already-local images through contentDigest (the platform image-manifest digest, introduced for #13636). Both values feed the com.docker.compose.image label that mustRecreate compares to detect image changes - so the two paths disagreeing made the first up after the pulling up see a phantom image change and recreate every container once, with no change to the project, the image, or the compose file.

Under the containerd image store a tag@digest image reference triggers this reliably: the pull path records the index digest (the raw inspect ID) on the container, and the next up resolves the same image to its platform manifest digest via contentDigest. The strings differ, mustRecreate fires, and the replacement container gets the platform digest - so it happens exactly once per container, which makes it easy to miss but breaks any workflow that relies on up being idempotent (we hit it because our orchestrator asserts unchanged services are never recreated across reconciles).

The fix makes pullServiceImage resolve the pulled image through the same manifests-aware inspect + contentDigest call getImageSummaries uses, so both sides of the staleness comparison speak the same scheme. Engines without manifest support (API < 1.48) keep the previous behavior via contentDigest's plain-ID fallback.

Repro

$ docker run -d --privileged --name repro -e DOCKER_TLS_CERTDIR= docker:dind   # 29.7.0, containerd store
$ cat repro.yml
services:
  app:
    image: alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
    command: ["sleep", "infinity"]
# (compose v5.4.0 as the CLI plugin inside the dind)
$ docker compose -f repro.yml -p repro up -d   # pulls, creates container A
$ docker compose -f repro.yml -p repro up -d   # => "Recreated" - container replaced, nothing changed
$ docker compose -f repro.yml -p repro up -d   # stable from here on

The two containers' com.docker.compose.image labels show the mismatch: container A carries sha256:6baf... (the pinned index digest), its replacement carries the platform manifest digest. v5.3.1 does not exhibit this; it appeared when the local-inspect side moved to contentDigest.

Verification

  • New unit test TestPullServiceImageUsesContentDigest fails against the old return value and passes with the fix.
  • go build ./..., go vet ./pkg/compose/, gofmt -s -l clean; go test ./pkg/compose/ passes.
  • End-to-end against a fresh docker:dind (29.7.0): with this fix the container from the pulling up survives repeated up runs (same container ID); unpatched v5.4.0 recreates it on the second up.

Related: #13636 (the fix that introduced contentDigest for the local-inspect path; this aligns the pull path with it).


Disclosure: this PR was prepared by Claude Code (Anthropic) - the diagnosis, fix, tests, and the verification runs above - in an agentic session I directed. I'm the submitter and point of contact.

🤖 Generated with Claude Code

pullServiceImage returned the pulled image's raw inspect ID, while
getImageSummaries resolves already-local images through contentDigest
(the platform image-manifest digest). Both values feed the
com.docker.compose.image label that mustRecreate compares to detect
image changes, so the two paths disagreeing made the first 'up' after
the pulling 'up' see a phantom image change and recreate every
container once, with no change anywhere. Under the containerd image
store a tag@digest reference triggers this: the raw inspect ID is the
index digest, while contentDigest picks the platform manifest digest.

Resolve the pulled image through the same manifests-aware inspect and
contentDigest call getImageSummaries uses, so both sides of the
staleness comparison speak the same scheme.

Verified against a fresh docker:dind (29.7.0, containerd store) with a
tag@digest service: unpatched v5.4.0 recreates the container on the
second 'up'; with this fix the container survives repeated 'up' runs.
Existing behavior is preserved for engines without manifest support
(contentDigest falls back to the plain ID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Max Malm <benjick@dumfan.net>
@benjick
benjick requested review from a team as code owners August 3, 2026 17:43
@benjick
benjick requested review from glours and ndeloof August 3, 2026 17:43
Extract the inspect-with-manifests + contentDigest sequence from
pullServiceImage into inspectContentDigest, next to the contentDigest
and manifestsSupported helpers it belongs with. pullServiceImage was
over the gocyclo limit of 16 with the resolve block inlined, and
getImageSummaries already ran the same sequence, so this names it once.

Rename the image parameter of the serviceWithHook test helper to img:
it shadowed the moby image package, newly imported by pull_test.go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Malm <benjick@dumfan.net>
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/images.go 63.63% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

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.

1 participant