Skip to content

Commit 8fa8124

Browse files
committed
probes: BPF uprobe service that ships fires through the log interface
Add a probes package that: - parses a YAML config of (symbol, file_match regex) pairs and assigns a 1-based spec_id per entry; - loads an embedded BPF uprobe program (probe.bpf.amd64, built by `make probes-bpf`) that emits one ringbuf record per fire carrying ktime/pid/tid/comm/spec_id; - on each newly-observed executable, regex-matches its path and attaches an exec.Uprobe per matching spec, encoding the spec_id in the uprobe cookie; - drains the ringbuf in a goroutine and forwards each event as a reporter.LogEvent (Body=symbol, attrs=pid/tid/comm/spec_id) via reporter.ParcaReporter.ReportLogEvents. The BPF service no longer owns the Arrow log stream — that lives in the reporter package now. Reporter integration is via two small additions: a ProbesHook interface (OnExecutable) plus a SetProbes setter on arrowReporter so ReportExecutable can notify the BPF service to attach to fresh binaries. Wires the existing --probe-config flag through main.go: when set, the service is started with the parca reporter; offline mode is rejected since log streaming needs a gRPC conn.
1 parent 50deab9 commit 8fa8124

16 files changed

Lines changed: 1040 additions & 11 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
/out
77
/bin
88
*.bpf.o
9+
probes/bpf/*.amd64
10+
probes/bpf/*.arm64
11+
probes/bpf/*.ll
912
TODO.md
1013
minikube-*
1114
/data

Makefile

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1-
.PHONY: all crossbuild build build-debug snap
1+
.PHONY: all crossbuild build build-debug snap probes-bpf
2+
3+
GOARCH ?= $(shell go env GOARCH)
4+
PROBES_BPF_OBJ := probes/bpf/probe.bpf.$(GOARCH)
5+
CLANG ?= clang
26

37
all: crossbuild
48

9+
probes-bpf: $(PROBES_BPF_OBJ)
10+
11+
# Compiles the simple-probes-v1 uprobe program. Requires clang with the bpf
12+
# target and libbpf headers (libbpf-dev / libbpf-devel).
13+
$(PROBES_BPF_OBJ): probes/bpf/probe.bpf.c
14+
$(CLANG) -O2 -g -target bpf \
15+
-D__TARGET_ARCH_$(GOARCH) \
16+
-Wall -Werror \
17+
-c $< -o $@
18+
519
crossbuild:
620
DOCKER_CLI_EXPERIMENTAL="enabled" docker run \
721
--rm \
@@ -13,10 +27,10 @@ crossbuild:
1327
docker.io/goreleaser/goreleaser-cross:v1.22.4 \
1428
release --snapshot --clean --skip=publish --verbose
1529

16-
build:
30+
build: probes-bpf
1731
go build -o parca-agent -buildvcs=false -ldflags="-extldflags=-static" -tags osusergo,netgo
1832

19-
build-debug:
33+
build-debug: probes-bpf
2034
go build -o parca-agent-debug -buildvcs=false -ldflags="-extldflags=-static" -tags osusergo,netgo -gcflags "all=-N -l"
2135

2236
snap: crossbuild

flags/flags.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ type Flags struct {
164164
MergeGpuProfiles bool `default:"false" help:"Report GPU kernel timing and GPU PC sampling under a single gpu_time/nanoseconds sample_type, differentiated by a gpu_view label (pc_sample|kernel_time). When false (the default), they are reported as separate sample_types (gpu_kernel_time/nanoseconds and gpu_pcsample/count) with no per-sample labels."`
165165

166166
OTLPLogging bool `default:"false" help:"Forward parca-agent's own logrus output to the remote-store as OTLP log records (in addition to local stderr). Requires a remote-store; ignored in offline mode."`
167+
168+
ProbeConfig string `default:"" help:"Path to a YAML file declaring uprobe attachments. When set, parca-agent attaches a uprobe per matching binary and streams probe-fire events to the configured remote-store as OTLP/Arrow logs. Empty disables the feature."`
167169
}
168170

169171
type ExitCode int

main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import (
6161
"github.com/parca-dev/parca-agent/flags"
6262
"github.com/parca-dev/parca-agent/oom"
6363
"github.com/parca-dev/parca-agent/parcagpu"
64+
"github.com/parca-dev/parca-agent/probes"
6465
"github.com/parca-dev/parca-agent/reporter"
6566
"github.com/parca-dev/parca-agent/uploader"
6667
)
@@ -443,6 +444,24 @@ func mainWithExitCode() flags.ExitCode {
443444
}
444445
}
445446

447+
if f.ProbeConfig != "" {
448+
if grpcConn == nil {
449+
return flags.Failure("--probe-config requires a remote-store; cannot be used in offline mode")
450+
}
451+
probesSvc, err := probes.Start(mainCtx, probes.StartConfig{
452+
ConfigPath: f.ProbeConfig,
453+
}, parcaReporter)
454+
if err != nil {
455+
return flags.Failure("Failed to start probes: %v", err)
456+
}
457+
parcaReporter.SetProbes(probesSvc)
458+
defer func() {
459+
if err := probesSvc.Close(); err != nil {
460+
log.Warnf("probes: close: %v", err)
461+
}
462+
}()
463+
}
464+
446465
includeEnvVars := libpf.Set[string]{}
447466
if len(f.IncludeEnvVar) > 0 {
448467
for _, env := range f.IncludeEnvVar {

probes/attach.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//go:build linux
2+
3+
package probes
4+
5+
import (
6+
"context"
7+
"sync"
8+
9+
"github.com/cilium/ebpf"
10+
"github.com/cilium/ebpf/link"
11+
log "github.com/sirupsen/logrus"
12+
"go.opentelemetry.io/ebpf-profiler/libpf"
13+
)
14+
15+
// attachReq is enqueued by OnExecutable for the worker goroutine to process.
16+
type attachReq struct {
17+
filePath string
18+
fileID libpf.FileID
19+
specs []ProbeSpec
20+
}
21+
22+
// attacher owns the state for matching new binaries against the spec list and
23+
// attaching paired entry/exit uprobes to them on a worker goroutine. The
24+
// hot-path callback only holds the mutex long enough to dedupe by FileID and
25+
// run regexes.
26+
type attacher struct {
27+
progEntry *ebpf.Program
28+
progExit *ebpf.Program
29+
specs []ProbeSpec
30+
31+
mu sync.Mutex
32+
attached map[libpf.FileID]struct{}
33+
links map[libpf.FileID][]link.Link
34+
35+
queue chan attachReq
36+
}
37+
38+
func newAttacher(progEntry, progExit *ebpf.Program, specs []ProbeSpec, queueDepth int) *attacher {
39+
return &attacher{
40+
progEntry: progEntry,
41+
progExit: progExit,
42+
specs: specs,
43+
attached: make(map[libpf.FileID]struct{}),
44+
links: make(map[libpf.FileID][]link.Link),
45+
queue: make(chan attachReq, queueDepth),
46+
}
47+
}
48+
49+
// OnExecutable is the cheap-path callback invoked from the otel ebpf-profiler
50+
// reporter goroutine. It must not block: dedupe, regex-match, and enqueue.
51+
// All disk I/O happens in attachWorker.
52+
func (a *attacher) OnExecutable(filePath string, fileID libpf.FileID) {
53+
a.mu.Lock()
54+
if _, seen := a.attached[fileID]; seen {
55+
a.mu.Unlock()
56+
return
57+
}
58+
a.attached[fileID] = struct{}{}
59+
a.mu.Unlock()
60+
61+
var matched []ProbeSpec
62+
for _, s := range a.specs {
63+
if s.FileMatchRE.MatchString(filePath) {
64+
matched = append(matched, s)
65+
}
66+
}
67+
if len(matched) == 0 {
68+
log.Debugf("probes: received %s (fileID=%s) — no spec matched", filePath, fileID.StringNoQuotes())
69+
return
70+
}
71+
log.Debugf("probes: received %s (fileID=%s) — %d spec(s) matched", filePath, fileID.StringNoQuotes(), len(matched))
72+
73+
select {
74+
case a.queue <- attachReq{filePath: filePath, fileID: fileID, specs: matched}:
75+
default:
76+
// Queue full: log and forget. We've already marked this fileID as
77+
// "seen" so we won't try again, which is fine for v1 — the user
78+
// can restart with a smaller probe-config or a deeper queue.
79+
log.Warnf("probes: attach queue full, dropping %s (fileID=%s)", filePath, fileID.StringNoQuotes())
80+
}
81+
}
82+
83+
// run is the attachWorker goroutine. Returns when the queue is drained after
84+
// ctx is cancelled.
85+
func (a *attacher) run(ctx context.Context) {
86+
for {
87+
select {
88+
case <-ctx.Done():
89+
return
90+
case req := <-a.queue:
91+
a.handle(req)
92+
}
93+
}
94+
}
95+
96+
func (a *attacher) handle(req attachReq) {
97+
ex, err := link.OpenExecutable(req.filePath)
98+
if err != nil {
99+
log.Warnf("probes: open executable %s: %v", req.filePath, err)
100+
return
101+
}
102+
103+
var newLinks []link.Link
104+
for _, s := range req.specs {
105+
cookie := s.Cookie()
106+
107+
entryLink, err := ex.Uprobe(s.EntrySymbol, a.progEntry, &link.UprobeOptions{
108+
Cookie: cookie,
109+
PID: 0,
110+
})
111+
if err != nil {
112+
log.Warnf("probes: attach entry %s @ %s: %v", s.EntrySymbol, req.filePath, err)
113+
continue
114+
}
115+
exitLink, err := ex.Uprobe(s.ExitSymbol, a.progExit, &link.UprobeOptions{
116+
Cookie: cookie,
117+
PID: 0,
118+
})
119+
if err != nil {
120+
log.Warnf("probes: attach exit %s @ %s: %v", s.ExitSymbol, req.filePath, err)
121+
// Roll back the entry uprobe so we don't have orphan stack pushes
122+
// with no exit to drain them.
123+
if cerr := entryLink.Close(); cerr != nil {
124+
log.Warnf("probes: rollback entry %s: %v", s.EntrySymbol, cerr)
125+
}
126+
continue
127+
}
128+
newLinks = append(newLinks, entryLink, exitLink)
129+
log.Debugf("probes: attached pair %s/%s @ %s (spec_id=%d, id=%s)",
130+
s.EntrySymbol, s.ExitSymbol, req.filePath, s.SpecID, s.ID)
131+
}
132+
if len(newLinks) == 0 {
133+
return
134+
}
135+
136+
a.mu.Lock()
137+
a.links[req.fileID] = append(a.links[req.fileID], newLinks...)
138+
a.mu.Unlock()
139+
}
140+
141+
// closeAllLinks tears down every attached uprobe link. Called from
142+
// service.Close.
143+
func (a *attacher) closeAllLinks() {
144+
a.mu.Lock()
145+
defer a.mu.Unlock()
146+
for fid, links := range a.links {
147+
for _, l := range links {
148+
if err := l.Close(); err != nil {
149+
log.Warnf("probes: close link for fileID=%s: %v", fid.StringNoQuotes(), err)
150+
}
151+
}
152+
}
153+
a.links = nil
154+
}

probes/bpf/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# probes BPF
2+
3+
`probe.bpf.c` is the kernel-side uprobe program. The compiled outputs
4+
`probe.bpf.amd64` and `probe.bpf.arm64` are produced by `make probes-bpf`
5+
in the parca-agent repository root and are git-ignored.
6+
7+
This README is committed so `//go:embed bpf` in `../loader.go` always has
8+
a valid embed target even before the BPF object is compiled.
9+
10+
Build dependencies: clang (>= 14, with the bpf target) and the libbpf
11+
headers (`bpf/bpf_helpers.h` etc., from libbpf-dev / libbpf-devel).

0 commit comments

Comments
 (0)