Skip to content

Commit 3bbab9f

Browse files
committed
cache binaries that have nothing to instrument
AcquireGlobalUprobe only cached successful attachments. When attach() returned no links (e.g. a stripped Go binary such as kubectl, which has no symbol table), nothing was remembered, so the binary was re-opened and its symbols re-parsed for every new process that ran it. On nodes that run such binaries frequently this is pure overhead. Cache the negative result in a separate map keyed by dev+inode. The binary's size and mtime are stored in the entry and re-checked on every lookup, so a binary replaced in place or whose inode is recycled is re-evaluated rather than served a stale verdict. Entries expire after a TTL; expired ones are swept on insertion, but at most once per TTL, so a burst of new binaries does not turn every insertion into a full-map scan under the lock. Live (refcounted) attachments are unchanged.
1 parent 0ea5fee commit 3bbab9f

2 files changed

Lines changed: 201 additions & 3 deletions

File tree

ebpftracer/tracer.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,25 @@ type globalUprobe struct {
9191
refcount int
9292
}
9393

94+
// negativeUprobe records that a binary had nothing to instrument (e.g. a
95+
// stripped Go binary such as kubectl), so it is not re-opened and re-parsed for
96+
// every process that runs it. It is keyed by dev+inode; size and mtime are kept
97+
// in the value and re-checked on every lookup so an in-place replacement or a
98+
// recycled inode is re-evaluated instead of being served a stale verdict. The
99+
// entry also self-expires after negativeUprobeCacheTTL. This is a heuristic, not
100+
// tamper-proof: a replacement that preserves both size and mtime within the
101+
// filesystem's timestamp granularity is only re-evaluated once the TTL elapses.
102+
type negativeUprobe struct {
103+
size int64
104+
mtime int64
105+
expiresAt time.Time
106+
}
107+
108+
// negativeUprobeCacheTTL bounds how long a "nothing to instrument" verdict is
109+
// trusted, so a binary that is later replaced in a way size+mtime cannot detect
110+
// is still eventually re-evaluated. A var (not const) so tests can shorten it.
111+
var negativeUprobeCacheTTL = 10 * time.Minute
112+
94113
type Tracer struct {
95114
disableL7Tracing bool
96115
hostNetNs netns.NsHandle
@@ -103,7 +122,10 @@ type Tracer struct {
103122
uprobes map[string]*ebpf.Program
104123

105124
globalUprobes map[UprobeKey]*globalUprobe
125+
negativeUprobes map[UprobeKey]negativeUprobe
126+
negativeSweepAt time.Time
106127
globalUprobesLock sync.Mutex
128+
now func() time.Time // overridable in tests
107129
}
108130

109131
func NewTracer(hostNetNs, selfNetNs netns.NsHandle, disableL7Tracing bool) *Tracer {
@@ -115,9 +137,11 @@ func NewTracer(hostNetNs, selfNetNs netns.NsHandle, disableL7Tracing bool) *Trac
115137
hostNetNs: hostNetNs,
116138
selfNetNs: selfNetNs,
117139

118-
readers: map[string]*perf.Reader{},
119-
uprobes: map[string]*ebpf.Program{},
120-
globalUprobes: map[UprobeKey]*globalUprobe{},
140+
readers: map[string]*perf.Reader{},
141+
uprobes: map[string]*ebpf.Program{},
142+
globalUprobes: map[UprobeKey]*globalUprobe{},
143+
negativeUprobes: map[UprobeKey]negativeUprobe{},
144+
now: time.Now,
121145
}
122146
}
123147

@@ -154,6 +178,7 @@ func (t *Tracer) Close() {
154178
}
155179
}
156180
t.globalUprobes = nil
181+
t.negativeUprobes = nil
157182
t.globalUprobesLock.Unlock()
158183
t.collection.Close()
159184
}
@@ -164,6 +189,7 @@ func (t *Tracer) AcquireGlobalUprobe(path string, attach func() []link.Link) (Up
164189
return UprobeKey{}, false
165190
}
166191
key := UprobeKey{Dev: stat.Dev, Ino: stat.Ino}
192+
size, mtime := stat.Size, stat.Mtim.Nano()
167193

