Skip to content

Retained ELF string table causes 100+ MiB heap growth per instrumented binary #2638

Description

@bourbonkk

⚠️ 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:
    1. 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.
    2. 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

  1. Deploy OBI v0.10.0 as DaemonSet with preset: application, internal_metrics.exporter: otel, profile_port: 6060.
  2. Ensure at least one Rust or C++ (Envoy) workload is running on one of the nodes.
  3. Wait for OBI to complete attacherLoop (a few minutes).
  4. curl http://<obi-pod>:6060/debug/pprof/heap | go tool pprof -top -inuse_space -.
  5. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions