Skip to content

Commit 5038adb

Browse files
authored
[SBOM] Derive image inUse from workloadmeta (#54713)
### What does this PR do? The SBOM check computed inUse from imageUsers, a map of image identifier to running container IDs that it maintained from workloadmeta container events. The map was keyed by the Image.ID of the merged container entity, and that field changes shape over a container's lifetime. On Kubernetes the containerd collector reports the image config digest and the kubelet reports the repo digest, and workloadmeta merges its sources in alphabetical order, so the kubelet value wins as soon as it appears. A container starting on a containerd node is therefore registered first under the config digest, a moment later under the repo digest once the kubelet describes it too, and removed only from the second. The config digest entry stayed behind, and that is exactly the key the img.ID fallback looks up, so the image reported inUse=true until the Agent restarted. Nothing reconciled the map, so a dropped event bundle left the same residue. Ask workloadmeta which images have a running container instead. The store is already up to date when an event bundle is handed to the check, and the question is the one the container check behind Live Containers asks, so the two agree by construction and no cache can drift from either. What is left of the old map is the set of images reported in use by the previous bundle, used only to decide when to push an update, so a stale entry can now delay an emission but never produce a wrong inUse. This drops the event ordering and the stopped container cleanup added in #48586, which the store makes unnecessary. Matching a container against the image entity ID, from the same change, stays: it is how a container on containerd names its image. When an image and the container that just started it are described by the same event bundle, they no longer produce an SBOM each. The first of the two used to carry inUse=false, because the container had not been registered yet, and the second corrected it. Co-authored-by: david.ducolombier <david.ducolombier@datadoghq.com>
1 parent ac7e50b commit 5038adb

5 files changed

Lines changed: 295 additions & 163 deletions

File tree

pkg/collector/corechecks/sbom/batch_refresher.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ func (br *batchRefresher) tick() <-chan time.Time {
4141

4242
// step performs a single refresh step
4343
func (br *batchRefresher) step() {
44+
running := runningImages(br.wmStore)
45+
4446
for _, img := range br.wmStore.ListImages() {
45-
br.proc.processImageSBOM(img)
47+
br.proc.processImageSBOM(img, running)
4648
}
4749
}

pkg/collector/corechecks/sbom/processor.go

Lines changed: 96 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ type processor struct {
5252
workloadmetaStore workloadmeta.Component
5353
containerFilter workloadfilter.FilterBundle
5454
tagger tagger.Component
55-
imageRepoDigests map[string]string // Map where keys are image repo digest and values are image ID
56-
imageUsers map[string]map[string]struct{} // Map where keys are image repo digest and values are set of container IDs
55+
imageRepoDigests map[string]string // Map where keys are image repo digest and values are image ID
56+
imagesInUse map[string]struct{} // Set of image IDs the back end was last told are in use
5757
sbomScanner *sbomscanner.Scanner
5858
contImageSBOM bool
5959
hostSBOM bool
@@ -102,7 +102,7 @@ func newProcessor(workloadmetaStore workloadmeta.Component, filterStore workload
102102
containerFilter: filterStore.GetContainerSBOMFilters(),
103103
tagger: tagger,
104104
imageRepoDigests: make(map[string]string),
105-
imageUsers: make(map[string]map[string]struct{}),
105+
imagesInUse: make(map[string]struct{}),
106106
sbomScanner: sbomScanner,
107107
contImageSBOM: contImageSBOM,
108108
hostSBOM: hostSBOM,
@@ -118,17 +118,24 @@ func isProcfsSBOMEnabled(cfg config.Component) bool {
118118
}
119119

120120
func (p *processor) processContainerImagesEvents(evBundle workloadmeta.EventBundle) {
121+
// The store already reflects the events in this bundle, so ask it which
122+
// images are in use rather than tracking container events ourselves. Ask
123+
// before acknowledging: the store hands the next bundle to the next
124+
// subscriber as soon as this one acknowledges, and would then answer for a
125+
// later moment than the bundle being processed describes.
126+
running := runningImages(p.workloadmetaStore)
127+
121128
evBundle.Acknowledge()
122129

123130
log.Tracef("Processing %d events", len(evBundle.Events))
124131

125-
// Separate events by kind and type so we can process them in an order that
126-
// keeps imageUsers accurate when SBOMs are computed.
132+
// Separate events by kind and type. Image events are handled first so that
133+
// imageRepoDigests is up to date when the identifiers the containers use
134+
// are resolved below.
127135
var (
128-
imageSetEvents []workloadmeta.Event
129-
imageUnsetEvents []workloadmeta.Event
130-
containerSetEvents []workloadmeta.Event
131-
containerUnsetEvents []workloadmeta.Event
136+
imageSetEvents []workloadmeta.Event
137+
imageUnsetEvents []workloadmeta.Event
138+
containerSetEvents []workloadmeta.Event
132139
)
133140

134141
for _, event := range evBundle.Events {
@@ -142,42 +149,49 @@ func (p *processor) processContainerImagesEvents(evBundle workloadmeta.EventBund
142149
case workloadmeta.KindContainer:
143150
if event.Type == workloadmeta.EventTypeSet {
144151
containerSetEvents = append(containerSetEvents, event)
145-
} else {
146-
containerUnsetEvents = append(containerUnsetEvents, event)
147152
}
148153
}
149154
}
150155

151-
// 1. Unregister removed containers first so imageUsers is up to date before
152-
// we compute inUse below. Processing image Set events before these removals
153-
// would cause false-positive inUse=true on the emitted SBOM.
154-
for _, event := range containerUnsetEvents {
155-
p.unregisterContainer(event.Entity.(*workloadmeta.Container))
156-
}
156+
// Images reported in this bundle, so that an image and the container that
157+
// just started it don't each produce an SBOM.
158+
reported := make(map[string]struct{}, len(imageSetEvents))
157159

158-
// 2. Unregister removed images.
159160
for _, event := range imageUnsetEvents {
160161
p.unregisterImage(event.Entity.(*workloadmeta.ContainerImageMetadata))
161162
// Let the SBOM expire on back-end side
162163
}
163164

164-
// 3. Register updated images and emit SBOMs; inUse now reflects the
165-
// current set of running containers.
166165
for _, event := range imageSetEvents {
167-
filterableContainerImage := workloadfilter.CreateContainerImage(event.Entity.(*workloadmeta.ContainerImageMetadata).Name)
166+
img := event.Entity.(*workloadmeta.ContainerImageMetadata)
167+
168+
filterableContainerImage := workloadfilter.CreateContainerImage(img.Name)
168169
if p.containerFilter.IsExcluded(filterableContainerImage) {
169170
continue
170171
}
171172

172-
p.registerImage(event.Entity.(*workloadmeta.ContainerImageMetadata))
173-
p.processImageSBOM(event.Entity.(*workloadmeta.ContainerImageMetadata))
173+
p.registerImage(img)
174+
p.processImageSBOM(img, running)
175+
reported[img.ID] = struct{}{}
176+
}
177+
178+
// Report images that gained their first running container, so that the
179+
// back end learns about them without waiting for the periodic refresh.
180+
// Containers name the same image in more than one way, so compare resolved
181+
// image IDs rather than the identifiers they use.
182+
imagesInUse := make(map[string]struct{}, len(running))
183+
for id := range running {
184+
imgID := p.resolveImageID(id)
185+
imagesInUse[imgID] = struct{}{}
186+
187+
if _, found := p.imagesInUse[imgID]; !found {
188+
p.reportImage(imgID, running, reported)
189+
}
174190
}
191+
p.imagesInUse = imagesInUse
175192

176-
// 4. Register new/updated containers. registerContainer may emit a second
177-
// SBOM for an image that just gained its first running container.
178193
for _, event := range containerSetEvents {
179194
container := event.Entity.(*workloadmeta.Container)
180-
p.registerContainer(container)
181195

182196
filterableContainer := workloadmetafilter.CreateContainer(container, nil)
183197
if p.containerFilter.IsExcluded(filterableContainer) {
@@ -200,52 +214,71 @@ func (p *processor) registerImage(img *workloadmeta.ContainerImageMetadata) {
200214

201215
func (p *processor) unregisterImage(img *workloadmeta.ContainerImageMetadata) {
202216
for _, repoDigest := range img.RepoDigests {
203-
delete(p.imageUsers, repoDigest)
204217
if p.imageRepoDigests[repoDigest] == img.ID {
205218
delete(p.imageRepoDigests, repoDigest)
206219
}
207220
}
208221
}
209222

210-
func (p *processor) registerContainer(ctr *workloadmeta.Container) {
211-
imgID := ctr.Image.ID
212-
ctrID := ctr.ID
223+
// runningImages returns the identifiers of the images that have at least one
224+
// running container. Depending on the runtime and on which workloadmeta
225+
// sources describe it, a container names its image either by image ID or by
226+
// repo digest, so the identifiers are returned as the containers spell them.
227+
func runningImages(store workloadmeta.Component) map[string]struct{} {
228+
containers := store.ListContainersWithFilter(workloadmeta.GetRunningContainers)
229+
230+
images := make(map[string]struct{}, len(containers))
231+
for _, ctr := range containers {
232+
if ctr.Image.ID != "" {
233+
images[ctr.Image.ID] = struct{}{}
234+
}
235+
}
213236

214-
if !ctr.State.Running {
215-
// Container is no longer running. Remove it from imageUsers so that a
216-
// subsequent SBOM computation does not incorrectly set inUse=true for
217-
// an image that has no running containers.
218-
p.unregisterContainer(ctr)
219-
return
237+
return images
238+
}
239+
240+
// imageInUse reports whether one of the running images is img, named either by
241+
// its ID or by one of its repo digests.
242+
func imageInUse(img *workloadmeta.ContainerImageMetadata, running map[string]struct{}) bool {
243+
if _, found := running[img.ID]; found {
244+
return true
220245
}
221246

222-
if _, found := p.imageUsers[imgID]; found {
223-
p.imageUsers[imgID][ctrID] = struct{}{}
224-
} else {
225-
p.imageUsers[imgID] = map[string]struct{}{
226-
ctrID: {},
247+
for _, repoDigest := range img.RepoDigests {
248+
if _, found := running[repoDigest]; found {
249+
return true
227250
}
251+
}
228252

229-
if realImgID, found := p.imageRepoDigests[imgID]; found {
230-
imgID = realImgID
231-
}
253+
return false
254+
}
232255

233-
if img, err := p.workloadmetaStore.GetImage(imgID); err != nil {
234-
log.Infof("Couldn’t find image %s in workloadmeta whereas it’s used by container %s: %v", imgID, ctrID, err)
235-
} else {
236-
p.processImageSBOM(img)
237-
}
256+
// resolveImageID maps the identifier a container uses to name its image to the
257+
// ID of the corresponding image entity. Identifiers that are already image IDs
258+
// are returned unchanged.
259+
func (p *processor) resolveImageID(id string) string {
260+
if imgID, found := p.imageRepoDigests[id]; found {
261+
return imgID
238262
}
263+
264+
return id
239265
}
240266

241-
func (p *processor) unregisterContainer(ctr *workloadmeta.Container) {
242-
imgID := ctr.Image.ID
243-
ctrID := ctr.ID
267+
// reportImage emits the SBOM of the image named by imgID, unless it has already
268+
// been reported for the event bundle being processed.
269+
func (p *processor) reportImage(imgID string, running, reported map[string]struct{}) {
270+
if _, found := reported[imgID]; found {
271+
return
272+
}
244273

245-
delete(p.imageUsers[imgID], ctrID)
246-
if len(p.imageUsers[imgID]) == 0 {
247-
delete(p.imageUsers, imgID)
274+
img, err := p.workloadmetaStore.GetImage(imgID)
275+
if err != nil {
276+
log.Infof("Couldn't find image %s in workloadmeta although a container runs it: %v", imgID, err)
277+
return
248278
}
279+
280+
p.processImageSBOM(img, running)
281+
reported[imgID] = struct{}{}
249282
}
250283

251284
func (p *processor) processHostScanResult(result sbom.ScanResult) {
@@ -362,7 +395,7 @@ func (p *processor) processProcfsScanResult(result sbom.ScanResult) {
362395
p.queue <- sbom
363396
}
364397

365-
func (p *processor) processImageSBOM(img *workloadmeta.ContainerImageMetadata) {
398+
func (p *processor) processImageSBOM(img *workloadmeta.ContainerImageMetadata, running map[string]struct{}) {
366399
if !p.contImageSBOM {
367400
return
368401
}
@@ -398,17 +431,14 @@ func (p *processor) processImageSBOM(img *workloadmeta.ContainerImageMetadata) {
398431
repos[repoName] = struct{}{}
399432
}
400433

401-
inUse := false
402-
for _, repoDigest := range img.RepoDigests {
403-
if _, found := p.imageUsers[repoDigest]; found {
404-
inUse = true
405-
break
406-
}
407-
}
408-
// Fallback for runtimes (e.g. containerd) where ctr.Image.ID is the image
409-
// config digest rather than a repo digest, so imageUsers is keyed by img.ID.
434+
inUse := imageInUse(img, running)
410435
if !inUse {
411-
_, inUse = p.imageUsers[img.ID]
436+
// A periodic refresh reaches this with no event bundle behind it, so
437+
// forget the image here rather than only when a bundle rebuilds the
438+
// set. Otherwise the back end is told the image is not in use while
439+
// the set still says it is, and a container starting it again is
440+
// taken for one that changes nothing and goes unreported.
441+
delete(p.imagesInUse, img.ID)
412442
}
413443

414444
cyclosbom, err := sbomutil.UncompressSBOM(img.SBOM)

0 commit comments

Comments
 (0)