Introduction to eBPF for Networking
eBPF (Extended Berkeley Packet Filter) is a revolutionary technology that allows developers to run sandboxed programs directly inside the Linux kernel without modifying kernel source code or loading custom kernel modules. Originally designed for packet filtering, eBPF has evolved into a powerful general-purpose kernel programmability framework. For networking specifically, eBPF enables high-performance packet processing, traffic observation, load balancing, and security enforcement directly at the kernel level.
At its core, eBPF works by letting you write programs in a restricted C-like language, compile them to BPF bytecode, and have the kernel's JIT compiler translate them into native machine code. The kernel verifier ensures these programs are safe to run, cannot crash the system, and always terminate in bounded time. This combination of safety and performance makes eBPF ideal for networking tasks that require both speed and flexibility.
Why eBPF Matters for Networking
Traditional networking approaches in Linux rely on the kernel's built-in networking stack, iptables rules, or custom kernel modules. Each of these has limitations. The built-in stack is rigid, iptables can become slow with large rule sets, and kernel modules are risky to develop and deploy. eBPF solves these problems by offering a programmable, safe, and fast alternative.
Key Benefits
- Performance: eBPF programs run in the kernel, eliminating expensive context switches between kernel and user space during packet processing.
- Flexibility: You can attach programs to various hook points in the networking stack, from network interfaces to socket buffers.
- Safety: The verifier ensures programs cannot crash the kernel or loop indefinitely.
- Observability: eBPF provides deep visibility into network traffic without the overhead of traditional monitoring tools.
- No kernel modifications: You can add functionality without recompiling the kernel or writing loadable kernel modules.
Prerequisites and Environment Setup
Before writing eBPF networking programs, you need to set up your development environment. This section walks through installing the necessary tools and dependencies on a Linux system.
System Requirements
You need a Linux kernel version 4.18 or newer for good eBPF networking support, though 5.4+ is recommended. You also need root privileges to load eBPF programs. The following setup assumes Ubuntu 22.04 or similar Debian-based distributions.
Installing Dependencies
# Update package lists
sudo apt update
# Install build tools and kernel headers
sudo apt install -y build-essential clang llvm libelf-dev \
linux-headers-$(uname -r) libbpf-dev bpftool \
git curl pkg-config
# Verify clang and llvm are installed
clang --version
llc --version
# Verify bpftool is available
bpftool version
Verifying eBPF Support
After installing the dependencies, verify that your kernel supports the eBPF features you need. The following command checks for BPF program type support:
# Check kernel BPF support
cat /proc/sys/kernel/unprivileged_bpf_disabled
# List supported BPF program types
bpftool feature probe | grep program_type
# Check if JIT compilation is enabled
cat /proc/sys/net/core/bpf_jit_enable
If bpf_jit_enable is set to 0, enable it for better performance:
sudo sysctl -w net.core.bpf_jit_enable=1
Writing Your First eBPF Network Program
Now let's write a practical eBPF program that counts network packets on a specific interface. This example uses the XDP (eXpress Data Path) hook, which processes packets at the earliest possible point in the receive path, before they enter the kernel's networking stack.
The eBPF Program (Kernel Side)
Create a file called packet_counter.c with the following content:
// packet_counter.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
// Define a BPF map to store packet counts
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 256);
__type(key, __u32);
__type(value, __u64);
} packet_count_map SEC(".maps");
SEC("xdp")
int count_packets(struct xdp_md *ctx) {
// Get pointers to the start and end of the packet data
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
// Parse the Ethernet header
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) {
return XDP_PASS;
}
// Check if this is an IP packet (Ethernet protocol 0x0800)
if (eth->h_proto != __builtin_bswap16(ETH_P_IP)) {
return XDP_PASS;
}
// Parse the IP header
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end) {
return XDP_PASS;
}
// Use the IP protocol number as the key (TCP=6, UDP=17, etc.)
__u32 key = iph->protocol;
__u64 init_val = 1;
// Look up the current count for this protocol
__u64 *count = bpf_map_lookup_elem(&packet_count_map, &key);
if (count) {
// Atomically increment the existing count
__sync_fetch_and_add(count, 1);
} else {
// Initialize the count for a new protocol
bpf_map_update_elem(&packet_count_map, &key, &init_val, BPF_ANY);
}
// Pass the packet through to the normal networking stack
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
This program does several things. First, it defines a BPF map of type BPF_MAP_TYPE_ARRAY to store packet counts keyed by IP protocol number. The SEC("xdp") annotation tells the compiler that this function should be attached to the XDP hook. Inside the function, we parse the Ethernet and IP headers, extract the protocol number, and update the count in the map. The XDP_PASS return value lets the packet continue through the normal kernel networking stack.
Compiling the eBPF Program
Compile the program using clang with the BPF target:
# Compile the eBPF program to BPF bytecode
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
-c packet_counter.c -o packet_counter.o
# Verify the compiled object file
llvm-objdump -S packet_counter.o | head -50
Loading and Attaching eBPF Programs
Now that we have compiled the eBPF program, we need a user-space program to load it into the kernel and attach it to a network interface. We will use the libbpf library for this task.
The User-Space Loader
Create a file called loader.c:
// loader.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <net/if.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include <signal.h>
static volatile int running = 1;
void handle_signal(int sig) {
running = 0;
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <bpf_object> <interface>\n", argv[0]);
return 1;
}
const char *obj_path = argv[1];
const char *ifname = argv[2];
// Get the interface index
int ifindex = if_nametoindex(ifname);
if (ifindex == 0) {
fprintf(stderr, "Failed to get interface index for %s: %s\n",
ifname, strerror(errno));
return 1;
}
// Open the BPF object file
struct bpf_object *obj = bpf_object__open_file(obj_path, NULL);
if (libbpf_get_error(obj)) {
fprintf(stderr, "Failed to open BPF object: %s\n",
strerror(errno));
return 1;
}
// Load the BPF program into the kernel
if (bpf_object__load(obj)) {
fprintf(stderr, "Failed to load BPF object: %s\n",
strerror(errno));
bpf_object__close(obj);
return 1;
}
// Find the XDP program by its section name
struct bpf_program *prog = bpf_object__find_program_by_name(obj, "count_packets");
if (!prog) {
fprintf(stderr, "Failed to find BPF program\n");
bpf_object__close(obj);
return 1;
}
int prog_fd = bpf_program__fd(prog);
// Attach the program to the network interface using XDP
int err = bpf_xdp_attach(ifindex, prog_fd, XDP_FLAGS_DRV_MODE, NULL);
if (err) {
fprintf(stderr, "Failed to attach XDP program (trying SKB mode)\n");
err = bpf_xdp_attach(ifindex, prog_fd, XDP_FLAGS_SKB_MODE, NULL);
if (err) {
fprintf(stderr, "Failed to attach XDP program: %s\n",
strerror(errno));
bpf_object__close(obj);
return 1;
}
}
printf("eBPF program attached to %s. Press Ctrl+C to detach.\n", ifname);
// Set up signal handler for clean exit
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
// Wait for termination signal
while (running) {
sleep(1;
}
// Detach the program and clean up
printf("\nDetaching eBPF program...\n");
bpf_xdp_detach(ifindex, XDP_FLAGS_DRV_MODE, NULL);
bpf_xdp_detach(ifindex, XDP_FLAGS_SKB_MODE, NULL);
bpf_object__close(obj);
printf("Done.\n");
return 0;
}
Compiling the Loader
# Compile the user-space loader
gcc -O2 -g loader.c -o loader -lbpf -lelf -lz
# Run the loader with the eBPF object and a network interface
sudo ./loader packet_counter.o eth0
Reading Data from User Space
Once the eBPF program is running and counting packets, you need a way to read the data from the BPF map. Let's create a separate program that periodically reads and displays the packet counts.
The Map Reader
Create a file called read_counts.c:
// read_counts.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
// Protocol name lookup
const char *proto_name(__u32 proto) {
switch (proto) {
case 1: return "ICMP";
case 6: return "TCP";
case 17: return "UDP";
case 47: return "GRE";
case 89: return "OSPF";
default: return "OTHER";
}
}
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <bpf_object>\n", argv[0]);
return 1;
}
const char *obj_path = argv[1];
// Open the BPF object to access its maps
struct bpf_object *obj = bpf_object__open_file(obj_path, NULL);
if (libbpf_get_error(obj)) {
fprintf(stderr, "Failed to open BPF object\n");
return 1;
}
// Find the packet count map by name
struct bpf_map *map = bpf_object__find_map_by_name(obj, "packet_count_map");
if (!map) {
fprintf(stderr, "Failed to find map\n");
bpf_object__close(obj);
return 1;
}
int map_fd = bpf_map__fd(map);
printf("Reading packet counts (Ctrl+C to stop):\n");
printf("%-10s %-15s\n", "Protocol", "Packet Count");
printf("---------------------------------\n");
// Read and display counts every 2 seconds
while (1) {
printf("\033[2J\033[H"); // Clear screen
printf("Reading packet counts (Ctrl+C to stop):\n");
printf("%-10s %-15s\n", "Protocol", "Packet Count");
printf("---------------------------------\n");
for (__u32 key = 0; key < 256; key++) {
__u64 value = 0;
int err = bpf_map_lookup_elem(map_fd, &key, &value);
if (err == 0 && value > 0) {
printf("%-10s %-15llu\n", proto_name(key), value);
}
}
sleep(2);
}
bpf_object__close(obj);
return 0;
}
Compile and run the reader:
# Compile the map reader
gcc -O2 -g read_counts.c -o read_counts -lbpf -lelf -lz
# In a separate terminal, run the reader
sudo ./read_counts packet_counter.o
Advanced Networking Use Cases
Beyond simple packet counting, eBPF can handle sophisticated networking tasks. Here are some common advanced use cases and how to approach them.
Traffic Filtering with XDP Drop
You can modify the XDP program to drop packets matching certain criteria, such as blocking traffic from specific IP addresses:
SEC("xdp")
int drop_packets(struct xdp_md *ctx) {
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != __builtin_bswap16(ETH_P_IP))
return XDP_PASS;
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end)
return XDP_PASS;
// Block traffic from 192.168.1.100 (0xC0A80164 in hex)
if (iph->saddr == __builtin_bswap32(0xC0A80164)) {
return XDP_DROP;
}
return XDP_PASS;
}
Socket-Level Filtering with TC
The Traffic Control (TC) hook allows you to attach eBPF programs at the socket buffer level for both ingress and egress traffic. This is useful for more granular control:
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/pkt_cls.h>
SEC("tc")
int tc_packet_marker(struct __sk_buff *skb) {
// Mark packets for traffic shaping
if (skb->protocol == __builtin_bswap16(0x0800)) {
// Set a mark on IP packets for QoS processing
skb->mark = 0x1234;
}
return TC_ACT_OK;
}
char _license[] SEC("license") = "GPL";
Attach the TC program using the tc command:
# Compile the TC program
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
-c tc_packet_marker.c -o tc_packet_marker.o
# Attach to ingress on eth0
sudo tc qdisc add dev eth0 clsact
sudo tc filter add dev eth0 ingress bpf da obj tc_packet_marker.o sec tc
# Remove when done
sudo tc filter del dev eth0 ingress
sudo tc qdisc del dev eth0 clsact
Load Balancing with eBPF
eBPF is the foundation of modern load balancers like Facebook's Katran and Cloudflare's L4 load balancer. Here is a simplified example of a round-robin load balancer using XDP:
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <linux/in.h>
#define MAX_BACKENDS 4
struct backend {
__u32 ip;
__u16 port;
};
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, MAX_BACKENDS);
__type(key, __u32);
__type(value, struct backend);
} backends SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, __u32);
} rr_counter SEC(".maps");
SEC("xdp")
int load_balancer(struct xdp_md *ctx) {
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != __builtin_bswap16(ETH_P_IP))
return XDP_PASS;
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end)
return XDP_PASS;
// Only handle UDP packets for simplicity
if (iph->protocol != IPPROTO_UDP)
return XDP_PASS;
// Get the round-robin counter
__u32 key = 0;
__u32 *idx = bpf_map_lookup_elem(&rr_counter, &key);
if (!idx)
return XDP_PASS;
__u32 backend_key = *idx % MAX_BACKENDS;
__sync_fetch_and_add(idx, 1);
// Look up the target backend
struct backend *backend = bpf_map_lookup_elem(&backends, &backend_key);
if (!backend)
return XDP_PASS;
// Rewrite the destination IP
iph->daddr = backend->ip;
// Recalculate IP checksum
iph->check = 0;
iph->check = __builtin_bswap16(
~((__u16)__builtin_bswap32(iph->saddr + iph->daddr +
iph->tot_len + iph->protocol)) & 0xFFFF);
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
Best Practices
When developing eBPF networking programs, following best practices ensures your programs are safe, performant, and maintainable.
Always Handle Bounds Checking
The eBPF verifier requires that every pointer access is bounds-checked. Always verify that (struct *)(ptr + 1) <= data_end before accessing packet data. Failing to do so will cause the verifier to reject your program.
Use BPF Maps Efficiently
Choose the right map type for your use case. BPF_MAP_TYPE_ARRAY is fast for indexed lookups, BPF_MAP_TYPE_HASH is better for arbitrary keys, and BPF_MAP_TYPE_PERCPU_ARRAY or BPF_MAP_TYPE_PERCPU_HASH avoid contention on multi-core systems by using per-CPU storage.
Minimize Packet Processing Overhead
Keep your eBPF programs as short as possible. Every cycle spent in an XDP or TC program adds latency to packet processing. Avoid complex logic and offload heavy computation to user space when possible.
Test with the Verifier
Always check verifier output when developing. Use bpftool prog load with the -d flag to see detailed verifier logs, which help identify why a program was rejected:
sudo bpftool prog load packet_counter.o /sys/fs/bpf/packet_counter type xdp -d
Clean Up Resources
Always detach eBPF programs and close file descriptors when your application exits. Pinned programs in /sys/fs/bpf/ persist until manually removed, so clean them up to avoid resource leaks:
# Remove a pinned program
sudo rm /sys/fs/bpf/packet_counter
Use CO-RE for Portability
Compile Once, Run Everywhere (CO-RE) makes eBPF programs portable across different kernel versions. Use bpf_core_read() for reading kernel structures and include vmlinux.h generated by bpftool btf dump instead of kernel headers:
# Generate vmlinux.h
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
Monitor Performance
Use bpftool prog show to monitor the runtime statistics of your eBPF programs, including how many times they have run and how long they take:
sudo bpftool prog show
Conclusion
eBPF has transformed Linux networking by providing a safe, fast, and programmable way to process packets directly in the kernel. In this tutorial, you learned what eBPF is, why it matters for networking, and how to set up a complete development environment. You wrote a practical XDP packet counter, built a user-space loader using libbpf, read data from BPF maps, and explored advanced use cases including traffic filtering, TC hooks, and load balancing. By following the best practices outlined here, you can build production-quality eBPF networking programs that deliver exceptional performance and flexibility. As the eBPF ecosystem continues to grow with projects like Cilium, Pixie, and Tetragon, mastering these fundamentals will give you a strong foundation for building the next generation of networking tools and infrastructure.