
Trace System Calls with eBPF
I show how I trace system calls using eBPF to get practical, low-overhead visibility into application behavior.
I run small sandboxed programs inside the linux kernel to capture execve, connect, send and recv events. The verifier and JIT keep the approach safe and fast. I explain what you need: a compatible kernel, basic permissions, and one of the standard toolchains to load probes.
My goal is hands-on: start on a single host, then expand to Kubernetes with tools that scale. I prefer bpftrace and bcc for quick checks, and Inspektor Gadget as a DaemonSet to follow requests across pods.
Throughout I focus on low overhead, clear outputs, and exportable data so you can move from raw events to decisions. If you want deeper performance profiling, see this practical guide on kernel performance with eBPF.
Key Takeaways
- I demonstrate tracing steps you can run today on a compatible host.
- Sandboxed programs give kernel-level visibility without heavy agents.
- Use bpftrace/bcc for host checks and Inspektor Gadget in clusters.
- Focus on execve, connect, send, recv to expose real app behavior.
- The kernel verifier and JIT protect stability and keep overhead low.
Why trace system calls with eBPF instead of legacy agents
I gather syscall events directly where they occur to avoid polling and reduce noise. That means I run code only on events—no continuous sampling—and the CPU and memory impact stays low.
The verifier checks safety before load. It blocks unsafe memory access and endless loops. That makes the approach safer than many kernel modules, which can crash a host if buggy.
- Low overhead: event-driven hooks cut CPU and memory use versus user-space daemons.
- Higher fidelity: syscall-level capture improves latency, error codes, and path attribution for applications and containers.
- Broader visibility: all languages and runtimes hit the kernel—no SDKs or per-service work.
- Reduced monitoring-induced issues: fewer IO or cache storms under peak load.
- Trade-offs: kernel versions, distro backports, and managed-platform policies affect available hooks; plan a fallback when needed.
I use this approach when I need tight performance monitoring and reliable network and process visibility with minimal operational risk.
What you need before you start
Start by confirming that the host kernel supports modern helpers and BTF. I check versions first: Linux 4.14+ is the minimum. I prefer 5.10+ for helpers, ring buffer, and a stronger verifier.
Permissions: loading programs typically needs root or CAP_BPF / CAP_SYS_ADMIN. Unprivileged modes may be disabled on many distros. Treat unprivileged access as a controlled lab feature, not a production default.
- Confirm exact kernel build and distro backports—Red Hat, Ubuntu, and Debian vary.
- Validate headers and BTF: CO-RE with BTF reduces per-kernel compilation pain and eases working with source code.
- Keep /sys/kernel/tracing and debugfs available for quick checks and printk debugging.
Tools: pick fast. I use bpftrace for ad-hoc probes and bcc for single-host scripts. For production loaders I rely on libbpf-based code. In clusters I deploy Inspektor Gadget as a DaemonSet.
Plan data paths: ring buffer for streaming events to user space; maps for counters and state; file or socket sinks for export. Lock down who can load programs and audit access. I keep a minimal libbpf skeleton and a small test application to validate hooks before I point this at live application traffic.
How eBPF tracing works in the Linux kernel
I keep this practical: understand the safety gate, pick the right hook, and keep code small.
I rely on two kernel stages when I load any probe. The verifier inspects every path to prove the code terminates and that pointers stay inside bounds. It rejects unsafe patterns before anything runs.
After verification the JIT compiles verified bytecode to native instructions for my CPU. That turns an ebpf program into fast handlers that run in kernel space with low overhead.
Which hooks matter
- Tracepoints: use sys_enter/sys_exit tracepoints for broad system calls coverage. Example: SEC(“tp/syscalls/sys_enter_execve”).
- Kprobes / kretprobes: attach when you need specific kernel function details.
- Uprobes: instrument user-space library or binary calls without kernel patching.
- Generic tracepoints: handy for stable, well-known events.
I keep each program tiny: no unbounded loops, simple maps, and minimal per-event work. I store counts and state in maps and push events via a ring buffer to user space. Tag events with process and container metadata so I can attribute costs cleanly.
Quick start: trace syscall activity on a single host
Get a live view of calls and errors with a few short commands on Ubuntu.
I confirm the kernel first: uname -r and check >= 4.14. I prefer 5.10+ for extra helpers and ring buffer support.
Install a starter tool: sudo apt install bpfcc-tools. That gives ready scripts and tracing tools like execsnoop, opensnoop, and killsnoop-bpfcc.
Run a quick example: sudo killsnoop-bpfcc. It shows which process sent kill calls — fast validation of unexpected terminations.
Watch live logs in another shell: sudo cat /sys/kernel/tracing/trace_pipe. bpf_printk output appears there when I prototype.
- Filter noise: add a PID or process name to the command line.
- Capture entry and exit to get args and result codes; that helps find why a call failed.
- Check impact: monitor top or perf stat while the probe runs to keep overhead low.
- Save outputs to a file so you can compare runs and refine what fields you export as data.
Do not enable unprivileged modes on shared hosts. I use sudo to load probes and keep audit logs of who ran the tool. Validate the path end-to-end by running a small application that execs a known binary and confirming the example output.
eBPF for system tracing: step-by-step walk-through
I load a tiny handler that emits a compact event to validate behavior quickly.
Attach to execve and other syscall tracepoints
I attach an ebpf program at SEC(“tp/syscalls/sys_enter_execve”) to catch process starts and args. I add the exit tracepoint to capture return codes.
Keep the kernel code minimal: fixed-size structs, no loops, bounded userspace reads. Use a loader that opens the object, finds the program, and attaches it.

