eBPF for Observability: Writing Your First Program
For most of Linux’s history, instrumenting the kernel meant two bad choices: add printk statements and recompile, or use SystemTap/DTrace-style tools that required kernel modules with no safety guarantees and a good chance of crashing your machine if you got something wrong. Neither was something you did casually on a production box.
eBPF changed that. It is a technology built into the Linux kernel — no modules, no recompilation — that lets you attach small, verified programs to hundreds of kernel and userspace hooks. The programs run at JIT-compiled native speed. The verifier statically checks them before they run and rejects anything that could crash the kernel or loop forever. You can attach a tracer to a live production system, collect the data you need, and detach without ever restarting a process or risking stability.
This post starts with how eBPF works internally (the parts that matter for using it effectively), moves through bpftrace as the fastest path to immediate insight, then covers writing a real program in C with libbpf, and finishes with the production tools — Cilium, Pixie, Parca — that have made eBPF the foundation of modern cloud-native observability.
What eBPF Is, Precisely
The name is a historical artifact: “extended Berkeley Packet Filter.” The original BPF was a packet filter mechanism from 1992. The “extended” version, introduced in Linux 3.18 (2014) and substantially expanded through 4.x and 5.x, is a general-purpose in-kernel execution engine with almost nothing in common with its predecessor beyond the name.
An eBPF program is a small chunk of bytecode — a restricted instruction set with 64-bit registers — that you load into the kernel via the bpf(2) system call. The kernel runs it through a verifier and a JIT compiler, then attaches it to a hook point. When that hook fires (a syscall, a kernel function entry, a network packet arriving), your program runs in kernel context.
The key properties:
- Verified safety: The verifier performs static analysis before the program runs. It checks that all memory accesses are bounds-checked, there are no null pointer dereferences, all branches eventually terminate (bounded loops only), and the program does not call arbitrary kernel functions (only a curated set of BPF helper functions). A program that fails verification is rejected — it never runs.
- JIT compiled: After verification, the bytecode is compiled to native machine code. There is no interpreter overhead. An eBPF program runs as fast as equivalent kernel code.
- No kernel recompilation: Programs are loaded at runtime into the running kernel. You can attach and detach them without rebooting, without kernel modules, without touching any running process.
- Data sharing via maps: eBPF programs communicate with userspace through BPF maps — typed, kernel-managed data structures (hash tables, arrays, ring buffers, LRU caches) that both kernel-side eBPF code and userspace programs can read and write.
Userspace Kernel
─────────────────────────────────────────────────────
bpf() syscall
Your program ──────────────────────► Verifier
│ static analysis
│ safety checks
▼
JIT compiler
│ bytecode → native
▼
Attach to hook
┌──────────────────────┐
│ Hook fires (event) │
│ eBPF program runs │
│ Writes to BPF map │
└──────────────────────┘
Your program ◄────────────────────── Read BPF map
mmap / read
Attachment Points: Where Programs Run
eBPF programs attach to hooks. The hook determines what data is available (which kernel structs you can read), what actions you can take, and what privileges are required. For observability work, the relevant categories are:
| Type | Attaches to | Stability | Use case |
|---|---|---|---|
tracepoint |
Static kernel trace events | Stable ABI | Preferred for production tracing |
kprobe |
Any kernel function entry | Unstable (fn names change) | Flexible, use when no tracepoint exists |
kretprobe |
Any kernel function return | Unstable | Capture return values, latency |
uprobe |
Userspace function entry | Stable per binary | Trace Go/Python/JVM without instrumentation |
perf_event |
Hardware/software perf events | Stable | CPU profiling, cache misses |
XDP |
NIC driver, before netstack | Stable | High-performance packet processing |
tc (traffic control) |
Network stack ingress/egress | Stable | Packet mangling, load balancing |
socket_filter |
Socket-level packet filtering | Stable | Per-socket filtering |
cgroup |
cgroup attach points | Stable | Container-level policy enforcement |
LSM |
Linux Security Module hooks | Stable | Security policy enforcement |
For observability, you will almost always start with tracepoint (stable ABI, kernel-guaranteed arguments) and fall back to kprobe when the tracepoint does not exist or does not expose the data you need. perf_event at 99 Hz is the standard approach for CPU flame graphs.
bpftrace: Immediate Insight Without Writing C
bpftrace is a high-level tracing language built on eBPF. Its syntax resembles awk: probe /filter/ { action }. It compiles the script to eBPF bytecode, loads it, and prints output — all in one command. It is the right tool for ad hoc investigation, one-off profiling, and learning the eBPF model before writing programs in C.
Install it:
|
|
Essential one-liners
|
|
The @ prefix creates a BPF map. @[key] = count() is a frequency counter keyed by the expression in brackets. hist() produces a log-scale histogram. All maps are printed at exit (or Ctrl-C). This is the bpftrace mental model: attach hooks that write to maps, read the maps.
Writing a short script
One-liners get unwieldy for multi-probe analysis. A script file:
#!/usr/bin/env bpftrace
// Track slow disk reads: reads that take longer than 10ms
tracepoint:block:block_rq_issue /args->rwbs == "R"/ {
@issue_time[args->dev, args->sector] = nsecs;
}
tracepoint:block:block_rq_complete {
$key_dev = args->dev;
$key_sec = args->sector;
if (@issue_time[$key_dev, $key_sec]) {
$latency_ms = (nsecs - @issue_time[$key_dev, $key_sec]) / 1000000;
if ($latency_ms > 10) {
printf("SLOW READ: dev=%d latency=%dms bytes=%d\n",
args->dev, $latency_ms, args->nr_sector * 512);
}
delete(@issue_time[$key_dev, $key_sec]);
}
}
END { clear(@issue_time); }
|
|
Writing a Real Program: libbpf and C
bpftrace is great for investigation. For a persistent tracer, a daemon that records metrics, or anything that needs to process data in userspace beyond what a bpftrace script can do, you write the eBPF program in C and load it with libbpf.
The modern approach is CO-RE (Compile Once, Run Everywhere). Instead of compiling against your specific kernel’s header files (which means your binary only works on that kernel version), you compile against a vmlinux.h generated from BTF (BPF Type Format) data. BTF encodes kernel struct layouts. At load time, libbpf reads the running kernel’s BTF and patches any struct field offsets that changed between the kernel you compiled on and the kernel you’re running on. One binary, many kernel versions.
Setup
|
|
The eBPF C program (kernel side)
This tracer records every execve() call with the process name, PID, and the command being executed — useful for tracking what processes are spawning on a busy system:
|
|
The userspace loader (C)
|
|
Build
|
|
|
|
Output:
PID PPID PARENT EXEC
--- ---- ------ ----
184321 184320 bash /usr/bin/ls
184322 1 systemd /usr/lib/postfix/sbin/pickup
184325 184321 ls /usr/bin/grep
This runs with zero impact on the processes being traced. There is no ptrace, no LD_PRELOAD, no signal sent to any process. The kernel fires the eBPF program at the tracepoint hook and your data appears in the ring buffer.
Key BPF Map Types
Maps are how eBPF programs store state and communicate with userspace. Choosing the right map type matters for both correctness and performance:
| Map type | Structure | Best for |
|---|---|---|
BPF_MAP_TYPE_HASH |
Key-value hash table | Per-{pid,tid,fd} state tracking |
BPF_MAP_TYPE_ARRAY |
Fixed-size indexed array | Per-CPU counters, configuration |
BPF_MAP_TYPE_PERCPU_HASH |
Per-CPU hash table | High-frequency updates without locking |
BPF_MAP_TYPE_RINGBUF |
Lock-free ring buffer | Event streaming to userspace (preferred) |
BPF_MAP_TYPE_PERF_EVENT_ARRAY |
perf buffer array | Legacy event streaming (use ringbuf instead) |
BPF_MAP_TYPE_LRU_HASH |
Hash with LRU eviction | Fixed-memory connection tracking |
BPF_MAP_TYPE_STACK_TRACE |
Stack trace storage | Profiling, flame graphs |
BPF_MAP_TYPE_LPM_TRIE |
Longest-prefix-match trie | CIDR-based network policy |
For event streaming, BPF_MAP_TYPE_RINGBUF is the right default in kernels 5.8+. It is more memory-efficient than PERF_EVENT_ARRAY (one buffer shared across all CPUs rather than one per CPU), preserves event ordering, and supports reserve/commit/discard patterns that allow writing complex events without intermediate copies.
Production Tools Built on eBPF
Writing eBPF programs in C is something you do when you have a specific measurement need that no existing tool covers. For common observability tasks, purpose-built tools handle the complexity for you.
Cilium and Hubble
Cilium replaces kube-proxy and iptables-based networking in Kubernetes with eBPF. Every packet path — service load balancing, network policy enforcement, connection tracking — runs through eBPF programs attached to XDP and TC hooks. The performance consequence is significant: iptables at scale involves traversing long rule chains per packet; eBPF policies are constant-time regardless of how many rules are defined.
Hubble is Cilium’s observability layer. It instruments the same eBPF hooks to produce L3/L4/L7 flow records — every TCP connection, every HTTP request, every DNS query, attributed to source and destination pod. No application instrumentation required. No sidecar proxy.
|
|
Hubble’s network flow data is the fastest way to answer “why can’t pod A reach pod B” in a Kubernetes cluster — it shows you the exact packet flow and the policy verdict (FORWARDED/DROPPED) at every hop.
Pixie
Pixie is zero-instrumentation application observability for Kubernetes. It uses uprobes attached to Go, Python, JVM, and Node.js runtimes to capture application-level protocol data without any code changes and without sidecar proxies. HTTP/1.1, HTTP/2, gRPC, DNS, PostgreSQL, MySQL, Redis, Kafka — Pixie captures request/response pairs for all of these directly from kernel memory.
|
|
Pixie’s query language (PxL, Python-like) lets you write custom analyses against the data Pixie is continuously collecting. Because the collection is kernel-native via eBPF, the overhead is typically 2-5% CPU compared to 10-30% for agent-based collection.
Parca: Continuous Profiling
Parca is a continuous profiling system. It samples stack traces across all processes on a host at 19 samples per second per CPU using a perf_event eBPF program, stores the aggregated profiles, and serves them as a flamegraph UI. Unlike profiling you turn on for a specific incident, Parca runs all the time — so when you have a CPU spike, the profile data is already there.
|
|
|
|
The flame graph view in Parca shows CPU time broken down by stack frame, continuously over time. You can select any time window and see where CPU was spent — without having added any profiling hooks to your applications.
bcc Tools: The Swiss Army Knife
The BCC (BPF Compiler Collection) project maintains a library of ready-to-run eBPF tools for specific performance questions. Most Linux distributions package them:
|
|
These are production-safe tools. The BCC repository (github.com/iovisor/bcc) has over 100 of them, each solving a specific measurement problem that previously required either invasive instrumentation or guesswork.
Kernel Requirements
eBPF capabilities have accumulated steadily across kernel versions. The minimum practical baseline for observability work is kernel 4.18 (2018), but several important features require newer kernels:
| Feature | Minimum kernel |
|---|---|
| Basic kprobes, tracepoints | 4.1 |
| BPF maps, JIT compiler | 4.1 |
| BTF (BPF Type Format) | 4.18 |
| CO-RE (Compile Once, Run Everywhere) | 5.2 |
| Ring buffer map | 5.8 |
| BPF LSM hooks | 5.7 |
| Bounded loops in verifier | 5.3 |
| bpftrace (reasonable) | 4.9+ |
Most modern Linux distributions running in production (Ubuntu 20.04+, Debian 11+, RHEL 8+, any kernel from 2020 onward) are comfortably in the 5.x range and support the full modern eBPF feature set. Check with uname -r before reaching for CO-RE features.
The Verifier: Why eBPF Programs Get Rejected
Understanding the verifier helps you debug the “permission denied” and “invalid program” errors you will inevitably encounter when writing eBPF programs.
The verifier performs a simulated execution of every possible path through your program. It tracks the type and range of values in each register. At every memory access, it checks that the pointer is valid and the offset is in bounds. It rejects programs that:
- Access memory through an unverified pointer (pointer not obtained from a known-valid source)
- Perform arithmetic that could produce an out-of-bounds pointer
- Call kernel functions not in the allowed helper set
- Have any code path that could loop unboundedly
- Exceed the instruction limit (1 million instructions as of kernel 5.2)
- Read uninitialized stack memory
Common rejection patterns:
|
|
The BPF_CORE_READ macro expands to bpf_probe_read_kernel() internally, which the verifier understands as a safe kernel memory read. Direct pointer dereferences of kernel pointers are rejected because the verifier cannot prove the pointer is still valid at the point of access.
When to Reach for eBPF
eBPF is genuinely powerful, but it is not always the right tool. A practical assessment:
Reach for bpftrace when: you have a production performance problem right now and need to answer “what is this process actually doing” within minutes. It is the fastest path from question to data.
Reach for libbpf/C when: you need a persistent tracer, a daemon that exports metrics to Prometheus, or custom logic that processes events in userspace beyond what bpftrace supports. The development cycle is longer but the result is deployable infrastructure.
Use the existing tools first: If you need syscall-level tracing, bcc’s opensnoop, tcpconnect, and biolatency exist and are battle-tested. If you’re on Kubernetes and need network visibility, Cilium Hubble covers most of what you’d otherwise write a custom tracer for. If you need continuous CPU profiling without instrumentation, deploy Parca. Building custom eBPF programs makes sense when you have a specific measurement requirement these tools do not address.
What eBPF does not do: it does not instrument application logic (for that, you want OpenTelemetry SDK tracing), it does not give you business-level metrics (order counts, user actions), and it does not replace distributed tracing across service boundaries. eBPF observability is kernel-layer and system-layer. It answers “what is the OS doing on behalf of this process” — not “what is the application’s business logic doing.”
The practical superpower is for SRE and platform engineering work: diagnosing latency spikes without application deploys, attributing CPU and I/O to specific processes without invasive instrumentation, and building security monitoring that cannot be bypassed by a compromised application. For those use cases, nothing else is close.
Comments