168194
t.globalUprobesLock.Lock()
169195
defer t.globalUprobesLock.Unlock()
@@ -172,14 +198,39 @@ func (t *Tracer) AcquireGlobalUprobe(path string, attach func() []link.Link) (Up
172198
gu.refcount++
173199
return key, true
174200
}
201+
if neg, ok := t.negativeUprobes[key]; ok {
202+
if t.now().Before(neg.expiresAt) && neg.size == size && neg.mtime == mtime {
203+
return UprobeKey{}, false // known-empty, unchanged, not expired
204+
}
205+
delete(t.negativeUprobes, key) // expired or replaced: re-evaluate below
206+
}
175207
links := attach()
176208
if len(links) == 0 {
209+
t.cacheNegativeUprobe(key, size, mtime)
177210
return UprobeKey{}, false
178211
}
179212
t.globalUprobes[key] = &globalUprobe{links: links, refcount: 1}
180213
return key, true
181214
}
182215

216+
// cacheNegativeUprobe remembers that key has nothing to instrument. Expired
217+
// entries are swept here, but at most once per TTL (negativeSweepAt), so a burst
218+
// of distinct binaries does not turn every insert into a full-map scan under the
219+
// lock. Stale entries are also dropped lazily on lookup in AcquireGlobalUprobe.
220+
// The caller must hold globalUprobesLock.
221+
func (t *Tracer) cacheNegativeUprobe(key UprobeKey, size, mtime int64) {
222+
now := t.now()
223+
if now.Sub(t.negativeSweepAt) >= negativeUprobeCacheTTL {
224+
for k, neg := range t.negativeUprobes {
225+
if now.After(neg.expiresAt) {
226+
delete(t.negativeUprobes, k)
227+
}
228+
}
229+
t.negativeSweepAt = now
230+
}
231+
t.negativeUprobes[key] = negativeUprobe{size: size, mtime: mtime, expiresAt: now.Add(negativeUprobeCacheTTL)}
232+
}
233+
183234
func (t *Tracer) ReleaseGlobalUprobes(keys ...UprobeKey) {
184235
t.globalUprobesLock.Lock()
185236
defer t.globalUprobesLock.Unlock()

ebpftracer/tracer_cache_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package ebpftracer
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strconv"
7+
"sync"
8+
"testing"
9+
"time"
10+
11+
"github.com/cilium/ebpf/link"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func newTestTracer() *Tracer {
17+
return &Tracer{
18+
globalUprobes: map[UprobeKey]*globalUprobe{},
19+
negativeUprobes: map[UprobeKey]negativeUprobe{},
20+
now: time.Now,
21+
}
22+
}
23+
24+
func writeFile(t *testing.T, content string) string {
25+
t.Helper()
26+
p := filepath.Join(t.TempDir(), "bin")
27+
require.NoError(t, os.WriteFile(p, []byte(content), 0755))
28+
return p
29+
}
30+
31+
// A binary with nothing to instrument must be parsed once and then served from
32+
// the cache, instead of being re-opened for every process that runs it.
33+
func TestAcquireGlobalUprobeCachesEmptyResult(t *testing.T) {
34+
tr := newTestTracer()
35+
path := writeFile(t, "binary")
36+
37+
calls := 0
38+
attach := func() []link.Link { calls++; return nil }
39+
40+
for i := 0; i < 5; i++ {
41+
key, ok := tr.AcquireGlobalUprobe(path, attach)
42+
assert.False(t, ok)
43+
assert.Equal(t, UprobeKey{}, key)
44+
}
45+
assert.Equal(t, 1, calls, "attach must be called only once for an unchanged binary")
46+
}
47+
48+
// A replaced binary (different size/mtime) must be evaluated again rather than
49+
// reusing a stale negative result.
50+
func TestAcquireGlobalUprobeReevaluatesReplacedBinary(t *testing.T) {
51+
tr := newTestTracer()
52+
path := writeFile(t, "v1")
53+
54+
calls := 0
55+
attach := func() []link.Link { calls++; return nil }
56+
57+
_, ok := tr.AcquireGlobalUprobe(path, attach)
58+
require.False(t, ok)
59+
require.Equal(t, 1, calls)
60+
61+
require.NoError(t, os.WriteFile(path, []byte("v2-longer"), 0755))
62+
_, ok = tr.AcquireGlobalUprobe(path, attach)
63+
require.False(t, ok)
64+
assert.Equal(t, 2, calls, "a replaced binary must be re-evaluated")
65+
}
66+
67+
// A binary repeatedly overwritten in place keeps the same inode, so the negative
68+
// cache must hold exactly one entry, not one per spawn.
69+
func TestNegativeCacheDoesNotGrowOnInPlaceOverwrite(t *testing.T) {
70+
tr := newTestTracer()
71+
path := writeFile(t, "v1")
72+
attach := func() []link.Link { return nil }
73+
74+
for i := 0; i < 20; i++ {
75+
require.NoError(t, os.WriteFile(path, []byte("v"+strconv.Itoa(i)), 0755))
76+
_, ok := tr.AcquireGlobalUprobe(path, attach)
77+
require.False(t, ok)
78+
}
79+
assert.Len(t, tr.negativeUprobes, 1, "in-place overwrite must reuse one inode-keyed slot")
80+
}
81+
82+
// An expired negative entry must be re-evaluated and replaced, not duplicated.
83+
// Expiry is driven by an injected clock rather than by poking internals.
84+
func TestAcquireGlobalUprobeExpiresEmptyResult(t *testing.T) {
85+
tr := newTestTracer()
86+
clock := time.Unix(0, 0)
87+
tr.now = func() time.Time { return clock }
88+
path := writeFile(t, "binary")
89+
90+
calls := 0
91+
attach := func() []link.Link { calls++; return nil }
92+
93+
_, ok := tr.AcquireGlobalUprobe(path, attach)
94+
require.False(t, ok)
95+
require.Equal(t, 1, calls)
96+
require.Len(t, tr.negativeUprobes, 1)
97+
98+
clock = clock.Add(negativeUprobeCacheTTL + time.Second)
99+
_, ok = tr.AcquireGlobalUprobe(path, attach)
100+
require.False(t, ok)
101+
assert.Equal(t, 2, calls, "an expired negative entry must be re-evaluated")
102+
assert.Len(t, tr.negativeUprobes, 1, "the stale entry must be replaced, not duplicated")
103+
}
104+
105+
// Expired negative entries must not accumulate; they are swept when a new
106+
// negative entry is added (at most once per TTL).
107+
func TestNegativeCacheSweepsExpiredEntries(t *testing.T) {
108+
tr := newTestTracer()
109+
clock := time.Unix(0, 0)
110+
tr.now = func() time.Time { return clock }
111+
112+
for i := 0; i < 10; i++ {
113+
tr.negativeUprobes[UprobeKey{Ino: uint64(i + 1)}] = negativeUprobe{
114+
expiresAt: clock.Add(time.Minute),
115+
}
116+
}
117+
// A live (refcounted) entry must be untouched by the negative-cache sweep.
118+
tr.globalUprobes[UprobeKey{Ino: 100}] = &globalUprobe{refcount: 1}
119+
120+
clock = clock.Add(negativeUprobeCacheTTL + time.Minute)
121+
_, ok := tr.AcquireGlobalUprobe(writeFile(t, "binary"), func() []link.Link { return nil })
122+
require.False(t, ok)
123+
124+
assert.Len(t, tr.negativeUprobes, 1, "expired negative entries must be swept; only the fresh one remains")
125+
require.Len(t, tr.globalUprobes, 1, "live entries must be kept")
126+
_, live := tr.globalUprobes[UprobeKey{Ino: 100}]
127+
assert.True(t, live)
128+
}
129+
130+
// The race detector must stay clean under concurrent acquisition of the same
131+
// binary, and the result must be a single negative entry.
132+
func TestNegativeCacheConcurrent(t *testing.T) {
133+
tr := newTestTracer()
134+
path := writeFile(t, "binary")
135+
attach := func() []link.Link { return nil }
136+
137+
var wg sync.WaitGroup
138+
for i := 0; i < 50; i++ {
139+
wg.Add(1)
140+
go func() {
141+
defer wg.Done()
142+
tr.AcquireGlobalUprobe(path, attach)
143+
}()
144+
}
145+
wg.Wait()
146+
assert.Len(t, tr.negativeUprobes, 1)
147+
}

0 commit comments

Comments
 (0)