Skip to content

Commit 6e4be02

Browse files
mergify[bot]orestisflleehinman
authored
add_docker_metadata: fix data races in lazy cgroup cache init (#51688) (#51841)
add_docker_metadata can run as a beat-level (global) processor, in which case a single instance is shared by every pipeline client and its Run method is invoked concurrently. Two data races existed on that path, both reachable by default because match_pids defaults to ["process.pid", "process.parent.pid"]. lazyCgroupCacheInit guarded the cache with a plain "if d.cgroups == nil" check-then-set. Concurrent Run calls raced on the pointer, and two callers could each build a cache and start its own janitor goroutine, leaking a goroutine and orphaning a cache. Replace the field with an atomic.Pointer[common.Cache] initialized exactly once through sync.Once, and load it everywhere it is read. common.Cache.Get held only the read lock, but get updates the element's last access time on access-expiry caches (the cgroup cache is one), so concurrent Get calls mutated the same element under a shared lock. Take the exclusive lock in Get. (cherry picked from commit e1add68) Co-authored-by: Orestis Floros <orestis.floros@elastic.co> Co-authored-by: Lee E. Hinman <lee.e.hinman@elastic.co>
1 parent ca0b5ca commit 6e4be02

4 files changed

Lines changed: 112 additions & 20 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: bug-fix
2+
summary: fix data races in the add_docker_metadata cache initialization
3+
component: all

libbeat/common/cache.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,9 @@ func (c *Cache) ReplaceWithTimeout(k Key, v Value, timeout time.Duration) Value
178178
// Get the current value associated with a key or nil if the key is not
179179
// present. The last access time of the element is updated.
180180
func (c *Cache) Get(k Key) Value {
181-
c.RLock()
182-
defer c.RUnlock()
181+
// Exclusive lock because get() updates the element's last access time on access-expiry caches.
182+
c.Lock()
183+
defer c.Unlock()
183184
v, _ := c.get(k)
184185
return v
185186
}

libbeat/processors/add_docker_metadata/add_docker_metadata.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ type addDockerMetadata struct {
6868
fields []string
6969
sourceProcessor beat.Processor
7070

71-
pidFields []string // Field names that contain PIDs.
72-
cgroups *common.Cache // Cache of PID (int) to container ids (string).
73-
dedot bool // If set to true, replace dots in labels with `_`.
74-
dockerAvailable atomic.Bool // If Docker exists in env, then it is set to true
75-
closeRetry chan struct{} // Channel to signal the connection retry goroutine to stop
71+
pidFields []string // Field names that contain PIDs.
72+
cgroups atomic.Pointer[common.Cache] // Cache of PID (int) to container ids (string).
73+
cgroupsOnce sync.Once // Guards the lazy initialization of cgroups.
74+
dedot bool // If set to true, replace dots in labels with `_`.
75+
dockerAvailable atomic.Bool // If Docker exists in env, then it is set to true
76+
closeRetry chan struct{} // Channel to signal the connection retry goroutine to stop
7677
waitRetry sync.WaitGroup
7778
closeOnce sync.Once
7879
closeErr error
80+
closed atomic.Bool // Set by Close so a late cgroupCache skips starting the janitor.
7981
cgreader processors.CGReader
8082
retryPeriod time.Duration // Period to wait when reconnecting to Docker
8183
retryTimeout time.Duration // Maximum time to wait when connecting to Docker, 0 means wait forever.
@@ -239,15 +241,22 @@ func (d *addDockerMetadata) retryConnectToDocker(connectToDocker func() error, r
239241
}
240242
}
241243

242-
func lazyCgroupCacheInit(d *addDockerMetadata) {
243-
if d.cgroups == nil {
244+
// cgroupCache returns the PID-to-container-ID cache, creating it and starting
245+
// its janitor on first use. It is safe to call from concurrent Run goroutines.
246+
func (d *addDockerMetadata) cgroupCache() *common.Cache {
247+
d.cgroupsOnce.Do(func() {
244248
d.log.Debug("Initializing cgroup cache")
245249
evictionListener := func(k common.Key, v common.Value) {
246250
d.log.Debugf("Evicted cached cgroups for PID=%v", k)
247251
}
248-
d.cgroups = common.NewCacheWithRemovalListener(cgroupCacheExpiration, 100, evictionListener)
249-
d.cgroups.StartJanitor(5 * time.Second)
250-
}
252+
cache := common.NewCacheWithRemovalListener(cgroupCacheExpiration, 100, evictionListener)
253+
d.cgroups.Store(cache)
254+
// Avoid a race and only start the janitor only if Close() has not be called yet.
255+
if !d.closed.Load() {
256+
cache.StartJanitor(5 * time.Second)
257+
}
258+
})
259+
return d.cgroups.Load()
251260
}
252261

253262
func (d *addDockerMetadata) Run(event *beat.Event) (*beat.Event, error) {
@@ -334,8 +343,9 @@ func (d *addDockerMetadata) Run(event *beat.Event) (*beat.Event, error) {
334343

335344
func (d *addDockerMetadata) Close() error {
336345
d.closeOnce.Do(func() {
337-
if d.cgroups != nil {
338-
d.cgroups.StopJanitor()
346+
d.closed.Store(true) // Prevent the janitor from starting.
347+
if cgroups := d.cgroups.Load(); cgroups != nil {
348+
cgroups.StopJanitor()
339349
}
340350

341351
// Stop the retry goroutine, this is safe to call even if the goroutine is not running.
@@ -377,8 +387,8 @@ func (d *addDockerMetadata) lookupContainerIDByPID(event *beat.Event) (string, e
377387
continue
378388
}
379389

380-
if d.cgroups != nil {
381-
if cid := d.cgroups.Get(pid); cid != nil {
390+
if cgroups := d.cgroups.Load(); cgroups != nil {
391+
if cid := cgroups.Get(pid); cid != nil {
382392
d.log.Debugf("Using cached cgroups for pid=%v", pid)
383393
cidStr, ok := cid.(string)
384394
if !ok {
@@ -401,11 +411,9 @@ func (d *addDockerMetadata) lookupContainerIDByPID(event *beat.Event) (string, e
401411
d.log.Debugf("failed to get cgroups for pid=%v: %v", pid, err)
402412
}
403413

404-
// Initialize at time of first use.
405-
lazyCgroupCacheInit(d)
406-
407414
cid, err := getContainerIDFromCgroups(cgroups)
408-
d.cgroups.Put(pid, cid)
415+
// Cache the result, creating the cache on first use.
416+
d.cgroupCache().Put(pid, cid)
409417

410418
return cid, err
411419
}

libbeat/processors/add_docker_metadata/add_docker_metadata_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"fmt"
2525
"os"
2626
"runtime"
27+
"sync"
2728
"sync/atomic"
2829
"testing"
2930
"time"
@@ -33,6 +34,7 @@ import (
3334

3435
"github.com/elastic/beats/v7/libbeat/beat"
3536
"github.com/elastic/beats/v7/libbeat/processors"
37+
"github.com/elastic/beats/v7/libbeat/tests/resources"
3638
"github.com/elastic/elastic-agent-autodiscover/bus"
3739
"github.com/elastic/elastic-agent-autodiscover/docker"
3840
"github.com/elastic/elastic-agent-libs/config"
@@ -447,6 +449,84 @@ func TestMatchPIDs(t *testing.T) {
447449
})
448450
}
449451

452+
func TestMatchPIDsConcurrent(t *testing.T) {
453+
containerID := "8c147fdfab5a2608fe513d10294bf77cb502a231da9725093a155bd25cd1f14b"
454+
p, err := buildDockerMetadataProcessor(logp.NewNopLogger(), config.NewConfig(), MockWatcherFactory(
455+
map[string]*docker.Container{
456+
containerID: {
457+
ID: containerID,
458+
Image: "image",
459+
Name: "name",
460+
},
461+
},
462+
nil,
463+
))
464+
require.NoError(t, err, "initializing add_docker_metadata processor")
465+
t.Cleanup(func() {
466+
assert.NoError(t, processors.Close(p), "closing add_docker_metadata processor")
467+
})
468+
469+
// Concurrent Run calls on a shared processor must not race on the lazily
470+
// initialized cgroup cache.
471+
start := make(chan struct{})
472+
var wg sync.WaitGroup
473+
for range 10 {
474+
wg.Go(func() {
475+
<-start
476+
477+
fields := mapstr.M{}
478+
fields.Put("process.pid", 1000)
479+
480+
result, err := p.Run(&beat.Event{Fields: fields})
481+
if !assert.NoError(t, err, "processing an event") {
482+
return
483+
}
484+
cid, err := result.Fields.GetValue("container.id")
485+
assert.NoError(t, err, "getting container.id")
486+
assert.Equal(t, containerID, cid)
487+
})
488+
}
489+
close(start)
490+
wg.Wait()
491+
}
492+
493+
// TestCloseBeforeCgroupCacheNoJanitorLeak covers Close running before the lazy cgroup cache is
494+
// initialized. A later cgroupCache call (as a late Run would trigger) must not start a janitor
495+
// goroutine that Close can no longer stop.
496+
func TestCloseBeforeCgroupCacheNoJanitorLeak(t *testing.T) {
497+
containerID := "8c147fdfab5a2608fe513d10294bf77cb502a231da9725093a155bd25cd1f14b"
498+
p, err := buildDockerMetadataProcessor(logp.NewNopLogger(), config.NewConfig(), MockWatcherFactory(
499+
map[string]*docker.Container{
500+
containerID: {
501+
ID: containerID,
502+
Image: "image",
503+
Name: "name",
504+
},
505+
},
506+
nil,
507+
))
508+
require.NoError(t, err, "initializing add_docker_metadata processor")
509+
d := p.(*addDockerMetadata)
510+
511+
assert.NoError(t, processors.Close(p), "closing processor")
512+
assert.Nil(t, d.cgroups.Load(), "cgroups should be nil, the cache was never initialized before Close")
513+
514+
// Stop any janitor a regression would start so it does not affect other tests.
515+
t.Cleanup(func() {
516+
if c := d.cgroups.Load(); c != nil {
517+
c.StopJanitor()
518+
}
519+
})
520+
521+
// Baseline after Close, once all processor goroutines have stopped.
522+
goroutinesChecker := resources.NewGoroutinesChecker()
523+
goroutinesChecker.FinalizationTimeout = 2 * time.Second
524+
525+
d.cgroupCache()
526+
527+
goroutinesChecker.Check(t)
528+
}
529+
450530
func TestWatcherError(t *testing.T) {
451531
logger, observedLogs := logptest.NewTestingLoggerWithObserver(t, "")
452532
testConfig, err := config.NewConfigFrom(map[string]interface{}{

0 commit comments

Comments
 (0)