⚠️ UPDATE 2026-07-13 — root cause re-diagnosed, see correction comment below
The original report below framed the issue as retention on Rust/C++/Envoy binaries, and attributed it to .strtab substring semantics. Both framings are now known to be inaccurate:
- Substring theory is wrong: Go stdlib
debug/elf.getString() returns via string([]byte) which always allocates a new backing array. The original .strtab slice is GC-eligible; substrings don't hold it.
- The heap the profiles captured is a mix of two independent causes:
- Retained heap on Go binaries — due to a missing
probe.Skip = true in pkg/ebpf/instrumenter.go:gatherGoOffsets, which causes link.Executable.Uprobe to be called with Address=0, forcing cilium's lazyLoadSymbols() to parse the full ELF and retain it on Executable.cachedSymbols for the tracer's lifetime.
- Transient parse cost when generic tracer's
resolveInstrPath falls back to the executable itself (Rust with debug info, Envoy static binary). This is GC-eligible after gatherOffsets returns, but pprof snapshots taken before the next GC show it in inuse-space.
The two clusters in the original report both had Go services co-located (flagd-build, product-catalog in Cluster A; flagd sidecars in Cluster B), so the retained portion of the observed heap was very likely from those, not from the Rust/Envoy processes named in the headline.
PR #2642 fixes cause (1) — retention on Go binaries. Cause (2) (transient parse cost on native-path exe fallback) remains open; it may warrant a separate issue.
The rest of this body is preserved for history but its root-cause section should be read alongside the correction comment.
Summary
When instrumenting large binaries (Rust, C++/Envoy, Erlang), OBI holds 100+ MiB of parsed ELF string table per unique instrumented binary in its resident heap. The retention persists after uprobe attach completes, and is not reclaimed by GC until all processes referencing that binary exit.
Root cause appears to be that symbol name strings — created via substring of the raw .strtab []byte — retain a reference to the full slice (Go semantics: string(b[i:j]) does not copy). The parsed link.(*Executable) (containing the symbol name map) is kept alive by existingTracers[Ino()] = tracer in pkg/appolly/discover/attacher.go, so the strtab buffer stays reachable through the substring references indefinitely.
Environment
- OBI version: v0.10.0 (
ghcr.io/open-telemetry/opentelemetry-ebpf-instrumentation/ebpf-instrument)
- Deployment: Kubernetes DaemonSet (2 clusters, ~7 nodes each)
- Preset:
application (HTTP/gRPC uprobes), network.enable: false
k8sCache.replicas: 0 (in-agent kube metadata)
- Kernel: Linux 5.15+
- Continuous profiling via Alloy
pyroscope.scrape on OBI's profile_port: 6060
Observation
Pyroscope heap inuse (memory:inuse_space) profiles taken from two heavy OBI DaemonSet pods on different clusters show the same dominant stack:
attacherLoop
└─ traceAttacher.getTracer
└─ ProcessTracer.NewExecutable
└─ cilium/ebpf/link.(*Executable).Uprobe
└─ (*Executable).address
└─ debug/elf.(*File).Symbols
└─ debug/elf.(*File).getSymbols64
└─ debug/elf.getString (120–143 MiB self)
Cluster A — single Rust binary
Node has one Rust service (shipping from the OpenTelemetry Demo) plus a few Erlang/Go processes. Heap flame graph:
| Symbol |
Self |
Total |
debug/elf.getString |
143 MiB |
143 MiB |
cilium/ebpf/link.(*Executable).load |
53.9 MiB |
166 MiB |
obi_instrumented_processes on that node: shipping (Rust), beam.smp, epmd, erl_child_setup, flagd-build, product-catalog.
Cluster B — two identical Envoy pods
Node runs frontend-proxy × 2 (Envoy, same image → same inode → correctly deduped to 1× tracer via existingTracers[Ino]), plus JVM/Go/Python/C daemon processes.
Total heap on this node ~350 MiB (baseline nodes without large binaries ~250 MiB). Heap breakdown:
| Symbol |
Self |
Total |
debug/elf.getString |
120 MiB |
120 MiB |
link.(*Executable).address.func1 (attach path) |
0 |
151 MiB |
ringBufForwarder.bgFlushOnTimeout |
0 |
128 MiB |
The debug/elf.getString figure is retained long after attach completed (steady state; not transient during startup).
Baseline comparison
Baseline pods (nodes with no Rust/C++/Envoy binaries — only Go/Python/JVM services) show:
procs.envStrsToMap — ~23 MiB
- k8s informer / cache — ~18 MiB
- no
elf.getString / getSymbols64 frames at all in inuse-space
Heap sits around ~250 MiB steady state, versus ~350 MiB on the heavy nodes.
Reproduction
- Deploy OBI v0.10.0 as DaemonSet with
preset: application, internal_metrics.exporter: otel, profile_port: 6060.
- Ensure at least one Rust or C++ (Envoy) workload is running on one of the nodes.
- Wait for OBI to complete
attacherLoop (a few minutes).
curl http://<obi-pod>:6060/debug/pprof/heap | go tool pprof -top -inuse_space -.
debug/elf.(*File).getSymbols64 / debug/elf.getString dominates the inuse-space profile.
Suggested fix
The parsed link.(*Executable) no longer needs the strtab byte slice once probe attachment is done — only the {function_name → address} mapping is used at runtime. Two possible directions:
Option 1: Detach symbol strings after attach
Copy the small set of retained symbol names to independent allocations so the underlying strtab slice becomes GC-eligible.
// After successful Uprobe attach in cilium/ebpf/link:
for name, sym := range e.symbols {
e.symbols[name] = symbol{
Name: strings.Clone(sym.Name),
Addr: sym.Addr,
}
}
// or, at OBI's side, after (*Executable).Uprobe returns,
// release the reference to `e` and keep only what OBI needs.
Option 2: Extract minimal {name, addr} set at parse time
Instead of storing *link.Executable (which transitively retains strtab), OBI's existingTracers could store a compact struct containing only the resolved probe attach points. Executable can be closed / dereferenced after NewExecutable returns.
Expected impact
- 100+ MiB → a few KiB per unique large-symbol binary.
- Reduces steady-state memory requirement for OBI DaemonSet on nodes hosting Rust/C++/Envoy workloads.
- Improves the effective density limit for OBI in production clusters where memory limits are set to accommodate this steady-state footprint.
Related issues
Happy to provide additional profiles, run experiments, or draft a PR if the proposed direction is acceptable.
Summary
When instrumenting large binaries (Rust, C++/Envoy, Erlang), OBI holds 100+ MiB of parsed ELF string table per unique instrumented binary in its resident heap. The retention persists after uprobe attach completes, and is not reclaimed by GC until all processes referencing that binary exit.
Root cause appears to be that symbol name strings — created via substring of the raw
.strtab[]byte— retain a reference to the full slice (Go semantics:string(b[i:j])does not copy). The parsedlink.(*Executable)(containing the symbol name map) is kept alive byexistingTracers[Ino()] = tracerinpkg/appolly/discover/attacher.go, so the strtab buffer stays reachable through the substring references indefinitely.Environment
ghcr.io/open-telemetry/opentelemetry-ebpf-instrumentation/ebpf-instrument)application(HTTP/gRPC uprobes),network.enable: falsek8sCache.replicas: 0(in-agent kube metadata)pyroscope.scrapeon OBI'sprofile_port: 6060Observation
Pyroscope heap inuse (
memory:inuse_space) profiles taken from two heavy OBI DaemonSet pods on different clusters show the same dominant stack:Cluster A — single Rust binary
Node has one Rust service (
shippingfrom the OpenTelemetry Demo) plus a few Erlang/Go processes. Heap flame graph:debug/elf.getStringcilium/ebpf/link.(*Executable).loadobi_instrumented_processeson that node:shipping(Rust),beam.smp,epmd,erl_child_setup,flagd-build,product-catalog.Cluster B — two identical Envoy pods
Node runs
frontend-proxy × 2(Envoy, same image → same inode → correctly deduped to 1× tracer viaexistingTracers[Ino]), plus JVM/Go/Python/C daemon processes.Total heap on this node ~350 MiB (baseline nodes without large binaries ~250 MiB). Heap breakdown:
debug/elf.getStringlink.(*Executable).address.func1(attach path)ringBufForwarder.bgFlushOnTimeoutThe
debug/elf.getStringfigure is retained long after attach completed (steady state; not transient during startup).Baseline comparison
Baseline pods (nodes with no Rust/C++/Envoy binaries — only Go/Python/JVM services) show:
procs.envStrsToMap— ~23 MiBelf.getString/getSymbols64frames at all in inuse-spaceHeap sits around ~250 MiB steady state, versus ~350 MiB on the heavy nodes.
Reproduction
preset: application,internal_metrics.exporter: otel,profile_port: 6060.attacherLoop(a few minutes).curl http://<obi-pod>:6060/debug/pprof/heap | go tool pprof -top -inuse_space -.debug/elf.(*File).getSymbols64/debug/elf.getStringdominates the inuse-space profile.Suggested fix
The parsed
link.(*Executable)no longer needs the strtab byte slice once probe attachment is done — only the{function_name → address}mapping is used at runtime. Two possible directions:Option 1: Detach symbol strings after attach
Copy the small set of retained symbol names to independent allocations so the underlying strtab slice becomes GC-eligible.
Option 2: Extract minimal {name, addr} set at parse time
Instead of storing
*link.Executable(which transitively retains strtab), OBI'sexistingTracerscould store a compact struct containing only the resolved probe attach points.Executablecan be closed / dereferenced afterNewExecutablereturns.Expected impact
Related issues
existingTracers[Ino]dedup path we reference here.Happy to provide additional profiles, run experiments, or draft a PR if the proposed direction is acceptable.