eBPF Observability: What Kernel-Level Telemetry Actually Buys You (and the Toil it Costs)
The marketing for eBPF often frames it as "magic" — a way to get total system visibility with zero overhead and no application changes. This is a lie. eBPF is a powerful, restricted virtual machine that runs inside the kernel, and like any VM, it comes with a steep operational tax. To use it for observability, you aren't just "turning on a feature"; you are writing kernel-level code that must pass a rigorous static analysis check (the verifier) before it is allowed to execute.
This guide strips away the hype and looks at the actual mechanics: how a probe becomes a metric, why the verifier rejects your code with an opaque EINVAL, and why a "zero-overhead" probe can still crush your CPU if you use the wrong map type.
The BPF Machine: Lifecycle of a Probe
An eBPF observability pipeline is a bridge between a kernel event and a user-space dashboard. The process follows a strict, unidirectional path:
[ KERNEL SPACE ] [ USER SPACE ]
(Event Trigger) (Agent Logic)
|
v ^
[ eBPF Program ] -- (write) --> [ BPF Map / RingBuf ] -- (poll) --|
^ |
|
[ Verifier / JIT ] v
(Static Analysis) [ Prometheus / Loki ]
(Aggregation/Storage)
The critical architectural constraints are:
- Non-transitive execution: The kernel program cannot "call" user-space. It can only leave data in a map (the shared memory) and signal the user-space agent to come and get it.
- The Verifier Gate: Before the program is JIT-compiled to native machine code, the verifier simulates every possible execution path. If it cannot prove that the program terminates and never accesses invalid memory, it is rejected.
- Context-Awareness: Probes are attached to specific hooks:
kprobes(kernel functions),uprobes(user-space functions),tracepoints(static kernel hooks), andXDP(network packets).
The Verifier's Wall: Path Explosion and Rejections
The most frustrating part of eBPF development is the verifier. It doesn't run your code; it performs static analysis. The limit isn't the number of instructions in your file, but the number of processed instructions across all possible execution paths.
When you see processed 1,000,000 insns (limit 1,000,000), you've hit Path Explosion. This happens when a program has many branches or loops that the verifier must explore independently. A 500-line program with deep nesting can be rejected while a 5,000-line linear program passes.
| Verifier Error | What's Actually Happening | The Fix |
|---|---|---|
invalid mem access 'map_value_or_null' | You tried to read from a map without checking if the key existed. | Add if (!ptr) return 0; immediately after the map lookup. |
infinite loop detected | The verifier cannot prove the loop terminates. | Use a constant bound for the loop or the bpf_loop() helper (v5.17+). |
invalid indirect read from stack | You're reading a stack variable before writing to it (information leak). | Initialize the buffer with __builtin_memset(). |
R1 type=... expected=... | You're passing the wrong pointer type (e.g., passing a stack pointer to a map helper). | Cast to the expected type or use the correct BPF helper. |
Data Planes: Ring Buffers vs. Perf Events
Getting data out of the kernel is the primary bottleneck. You have two main choices for event streaming:
- Perf Event Arrays (The Old Way): Per-CPU buffers. The user-space agent must poll every CPU's buffer separately. This leads to "out-of-order" events and higher overhead as the number of cores grows.
- Ring Buffers (The Modern Way - v5.8+): A single, shared ring buffer for all CPUs. It provides strict FIFO ordering and is significantly more efficient for high-volume telemetry.
For stateful observability (e.g., counting requests per IP), you use BPF Maps. The trade-off is between BPF_MAP_TYPE_HASH (sparse keys, slower) and BPF_MAP_TYPE_ARRAY (fixed index, faster). In production, Per-CPU Maps are mandatory for high-throughput counters to avoid the "atomic-update bottleneck" where multiple cores fight over a single lock.
Production Blueprint: a Simple Request-Latency Probe
This is a minimal, production-ready pattern for tracing a function's latency. It uses a kprobe to start a timer and a kretprobe to calculate the delta and push it to a ring buffer.
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
struct event_t { __uint128_t start; __uint128_t delta; unsigned int pid; };
// Define the RingBuffer for event streaming
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
// Per-CPU map to store start times for each PID
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1024 * 16);
__type(key, __u32);
__type(value, __u64);
} start_times SEC(".maps");
SEC("kprobe/sys_clone")
int BPF_KPROBE(sys_clone)
{
__u32 pid = bpf_get_current_pid_tgid();
__u64 ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start_times, &pid, &ts, BPF_ANY);
return 0;
}
SEC("kretprobe/sys_clone")
int BPF_KRETPROBE(sys_clone)
{
__u32 pid = bpf_get_current_pid_tgid();
__u64 *start = bpf_map_lookup_elem(&start_times, &pid);
if (!start) return 0; // Verifier requires this NULL check
__u64 delta = bpf_ktime_get_ns() - *start;
struct event_t *e = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0);
if (!e) return 0;
e->start = *start;
e->delta = delta;
e->pid = pid;
bpf_ringbuf_submit(e, 0);
bpf_map_delete_elem(&start_times, &pid);
return 0;
}
BCC.kprobe_sys_clone = 1; // metadata for loader
package main
import (
"log"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/ringbuf"
)
func main() {
// 1. Load the pre-compiled BPF bytecode
spec, err := ebpf.LoadCollectionSpec("probe.o")
if err != nil { log.Fatal(err) }
coll, err := ebpf.NewCollection(spec)
if err != nil { log.Fatal(err) }
defer coll.Close()
// 2. Attach to the kernel hooks
link, err := link.Kprobe("sys_clone", coll.Programs["sys_clone"], nil)
if err != nil { log.Fatal(err) }
defer link.Close()
// 3. Poll the RingBuffer for events
rd, err := ringbuf.NewReader(coll.Maps["rb"].// cast to ringbuf)
if err != nil { log.Fatal(err) }
for {
event, err := rd.Read()
if err != nil { continue }
log.Printf("PID %d latency: %d ns", event.Pid, event.Delta)
}
}
Production Realities: The Cost of Visibility
The "zero-overhead" claim is based on the fact that an idle probe costs nothing. But a hot probe is a different story.
- The Observer Effect: If you attach a
kprobeto a function that is called 1 million times per second (e.g.,tcp_v4_rcv), the overhead of the BPF program execution, map updates, and ring-buffer submissions will manifest as a measurable increase in CPU usage. In some cases, this can slow down the very system you are trying to measure by 5–15%. - The Kernel Fragmentation Tax: eBPF is highly dependent on the kernel version. A program that works on Ubuntu 22.04 (Kernel 5.15) might fail on an older RHEL 7 node due to missing helpers or a different ABI. The
BTF (BPF Type Format)solved this for the most part, but you still needCONFIG_DEBUG_INFO_BTF=yin the kernel config. - The Verifier Wall as a Developer Bottleneck: eBPF development is not like standard Go or Python. You cannot just "try it." Every change requires a full re-verification. For complex logic, you will spend 80% of your time fighting the verifier and 20% writing the actual logic.
Who Should Skip eBPF Observability
Despite its power, eBPF is not the right tool for everyone. You should skip it if:
- You lack Kernel Control: If you are on a highly restrictive managed environment (some old Fargate/Lambda versions) where
bpf()syscalls are blocked, eBPF is a non-starter. - You can solve it with a Sidecar: If you only need L7 HTTP metrics and already have a service mesh (Istio/Linkerd), the sidecar proxy's metrics are "good enough" and carry zero kernel risk.
- You are terrified of
CAP_SYS_ADMIN: Until recently, eBPF required root/sys-admin privileges. WhileCAP_BPFnow exists to narrow this, the security surface area of loading code into the kernel is inherently higher than loading a user-space agent. - Your scale is small: If you have 10 nodes and 50 pods, the operational toil of managing BPF programs and kernel versions outweighs the benefits. Use standard Prometheus exporters.