Skip to content

Commit cad9713

Browse files
committed
c8d: implement missing image delete logic
Ports over all the previous image delete logic, such as: - Introduce `prune` and `force` flags - Introduce the concept of hard and soft image delete conflics, which represent: - image referenced in multiple tags (soft conflict) - image being used by a stopped container (soft conflict) - image being used by a running container (hard conflict) - Implement delete logic such as: - if deleting by reference, and there are other references to the same image, just delete the passed reference - if deleting by reference, and there is only 1 reference and the image is being used by a running container, throw an error if !force, or delete the reference and create a dangling reference otherwise - if deleting by imageID, and force is true, remove all tags (otherwise soft conflict) - if imageID, check if stopped container is using the image (soft conflict), and delete anyway if force - if imageID was passed in, check if running container is using the image (hard conflict) - if `prune` is true, and the image being deleted has dangling parents, remove them This commit also implements logic to get image parents in c8d by comparing shared layers. Signed-off-by: Laura Brehm <[email protected]>
1 parent f117aef commit cad9713

2 files changed

Lines changed: 334 additions & 14 deletions

File tree

daemon/containerd/image_children.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,71 @@ func isRootfsChildOf(child ocispec.RootFS, parent ocispec.RootFS) bool {
127127

128128
return true
129129
}
130+
131+
// parents returns a slice of image IDs whose entire rootfs contents match,
132+
// in order, the childs first layers, excluding images with the exact same
133+
// rootfs.
134+
//
135+
// Called from image_delete.go to prune dangling parents.
136+
func (i *ImageService) parents(ctx context.Context, id image.ID) ([]imageWithRootfs, error) {
137+
target, err := i.resolveDescriptor(ctx, id.String())
138+
if err != nil {
139+
return nil, errors.Wrap(err, "failed to get child image")
140+
}
141+
142+
cs := i.client.ContentStore()
143+
144+
allPlatforms, err := containerdimages.Platforms(ctx, cs, target)
145+
if err != nil {
146+
return nil, errdefs.System(errors.Wrap(err, "failed to list platforms supported by image"))
147+
}
148+
149+
var childRootFS []ocispec.RootFS
150+
for _, platform := range allPlatforms {
151+
rootfs, err := platformRootfs(ctx, cs, target, platform)
152+
if err != nil {
153+
if cerrdefs.IsNotFound(err) {
154+
continue
155+
}
156+
return nil, errdefs.System(errors.Wrap(err, "failed to get platform-specific rootfs"))
157+
}
158+
159+
childRootFS = append(childRootFS, rootfs)
160+
}
161+
162+
imgs, err := i.client.ImageService().List(ctx)
163+
if err != nil {
164+
return nil, errdefs.System(errors.Wrap(err, "failed to list all images"))
165+
}
166+
167+
var parents []imageWithRootfs
168+
for _, img := range imgs {
169+
nextImage:
170+
for _, platform := range allPlatforms {
171+
rootfs, err := platformRootfs(ctx, cs, img.Target, platform)
172+
if err != nil {
173+
if cerrdefs.IsNotFound(err) {
174+
continue
175+
}
176+
return nil, errdefs.System(errors.Wrap(err, "failed to get platform-specific rootfs"))
177+
}
178+
179+
for _, childRoot := range childRootFS {
180+
if isRootfsChildOf(childRoot, rootfs) {
181+
parents = append(parents, imageWithRootfs{
182+
img: img,
183+
rootfs: rootfs,
184+
})
185+
break nextImage
186+
}
187+
}
188+
}
189+
}
190+
191+
return parents, nil
192+
}
193+
194+
type imageWithRootfs struct {
195+
img containerdimages.Image
196+
rootfs ocispec.RootFS
197+
}

daemon/containerd/image_delete.go

Lines changed: 266 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ package containerd
22

