← All blogs
ebpf · observability · go · distributed-systems

eBPF-Powered Request Tracing in Go Microservices Without Instrumentation Tax

How eBPF uprobes and ring buffers replace manual trace propagation in Go services—mechanics, tradeoffs, and failure modes.

eBPF-Powered Request Tracing in Go Microservices Without Instrumentation Tax

Manual OpenTelemetry instrumentation in Go microservices carries a compounding cost: every SDK call site, every context propagation branch, every baggage extraction is code that can drift, be omitted in a hot path, or impose measurable CPU overhead at high RPS. The alternative that has become operationally viable in 2025–2026 is attaching eBPF uprobes directly to Go runtime symbols and HTTP/gRPC library entry points to reconstruct distributed traces from kernel and user-space events—no code change required in the target binary.

This article examines the mechanics of that approach, where it breaks, and the tradeoffs that determine whether it belongs in your production stack.

Why Go's Runtime Makes eBPF Tracing Non-Trivial

eBPF uprobes work by patching a breakpoint instruction at a specified offset in a running binary. When execution hits that offset, the kernel pauses the thread, runs the attached BPF program, and resumes. For C or Rust binaries this maps cleanly onto function prologues. Go introduces three complications.

Goroutine scheduling. Go's M:N scheduler multiplexes goroutines onto OS threads. A single HTTP request may be handled by goroutine G on thread M1 at the point an uprobe fires, then rescheduled to M2 before the response is written. A naive uprobe reading pthread_self() or the current PID/TID will lose continuity across that yield. The correct anchor is the goroutine ID (runtime.g struct field goid), which requires either a BTF-aware map keyed by goid extracted from the goroutine stack, or a fixed offset computation against the g pointer stored in thread-local storage register (FS on amd64, R28 on arm64).

Stack layout and calling convention. Go 1.17 introduced register-based calling conventions. Function arguments are now passed in registers (AX, BX, CX, DI, SI, R8–R11 on amd64) rather than on the stack. An uprobe BPF program written for Go 1.16 ABI that reads ctx from sp+8 will read garbage on 1.17+ binaries. eBPF-based tracers must either ship ABI-aware probe logic per Go minor version or use DWARF location expressions from the binary's debug info to compute the correct register or memory location at each probe site.

Inlining and dead-code elimination. The Go compiler aggressively inlines small functions. If http.(*Transport).roundTrip is partially inlined into a caller, the symbol may not exist at the address the tracer expects. Probing at the wrong offset produces missed spans or corrupted argument reads silently.

The Probe Architecture

A production-grade eBPF tracer for Go services typically combines three probe types:

  1. uprobes on net/http and google.golang.org/grpc entry/exit points to capture request start, method, URL/method name, and status code.
  2. uprobes on runtime.newproc1 (goroutine creation) and runtime.goexit to track goroutine lifecycle and correlate spans across async boundaries.
  3. uprobes on crypto/tls handshake functions for latency attribution in TLS-heavy services.

Data flows from user-space probes through a BPF ring buffer (preferred over perf buffers in kernels ≥5.8 for lower overhead and ordering guarantees) to a user-space consumer written in Go using cilium/ebpf.

// Simplified ring buffer consumer (user-space side)
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
    log.Fatalf("opening ring buffer: %v", err)
}
defer rd.Close()

for {
    record, err := rd.Read()
    if errors.Is(err, ringbuf.ErrClosed) {
        return
    }
    if err != nil {
        continue // transient read error; log and continue
    }
    var event HTTPEvent
    if err := binary.Read(bytes.NewReader(record.RawSample), binary.LittleEndian, &event); err != nil {
        continue
    }
    // Reconstruct span from goroutine ID, timestamps, and request metadata
    span := buildSpan(event)
    exporter.Export(span)
}

The HTTPEvent struct mirrors the C struct defined in the BPF program, with fields for goid, start_ns, end_ns, status_code, and a fixed-length URL byte array. Alignment padding must match exactly; a single byte of mismatch causes every field after the first to decode incorrectly.

Distributed Context Without W3C Headers

The most significant design tension: how do you propagate trace context across service boundaries if you cannot inject headers in application code?

Option A: Synthesize context from network identity. The eBPF program attaches a tc (traffic control) hook at the network interface layer and reads or writes W3C traceparent headers directly in packet data using BPF helper bpf_skb_store_bytes. This requires CAP_NET_ADMIN and works only for cleartext HTTP/1.1. TLS termination happens above the socket layer, so the BPF program sees encrypted bytes.

