fix(build): resolve image volumes to a mountable name, not a manifest digest

With the containerd image store, ImageSummary.ID holds the digest of the
platform-specific manifest so ServiceHash stays stable across attested
rebuilds (see contentDigest). resolveImageVolumes reused that same value
as the `type: image` mount Source, but the daemon only resolves a mount
Source by name/tag or top-level image ID, not by manifest digest — so
`compose up` failed with "No such image" whenever the volume's source
image was already present locally (always for a built image; on a
second run for a pulled one).

Keep Source as the resolved image name, and track the digest separately
via a new com.docker.compose.image-volume-digest label so mustRecreate
can still detect a rebuilt/updated source image independently of Source.

Fixes #14005

Signed-off-by: Ricardo Branco <[email protected]>
Signed-off-by: Guillaume Lours <[email protected]>
This commit is contained in:
Ricardo Branco
2026-08-13 12:03:29 +02:00
committed by Guillaume Lours
parent d4fe2aead5
commit 1615bf352f
4 changed files with 44 additions and 22 deletions
+5
View File
@@ -47,6 +47,11 @@ const (
SlugLabel = "com.docker.compose.slug"
// ImageDigestLabel stores digest of the container image used to run service
ImageDigestLabel = "com.docker.compose.image"
// ImageVolumeDigestLabel stores the content digest of each `type: image`
// volume's source image, as "target=digest" pairs joined by ",", so
// mustRecreate can detect a rebuilt/updated source image independently of
// the mount Source (which must stay a resolvable name, not a digest).
ImageVolumeDigestLabel = "com.docker.compose.image-volume-digest"
// DependenciesLabel stores service dependencies
DependenciesLabel = "com.docker.compose.depends_on"
// VersionLabel stores the compose tool version used to build/run application
+28 -16
View File
@@ -19,6 +19,7 @@ package compose
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -167,25 +168,36 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
}
func resolveImageVolumes(service *types.ServiceConfig, images map[string]api.ImageSummary, projectName string) {
var digests []string
for i, vol := range service.Volumes {
if vol.Type == types.VolumeTypeImage {
imgName := vol.Source
if _, ok := images[vol.Source]; !ok {
// check if source is another service in the project
imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: vol.Source}, projectName)
// If we still can't find it, it might be an external image that wasn't pulled yet or doesn't exist
if _, ok := images[imgName]; !ok {
continue
}
}
if img, ok := images[imgName]; ok {
// Use Image ID directly as source.
// Using name@digest format (via reference.WithDigest) fails for local-only images
// that don't have RepoDigests (e.g. built locally in CI).
// Image ID (sha256:...) is always valid and ensures ServiceHash changes on rebuild.
service.Volumes[i].Source = img.ID
if vol.Type != types.VolumeTypeImage {
continue
}
imgName := vol.Source
if _, ok := images[vol.Source]; !ok {
// check if source is another service in the project
imgName = api.GetImageNameOrDefault(types.ServiceConfig{Name: vol.Source}, projectName)
// If we still can't find it, it might be an external image that wasn't pulled yet or doesn't exist
if _, ok := images[imgName]; !ok {
continue
}
}
img, ok := images[imgName]
if !ok {
continue
}
// The daemon only resolves a `type=image` mount Source that is a name/tag
// or a top-level image ID, not a per-platform manifest digest (which is
// what ImageSummary.ID holds to stay stable across attested rebuilds, see
// contentDigest()). Keep Source as the resolved name so mounting always
// works, and track the digest separately so mustRecreate can still detect
// a changed source image.
service.Volumes[i].Source = imgName
digests = append(digests, vol.Target+"="+img.ID)
}
if len(digests) > 0 {
sort.Strings(digests)
service.CustomLabels.Add(api.ImageVolumeDigestLabel, strings.Join(digests, ","))
}
}
+8 -6
View File
@@ -103,12 +103,13 @@ func (s *ObservedState) selectVolume(key, desiredName string) (ObservedVolume, [
// ObservedContainer holds the relevant state extracted from a running or stopped
// container, with label values pre-parsed for efficient comparison.
type ObservedContainer struct {
ID string
Name string
State container.ContainerState // "running", "exited", "created", "restarting", etc.
ConfigHash string // label com.docker.compose.config-hash
ImageDigest string // label com.docker.compose.image
Number int // label com.docker.compose.container-number
ID string
Name string
State container.ContainerState // "running", "exited", "created", "restarting", etc.
ConfigHash string // label com.docker.compose.config-hash
ImageDigest string // label com.docker.compose.image
ImageVolumeDigest string // label com.docker.compose.image-volume-digest
Number int // label com.docker.compose.container-number
// ConnectedNetworks maps network IDs found in the container's network
// settings. Key is the network name as seen by Docker, value is the
@@ -333,6 +334,7 @@ func toObservedContainer(c container.Summary) ObservedContainer {
State: c.State,
ConfigHash: c.Labels[api.ConfigHashLabel],
ImageDigest: c.Labels[api.ImageDigestLabel],
ImageVolumeDigest: c.Labels[api.ImageVolumeDigestLabel],
Number: number,
ConnectedNetworks: networks,
Summary: c,
+3
View File
@@ -770,6 +770,9 @@ func (r *reconciler) mustRecreate(expected types.ServiceConfig, expectedHash str
if oc.ImageDigest != expected.CustomLabels[api.ImageDigestLabel] {
return true
}
if oc.ImageVolumeDigest != expected.CustomLabels[api.ImageVolumeDigestLabel] {
return true
}
if oc.State == container.StateRunning && r.hasNetworkMismatch(expected, oc) {
return true
}