33
import (
44
"context"
5+
"fmt"
6+
"sort"
7+
"strings"
58

69
"github.com/containerd/containerd/images"
710
"github.com/docker/distribution/reference"
811
"github.com/docker/docker/api/types"
12+
"github.com/docker/docker/container"
13+
"github.com/docker/docker/image"
14+
"github.com/docker/docker/pkg/stringid"
915
"github.com/opencontainers/go-digest"
1016
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1117
"github.com/sirupsen/logrus"
@@ -30,8 +36,6 @@ import (
3036
// are divided into two categories grouped by their severity:
3137
//
3238
// Hard Conflict:
33-
// - a pull or build using the image.
34-
// - any descendant image.
3539
// - any running container using the image.
3640
//
3741
// Soft Conflict:
@@ -45,8 +49,6 @@ import (
4549
// meaning any delete conflicts will cause the image to not be deleted and the
4650
// conflict will not be reported.
4751
//
48-
// TODO(thaJeztah): implement ImageDelete "force" options; see https://github.com/moby/moby/issues/43850
49-
// TODO(thaJeztah): implement ImageDelete "prune" options; see https://github.com/moby/moby/issues/43849
5052
// TODO(thaJeztah): image delete should send prometheus counters; see https://github.com/moby/moby/issues/45268
5153
func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force, prune bool) ([]types.ImageDeleteResponseItem, error) {
5254
parsedRef, err := reference.ParseNormalizedNamed(imageRef)
@@ -59,28 +61,278 @@ func (i *ImageService) ImageDelete(ctx context.Context, imageRef string, force,
5961
return nil, err
6062
}
6163

64+
imgID := image.ID(img.Target.Digest)
65+
66+
if isImageIDPrefix(imgID.String(), imageRef) {
67+
return i.deleteAll(ctx, img, force, prune)
68+
}
69+
70+
singleRef, err := i.isSingleReference(ctx, img)
71+
if err != nil {
72+
return nil, err
73+
}
74+
if !singleRef {
75+
err := i.client.ImageService().Delete(ctx, img.Name)
76+
if err != nil {
77+
return nil, err
78+
}
79+
i.LogImageEvent(imgID.String(), imgID.String(), "untag")
80+
records := []types.ImageDeleteResponseItem{{Untagged: reference.FamiliarString(reference.TagNameOnly(parsedRef))}}
81+
return records, nil
82+
}
83+
84+
using := func(c *container.Container) bool {
85+
return c.ImageID == imgID
86+
}
87+
ctr := i.containers.First(using)
88+
if ctr != nil {
89+
if !force {
90+
// If we removed the repository reference then
91+
// this image would remain "dangling" and since
92+
// we really want to avoid that the client must
93+
// explicitly force its removal.
94+
refString := reference.FamiliarString(reference.TagNameOnly(parsedRef))
95+
err := &imageDeleteConflict{
96+
reference: refString,
97+
used: true,
98+
message: fmt.Sprintf("container %s is using its referenced image %s",
99+
stringid.TruncateID(ctr.ID),
100+
stringid.TruncateID(imgID.String())),
101+
}
102+
return nil, err
103+
}
104+
105+
err := i.softImageDelete(ctx, img)
106+
if err != nil {
107+
return nil, err
108+
}
109+
110+
i.LogImageEvent(imgID.String(), imgID.String(), "untag")
111+
records := []types.ImageDeleteResponseItem{{Untagged: reference.FamiliarString(reference.TagNameOnly(parsedRef))}}
112+
return records, nil
113+
}
114+
115+
return i.deleteAll(ctx, img, force, prune)
116+
}
117+
118+
// deleteAll deletes the image from the daemon, and if prune is true,
119+
// also deletes dangling parents if there is no conflict in doing so.
120+
// Parent images are removed quietly, and if there is any issue/conflict
121+
// it is logged but does not halt execution/an error is not returned.
122+
func (i *ImageService) deleteAll(ctx context.Context, img images.Image, force, prune bool) ([]types.ImageDeleteResponseItem, error) {
123+
var records []types.ImageDeleteResponseItem
124+
125+
// Workaround for: https://github.com/moby/buildkit/issues/3797
62126
possiblyDeletedConfigs := map[digest.Digest]struct{}{}
63-
if err := i.walkPresentChildren(ctx, img.Target, func(_ context.Context, d ocispec.Descriptor) {
127+
err := i.walkPresentChildren(ctx, img.Target, func(_ context.Context, d ocispec.Descriptor) {
64128
if images.IsConfigType(d.MediaType) {
65129
possiblyDeletedConfigs[d.Digest] = struct{}{}
66130
}
67-
}); err != nil {
131+
})
132+
if err != nil {
68133
return nil, err
69134
}
135+
defer func() {
136+
if err := i.unleaseSnapshotsFromDeletedConfigs(context.Background(), possiblyDeletedConfigs); err != nil {
137+
logrus.WithError(err).Warn("failed to unlease snapshots")
138+
}
139+
}()
70140

71-
err = i.client.ImageService().Delete(ctx, img.Name, images.SynchronousDelete())
141+
imgID := img.Target.Digest.String()
142+
143+
var parents []imageWithRootfs
144+
if prune {
145+
parents, err = i.parents(ctx, image.ID(imgID))
146+
if err != nil {
147+
logrus.WithError(err).Warn("failed to get image parents")
148+
}
149+
sortParentsByAffinity(parents)
150+
}
151+
152+
imageRefs, err := i.client.ImageService().List(ctx, "target.digest=="+imgID)
72153
if err != nil {
73154
return nil, err
74155
}
156+
for _, imageRef := range imageRefs {
157+
if err := i.imageDeleteHelper(ctx, imageRef, &records, force); err != nil {
158+
return records, err
159+
}
160+
}
161+
i.LogImageEvent(imgID, imgID, "delete")
162+
records = append(records, types.ImageDeleteResponseItem{Deleted: imgID})
75163

76-
// Workaround for: https://github.com/moby/buildkit/issues/3797
77-
if err := i.unleaseSnapshotsFromDeletedConfigs(context.Background(), possiblyDeletedConfigs); err != nil {
78-
logrus.WithError(err).Warn("failed to unlease snapshots")
164+
for _, parent := range parents {
165+
if !isDanglingImage(parent.img) {
166+
break
167+
}
168+
err = i.imageDeleteHelper(ctx, parent.img, &records, false)
169+
if err != nil {
170+
logrus.WithError(err).Warn("failed to remove image parent")
171+
break
172+
}
173+
parentID := parent.img.Target.Digest.String()
174+
i.LogImageEvent(parentID, parentID, "delete")
175+
records = append(records, types.ImageDeleteResponseItem{Deleted: parentID})
79176
}
80177

81-
imgID := string(img.Target.Digest)
82-
i.LogImageEvent(imgID, imgID, "untag")
83-
i.LogImageEvent(imgID, imgID, "delete")
178+
return records, nil
179+
}
180+
181+
// isImageIDPrefix returns whether the given
182+
// possiblePrefix is a prefix of the given imageID.
183+
func isImageIDPrefix(imageID, possiblePrefix string) bool {
184+
if strings.HasPrefix(imageID, possiblePrefix) {
185+
return true
186+
}
187+
if i := strings.IndexRune(imageID, ':'); i >= 0 {
188+
return strings.HasPrefix(imageID[i+1:], possiblePrefix)
189+
}
190+
return false
191+
}
192+
193+
func sortParentsByAffinity(parents []imageWithRootfs) {
194+
sort.Slice(parents, func(i, j int) bool {
195+
lenRootfsI := len(parents[i].rootfs.DiffIDs)
196+
lenRootfsJ := len(parents[j].rootfs.DiffIDs)
197+
if lenRootfsI == lenRootfsJ {
198+
return isDanglingImage(parents[i].img)
199+
}
200+
return lenRootfsI > lenRootfsJ
201+
})
202+
}
203+
204+
// isSingleReference returns true if there are no other images in the
205+
// daemon targeting the same content as `img` that are not dangling.
206+
func (i *ImageService) isSingleReference(ctx context.Context, img images.Image) (bool, error) {
207+
refs, err := i.client.ImageService().List(ctx, "target.digest=="+img.Target.Digest.String())
208+
if err != nil {
209+
return false, err
210+
}
211+
for _, ref := range refs {
212+
if !isDanglingImage(ref) && ref.Name != img.Name {
213+
return false, nil
214+
}
215+
}
216+
return true, nil
217+
}
218+
219+
type conflictType int
220+
221+
const (
222+
conflictRunningContainer conflictType = 1 << iota
223+
conflictActiveReference
224+
conflictStoppedContainer
225+
conflictHard = conflictRunningContainer
226+
conflictSoft = conflictActiveReference | conflictStoppedContainer
227+
)
228+
229+
// imageDeleteHelper attempts to delete the given image from this daemon.
230+
// If the image has any hard delete conflicts (running containers using
231+
// the image) then it cannot be deleted. If the image has any soft delete
232+
// conflicts (any tags/digests referencing the image or any stopped container
233+
// using the image) then it can only be deleted if force is true. Any deleted
234+
// images and untagged references are appended to the given records. If any
235+
// error or conflict is encountered, it will be returned immediately without
236+
// deleting the image.
237+
func (i *ImageService) imageDeleteHelper(ctx context.Context, img images.Image, records *[]types.ImageDeleteResponseItem, force bool) error {
238+
// First, determine if this image has any conflicts. Ignore soft conflicts
239+
// if force is true.
240+
c := conflictHard
241+
if !force {
242+
c |= conflictSoft
243+
}
244+
245+
imgID := image.ID(img.Target.Digest)
246+
247+
err := i.checkImageDeleteConflict(ctx, imgID, c)
248+
if err != nil {
249+
return err
250+
}
251+
252+
untaggedRef, err := reference.ParseAnyReference(img.Name)
253+
if err != nil {
254+
return err
255+
}
256+
err = i.client.ImageService().Delete(ctx, img.Name, images.SynchronousDelete())
257+
if err != nil {
258+
return err
259+
}
260+
261+
i.LogImageEvent(imgID.String(), imgID.String(), "untag")
262+
*records = append(*records, types.ImageDeleteResponseItem{Untagged: reference.FamiliarString(untaggedRef)})
263+
264+
return nil
265+
}
266+
267+
// ImageDeleteConflict holds a soft or hard conflict and associated
268+
// error. A hard conflict represents a running container using the
269+
// image, while a soft conflict is any tags/digests referencing the
270+
// given image or any stopped container using the image.
271+
// Implements the error interface.
272+
type imageDeleteConflict struct {
273+
hard bool
274+
used bool
275+
reference string
276+
message string
277+
}
278+
279+
func (idc *imageDeleteConflict) Error() string {
280+
var forceMsg string
281+
if idc.hard {
282+
forceMsg = "cannot be forced"
283+
} else {
284+
forceMsg = "must be forced"
285+
}
286+
return fmt.Sprintf("conflict: unable to delete %s (%s) - %s", idc.reference, forceMsg, idc.message)
287+
}
288+
289+
func (imageDeleteConflict) Conflict() {}
290+
291+
// checkImageDeleteConflict returns a conflict representing
292+
// any issue preventing deletion of the given image ID, and
293+
// nil if there are none. It takes a bitmask representing a
294+
// filter for which conflict types the caller cares about,
295+
// and will only check for these conflict types.
296+
func (i *ImageService) checkImageDeleteConflict(ctx context.Context, imgID image.ID, mask conflictType) error {
297+
if mask&conflictRunningContainer != 0 {
298+
running := func(c *container.Container) bool {
299+
return c.ImageID == imgID && c.IsRunning()
300+
}
301+
if ctr := i.containers.First(running); ctr != nil {
302+
return &imageDeleteConflict{
303+
reference: stringid.TruncateID(imgID.String()),
304+
hard: true,
305+
used: true,
306+
message: fmt.Sprintf("image is being used by running container %s", stringid.TruncateID(ctr.ID)),
307+
}
308+
}
309+
}
310+
311+
if mask&conflictStoppedContainer != 0 {
312+
stopped := func(c *container.Container) bool {
313+
return !c.IsRunning() && c.ImageID == imgID
314+
}
315+
if ctr := i.containers.First(stopped); ctr != nil {
316+
return &imageDeleteConflict{
317+
reference: stringid.TruncateID(imgID.String()),
318+
used: true,
319+
message: fmt.Sprintf("image is being used by stopped container %s", stringid.TruncateID(ctr.ID)),
320+
}
321+
}
322+
}
323+
324+
if mask&conflictActiveReference != 0 {
325+
refs, err := i.client.ImageService().List(ctx, "target.digest=="+imgID.String())
326+
if err != nil {
327+
return err
328+
}
329+
if len(refs) > 1 {
330+
return &imageDeleteConflict{
331+
reference: stringid.TruncateID(imgID.String()),
332+
message: "image is referenced in multiple repositories",
333+
}
334+
}
335+
}
84336

85-
return []types.ImageDeleteResponseItem{{Untagged: reference.FamiliarString(parsedRef)}}, nil
337+
return nil
86338
}

0 commit comments

Comments
 (0)