Option B: Sidecar context injection. Route all outbound calls through a local sidecar (Envoy, or a lightweight Go proxy) that holds a goroutine-ID-to-trace-context map populated by the eBPF consumer. The sidecar injects headers before forwarding. This reintroduces a network hop but keeps TLS intact.

Option C: Header interception via uprobe on http.Header.Set. Attach an uprobe to the net/http header-writing path and inject the traceparent value by patching the header map in memory using bpf_probe_write_user. This helper is explicitly marked as dangerous in the kernel—it can corrupt process memory—and is restricted to CONFIG_BPF_KPROBE_OVERRIDE builds. Most production distributions do not ship that config.

Option B is the only approach that is simultaneously TLS-compatible, safe, and widely deployable. Its latency cost (loopback RTT for sidecar injection) is typically under 100µs on modern hardware—acceptable for services where spans already represent multi-millisecond operations.

Failure Modes in Production

Goroutine ID reuse. Go recycles goroutine IDs. Under high concurrency a goid may be reused before the eBPF consumer has flushed its state map. The mitigation is evicting map entries aggressively (e.g., on any span export) and using (goid, start_ns) as the composite key rather than goid alone.

Binary upgrades without tracer restart. When the Go binary is replaced by a rolling deploy, symbol offsets change. Uprobes attached to the old binary's VMA are automatically removed by the kernel when the last reference to that mapping drops. The new binary starts untraced until the control plane reattaches probes. This creates a tracing gap during rollouts. A robust tracer watches inotify events on the binary path and reattaches within seconds, but that window still exists.

Kernel version constraints. Ring buffers require kernel ≥5.8. BTF-based CO-RE (Compile Once, Run Everywhere) requires ≥5.4 with CONFIG_DEBUG_INFO_BTF. On AWS, Amazon Linux 2023 ships 6.1 kernels; EKS node groups using AL2 may still be on 5.10. Validate your kernel matrix before adopting ring buffers or CO-RE probes.

Stripped binaries. Go binaries compiled with -ldflags "-s -w" remove symbol tables and DWARF. The tracer cannot resolve function names to offsets without symbols. The operational fix is to retain at minimum the symbol table (-ldflags "-w" only, omitting -s), which adds roughly 10–15% to binary size but preserves .symtab.

Performance Overhead: What the Numbers Actually Mean

eBPF uprobes are not free. Each uprobe fires a software breakpoint that traps into the kernel. At 50,000 RPS on a service with 4 probe sites per request, that is 200,000 kernel entries per second. Published kernel benchmarks place uprobe overhead at roughly 1–3µs per fire on modern x86 hardware, yielding a ceiling cost of ~200–600ms of CPU per second on a single core—real but manageable if the alternative is 10% of developer time maintaining instrumentation code.

The ring buffer consumer in Go should run on a dedicated goroutine pinned to a non-request-serving CPU (via runtime.LockOSThread and CPU affinity through unix.SchedSetaffinity) to prevent GC pressure from span allocation interfering with request handling.

Decision Framework

Adopt eBPF-based tracing in Go services when:

  • You operate a large service mesh where retroactive manual instrumentation across dozens of repositories is operationally infeasible.
  • You need tracing coverage for third-party or vendored Go binaries you cannot modify.
  • Your kernel baseline is ≥5.8 across all node types.
  • You can tolerate the binary symbol table requirement (no full stripping).
  • You have a team comfortable operating BPF programs—debugging a corrupt BPF map is significantly harder than debugging a misconfigured SDK.

Stay with manual OpenTelemetry instrumentation when:

  • You need business-level span attributes (user ID, tenant, feature flags) that only application code can supply—eBPF cannot synthesize semantic meaning from raw HTTP bytes.
  • Your Go version cadence outpaces your tracer's ABI compatibility table.
  • You run on kernels below 5.4 or on distributions without BTF support.
  • Your services handle sufficiently low RPS that per-request SDK overhead is negligible.

The production reality in 2026 is that eBPF and manual instrumentation are complementary, not substitutes. eBPF provides coverage and baseline latency attribution with zero developer friction. SDK instrumentation provides semantic richness. The highest-fidelity observability stacks run both, with the eBPF layer acting as a consistency check against spans the application layer drops under load.

eBPF-Powered Request Tracing in Go Microservices Without Instrumentation Tax | Neeraj Singhi