Skip to content

Fix/down rmi dangling images - #14266

Open
glours wants to merge 4 commits into
docker:mainfrom
glours:fix/down-rmi-dangling-images-14219
Open

glours wants to merge 4 commits into
docker:mainfrom
glours:fix/down-rmi-dangling-images-14219

Conversation

@glours

@glours glours commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

What I did
docker compose down --rmi ... always printed a misleading Dangling images ... Removed line, even on a repeat down with nothing left to clean up.

  • Dangling-image removal now stays silent when there's nothing eligible to remove, mirroring the existing silent skip in removeNetwork. A failure while checking for dangling images surfaces as a visible error instead of the misleading label, and no longer aborts the rest of down. A partial removal failure (a real daemon error, not a benign "already gone" race) keeps the "Removed" status visible alongside the actual error, instead of hiding it.
  • Found the same hazard on a sibling code path during review: ensureImagesDown also listed tagged images to prune synchronously and returned early on failure, before down had scheduled the already-built network/volume cleanup ops onto its errgroup. That listing is now deferred the same way, so a failure there can't block sibling cleanup either.

Verified with go test ./pkg/compose/... (full suite green, new coverage for both fixes), golangci-lint run --build-tags "e2e" ./pkg/compose/... (0 issues), and manually against a real Docker daemon: repeat down --rmi with nothing to remove stays silent (exit 0); a dangling image pinned by a running container surfaces the real conflict alongside "Removed" (exit 1) while network/containers still get cleaned up; a normal down --rmi on a built tagged image is unchanged.

Related issue

fixes #14219

(not mandatory) A picture of a cute animal, if possible in relation to what you did
image

glours and others added 2 commits September 25, 2026 15:57
…hing to clean up