Read events in user space with maps and ring buffers
I collect timestamp, PID/TID, comm, and selected args. The kernel handler packs the struct and calls bpf_ringbuf_submit.
On the user side I call ring_buffer__new and run ring_buffer__consume in a short loop. Sleep briefly between polls to cap CPU.
Filter by PID, process name, or container ID
I keep filters in an in-kernel map. The program checks the map and drops unwanted events before submitting.
| Filter | Map Type | Impact |
|---|---|---|
| PID | hash | Zero extra events; low CPU |
| Process name | perf_event array | Easy updates; small memory |
| Container ID | cgroup id map | Good attribution; slightly more checks |
- Expand to sys_enter_connect, sys_enter_sendto, and sys_enter_recvfrom to link network calls to process activity.
- Correlate events in user space by PID/TID and timestamp—keep kernel code focused on emit-only work.
- Measure overhead: run high-rate tests and drop fields or add sampling if ring buffer drops appear.
Kubernetes scenario: trace a live app with Inspektor Gadget
I deploy Inspektor Gadget across the cluster to capture live request flows between pods. This gives process, pod, and network context without installing agents in each container. Minimum node kernel 5.10+ is recommended for best helper support.
- Install plugin: kubectl krew install gadget. Confirm with kubectl gadget version.
- Deploy DaemonSet: kubectl gadget deploy — it creates gadget pods in the gadget namespace.
- Verify readiness: kubectl get pods -n gadget should show one Running pod per node. If not, check node kernel and permissions.
Follow calls across pods
Use traceloop to watch request flows: kubectl gadget traceloop -n demo-app. It shows pod, container, and PID context so I can link an application call to the replica that handled it.
Trace TCP and generate traffic
Run TCP traces with kubectl gadget trace_tcp -n demo-app to see source/destination pairs, ports, and timing. Narrow scope by pod name or label to reduce noise.
Practical checks: curl the frontend, port-forward if needed, and confirm connect, send, and recv patterns appear. Save outputs to files per test so you can compare data across deployments and kernel versions.
From data to insight: exporting and interpreting traces
I turn runtime events into searchable data that links processes, pods, and errors.
I export JSON lines per event. Each line has a timestamp, PID/TID, pod and container IDs, syscall name, args, duration, and result code.
I enrich records at the edge: node name, namespace, and pod labels. That gives offline tools the context they need to join data to services and application owners.
I stitch related calls by PID and time to form request timelines. Then I compute p95 and p99 latencies by container and call type to surface slow pods.
| Field | Purpose | Storage | Example |
|---|---|---|---|
| timestamp | Order events; compute latency | JSONL / ClickHouse | 2025-06-01T12:34:56.789Z |
| pid_tid | Correlate spans and processes | Elasticsearch / SQLite | 1234:1234 |
| pod/container | Attribute to application and owner | Long-term aggregates | demo-app / abcdef |
| syscall, args, rc | Diagnose failures and hotspots | Raw archive + daily rollups | connect, ip:port, -110 |
I flag common failures by errno and link each to pod and source IP. That speeds root-cause analysis and supports rollout checks.
I sample low-value events when volume spikes. I keep raw exports for forensics and store daily aggregates for trend analysis.
Finally, I feed exports into ClickHouse, Elasticsearch, or a simple SQLite workflow. That gives fast queries and actionable analysis to drive operational decisions.
Moving beyond printf: maps, ring buffers, and BTF
I prefer emitting compact events into a shared queue rather than printing in-kernel. That shift gives predictable delivery under bursty load and keeps kernel handlers tiny.
Why bpf_ringbuf beats perf buffer on multi-CPU workloads
The ring buffer is a global MPSC queue. It avoids per-CPU tuning and the drop spikes you see when one CPU dominates events.
In the kernel I reserve space with bpf_ringbuf_reserve, write the small struct, then call bpf_ringbuf_submit. On the user side ring_buffer__new creates the consumer and ring_buffer__consume reads events. That pattern keeps memory pressure low and throughput high.
Use BTF and CO-RE to cut build friction
BTF carries type info that lets CO-RE relocate fields across kernels. I compile once and run the same object on many kernels without brittle source code patches.
That reduces loader complexity and keeps the program portable. I lean on libbpf so my loader code stays small and robust when helpers change.
- I keep event structs minimal: include PID, timestamp, and key args — push heavy formatting to user space.
- I pick maps by access: array and hash for filters, LRU for churn, per-CPU maps for counters if needed.
- Validate buffer health: track drops, tune ring size, and test CPU affinity under bursts.
Practical rule: keep kernel space handlers tight and push enrichment into the consumer. That yields reliable data with low overhead and easier code maintenance.
Performance and safety considerations in production
Keep production probes tight: attach only where traffic matters and fail fast if load rises.
Minimize overhead
I attach only to the call paths I need. Fewer hooks reduce cpu and memory use during spikes.
I keep handlers tiny. Pack a small struct, avoid parsing, and send raw events to user space for formatting.
I sample high-volume, low-value paths. I keep full fidelity for errors and outliers.
Security posture
I restrict who can load programs. Limit CAP_BPF and CAP_SYS_ADMIN to a small ops group and audit every load and unload.
I keep unprivileged access off in production. Many distros disable it by default—keep it that way unless you have a secure sandbox.
I protect exported data: encrypt at rest and in transit and limit reads to authorized teams.
- Monitor lost events and ring buffer backpressure.
- Stage kernel upgrades and validate helpers before rollout.
- Have an immediate kill switch: feature flags or detach commands to restore headroom.
| Control | Why it matters | Action |
|---|---|---|
| Selective attach | Limits CPU and memory use | Attach minimal hooks; filter in-kernel |
| Tiny handlers | Reduces kernel work and risks | Emit compact events; format in user space |
| Access control | Reduces blast radius of buggy loads | Restrict caps; log load/unload |
Final note: the verifier and JIT protect safety and efficiency. Kernel modules may be faster but lack those guards and can crash the kernel if buggy—I use them only after careful review.
When to use eBPF vs kernel modules for tracing
I pick an approach by mapping concrete constraints: latency, safety, and maintenance. I state goals first—what visibility I need, and how much risk the fleet can take.
Short verdict: I use ebpf by default. The verifier and JIT give strong safety and wide distro support. That makes iterative debugging and deployment faster for most teams.
Trade-offs: safety, latency, and maintenance in real systems
Kernel modules run with full kernel privileges. They can be lean and slightly faster because they avoid BPF ABI calls. That can help extreme low-latency needs when you fully control the fleet.
Modules demand heavy review and upkeep. One bad pointer can crash a host. I accept that only when performance gains justify the maintenance cost.
- I favour portability: ebpf with BTF and CO-RE runs across many linux kernel builds without code forks.
- I choose modules when latency is the overriding metric and I control all nodes.
- I document the way we choose: clear criteria—latency, safety, and fleet control—so future developers can repeat the decision.
| Aspect | ebpf | kernel modules |
|---|---|---|
| Safety | Verifier checks; limited kernel surface | Full privilege; higher crash risk |
| Performance | JIT is fast enough for most observability tasks | Lower overhead in tight, controlled cases |
| Maintenance | Portable; easier to iterate and ship | More testing and review; version-sensitive |
I keep an exit strategy. If I must switch approaches I run parallel pipelines and migrate without downtime. That protects production while we compare performance and operational costs.
Where eBPF tracing shines in real environments
I observe app behavior and network flows without changing application code. That gives clear timelines when incidents happen. Big teams at places like Facebook and Netflix used this early; modern tools — Inspektor Gadget, Pixie, Hubble, Tetragon — add cluster context today.
I use it to debug flaky microservices: I follow connect, send, and recv patterns between pods to find failing dependencies fast. I reduce MTTR at 3 a.m.: node-level traces explain what happened before a pod vanished. I profile startup paths and spot slow execve or file open calls that delay deploys.
I also watch noisy neighbors and baseline performance per service and node. I flag suspicious process spawns and odd network packets without injecting libraries into apps. I export clean events to existing stores and correlate logs, traces, and metrics. See how I monitor packet drops with monitor packet drops as a practical example.
FAQ
What does "Trace System Calls with eBPF" let me do?
Why choose this approach over legacy agents or kernel modules?
What kernel and distro support do I need to get started?
What permissions are required to run tracing tools?
Which tools should I use: bcc, bpftrace, libbpf-based programs, or Inspektor Gadget?
How does tracing work inside the kernel — is it safe?
What program types and hooks are available?
How do I trace activity on a single host quickly?
How do I attach to execve and other syscall tracepoints safely?
How do I read events from the kernel into userspace?
How can I filter traces by PID, process name, or container ID?
How do I trace a live Kubernetes app with Inspektor Gadget?
How do I follow network requests across pods and containers?
How do I get actionable insight from raw trace data?
When should I move beyond printf-style probes to maps, ring buffers, and BTF?
Why prefer bpf_ringbuf over perf buffer for multi-CPU workloads?
What performance and safety limits should I enforce in production?
When is a kernel module still the right choice?
In what environments does this tracing approach shine?
Related: Pixie eBPF: Auto-Instrument Kubernetes Without Code Changes
Related: BCC eBPF Compiler for Linux Kernel Tracing
Related: Operationalizing Falco: eBPF Driver Setup and Alert Routing