`down --rmi` always printed "Dangling images ... Removed" even when
there was nothing to remove, on every repeat `down` (docker#14219). The
dangling-images removal now: stays silent when there's nothing
eligible, same as removeNetwork's silent skip; shows a visible error
instead of the misleading label when the listing itself fails, without
aborting the rest of `down`; and keeps the "Removed" status visible
alongside the real daemon error when some images fail to remove for a
genuine reason (not a benign already-gone race).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
Found during review of docker#14219: `ensureImagesDown` still listed tagged
images to prune synchronously and returned the error immediately,
before `down` had scheduled the already-built network/volume cleanup
ops onto its errgroup — the exact same hazard just fixed for dangling
images, on a different code path. The listing is now deferred to when
its own op runs, so a failure surfaces as a visible error without
blocking sibling ops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
@glours
glours requested review from a team as code owners September 25, 2026 14:53
@glours
glours requested a review from ndeloof September 25, 2026 14:53

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The fix correctly defers dangling- and tagged-image listing into the ops phase so a listing failure can no longer abort sibling cleanup (networks, volumes). The new removeDanglingImagesOp silent-skip logic mirrors removeNetwork, and the removeImages helper in image_pruner.go properly bounds parallelism with eg.SetLimit(s.maxConcurrency). Test coverage is thorough and the logic is sound.

Lower-confidence findings (not posted inline)

  • [low] pkg/compose/down.go:217 — doc comment contradicts implementation: claims listing failure is reported "without the 'Dangling images' label" but errorEvent(eventID, err.Error()) at line 226 uses eventID = "Dangling images" as the resource ID, so the label IS attached. The comment should say "under the 'Dangling images' label". (Documentation-only inconsistency; no behavioural impact. Not sent to verifier per policy for low-severity findings.)

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.38554% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/image_pruner.go 93.75% 1 Missing and 1 partial ⚠️
pkg/compose/watch.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@ndeloof ndeloof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff, ran the pkg/compose suite (green) and the CI-pinned golangci-lint v2.13.2 (0 issues). The core is solid: the #14219 fix does exactly what the issue asks, the sibling hazard caught in ensureImagesDown is real and the signature change (no error return) locks the bug class structurally, and the five new tests pin the event stream with unusual precision.

One design point worth deciding explicitly (inline): tagged and dangling images now behave oppositely on the same daemon condition. A tagged image still in use goes through removeResource -> warning "Resource is still in use", error swallowed, down exits 0. A dangling image still in use is a hard error in removeImages -> the op fails -> down exits 1 (the PR body's manual test). Suggested tolerance inline; if the asymmetry is intended, a comment saying so would keep the next reader from "fixing" it either way.

Two smaller items with suggestions inline: the "Removed" label can currently show when nothing was actually removed, and the tagged-images fan-out is unbounded where removeImages is capped. Plus one note on a duplicated filter.

Comment thread pkg/compose/image_pruner.go Outdated
Comment on lines +190 to +194
if errdefs.IsNotFound(err) {
// already gone, e.g. removed concurrently by something else
logrus.Debugf("dangling image %s already removed: %v", img.ID, err)
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The asymmetry: a tagged image still in use ends in removeResource's IsConflict branch — visible warning, error swallowed, down exits 0 — while a dangling image still in use lands here as a hard error, failing the op and the whole down (exit 1). Same daemon condition, opposite outcomes depending on the tag. Suggest tolerating Conflict the way NotFound is tolerated (skipped, neither removed nor failed), aligning with the tagged path:

Suggested change
if errdefs.IsNotFound(err) {
// already gone, e.g. removed concurrently by something else
logrus.Debugf("dangling image %s already removed: %v", img.ID, err)
return nil
}
if errdefs.IsNotFound(err) {
// already gone, e.g. removed concurrently by something else
logrus.Debugf("dangling image %s already removed: %v", img.ID, err)
return nil
}
if errdefs.IsConflict(err) {
// still in use: the same benign skip the tagged-image
// path reports via removeResource's conflict branch
logrus.Debugf("dangling image %s still in use: %v", img.ID, err)
return nil
}

If the exit-1 behavior is the intended contract for dangling images, keeping it is fine too — but then a comment stating the asymmetry is deliberate would help.

Comment thread pkg/compose/down.go Outdated
Comment on lines +241 to +244
_, err = s.removeImages(ctx, eligible)
if err != nil {
s.events.On(newEvent(eventID, api.Warning, "Removed", err.Error()))
return err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The doc comment says "some images did get removed — and adds the error as a visible warning alongside it", but nothing guarantees that: on a total failure (every eligible image errors, none NotFound), removed is empty and the label still claims "Removed". removeImages already returns the removed list; using it keeps the label honest:

Suggested change
_, err = s.removeImages(ctx, eligible)
if err != nil {
s.events.On(newEvent(eventID, api.Warning, "Removed", err.Error()))
return err
removed, err := s.removeImages(ctx, eligible)
if err != nil {
if len(removed) == 0 {
// nothing was actually removed: report the failure without
// claiming "Removed"
s.events.On(errorEvent(eventID, err.Error()))
} else {
s.events.On(newEvent(eventID, api.Warning, "Removed", err.Error()))
}
return err
}

Comment thread pkg/compose/down.go
return err
}

eg, ctx := errgroup.WithContext(ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: removeImages caps its fan-out at s.maxConcurrency, but this one is unbounded (as the old per-image downOps were, so no regression — just an opportunity to harmonize while the code moves):

Suggested change
eg, ctx := errgroup.WithContext(ctx)
eg, ctx := errgroup.WithContext(ctx)
eg.SetLimit(s.maxConcurrency)

Comment thread pkg/compose/down.go Outdated
return err
}

var eligible []image.Summary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this listing + keep filter is now duplicated with removeDanglingImages (image_pruner.go), which does exactly the same dance for the watch path. A tiny shared helper (eligibleDanglingImages(ctx, projectName, keep)) would keep the two from drifting apart.

A dangling image still in use failed the whole op, while a tagged
image or volume in the same state only gets a swallowed warning via
removeResource — same daemon condition, inconsistent outcome. Dangling
images now get the same tolerance. Also stop claiming "Removed" when
a removal failure means nothing actually was, bound the tagged-image
fan-out the same way the dangling one already is, and share the
listing+filter logic between the down and watch paths instead of
duplicating it.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review covers only the commits pushed since 67105b5ed48a.

Assessment: 🟡 NEEDS ATTENTION

One medium-confidence finding in the newly introduced errdefs.IsConflict branch.

Comment thread pkg/compose/down.go Outdated

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review covers only the commits pushed since 67105b5ed48a.

Assessment: 🟡 NEEDS ATTENTION

One CONFIRMED medium-severity logic issue introduced in this incremental diff.

Comment thread pkg/compose/down.go Outdated
When a dangling-image batch had both a genuine removal error and an
image tolerated as still in use, with nothing actually removed, the
error path reported only the genuine failure and silently dropped the
still-in-use count — the same information loss the "Removed" label
was already fixed to avoid, just on its error-reporting sibling
branch. Both facts now show up together regardless of which path the
batch's outcome takes.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
@glours
glours requested a review from docker-agent September 25, 2026 18:29

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review covers only the commits pushed since d9eb9e3.

Assessment: 🟢 APPROVE

The incremental diff introduces a clean refactor of the dangling-image removal path:

  • image_pruner.go: removeImages now returns three separate buckets (removed, stillInUse, alreadyGone) instead of a single removed list, and the mutex is held for the entire error-handling block — safe and simpler than the prior pattern.
  • down.go: removeDanglingImagesOp uses a well-structured switch over all outcome combinations, correctly guarding the Removed event behind both non-zero removed and zero-removed/stillInUse checks. The prior review threads (false Removed on all-conflict paths) appear fully addressed.
  • Tests: New test cases accurately model each scenario, and the errdefs.ErrConflict/errdefs.ErrNotFound patterns are consistent with the production checks.

No bugs introduced by the new + lines were found.

This branch has not been deployed

No deployments
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.

[BUG] Unexpected output on already turned off service when running compose down

3 participants