Managing network bandwidth allocation on high-throughput Linux servers is a critical infrastructure challenge. Traditional tools like tc (Traffic Control) combined with HTB (Hierarchical Token Bucket) have long been the standard for shaping traffic. However, at scale, the overhead of traditional packet queuing mechanisms becomes a bottleneck. The modern, performant solution is leveraging eBPF (Extended Berkeley Packet Filter) directly within the Linux Traffic Control (TC) subsystem.
This guide explains how to write, compile, and deploy an eBPF TC program specifically designed to enforce egress rate limiting. By operating directly inside the kernel’s network datapath, eBPF TC programs provide unparalleled performance and observability for bandwidth management.
Understanding eBPF in the TC Subsystem
While eBPF is commonly associated with XDP (eXpress Data Path) for ingress filtering, XDP cannot intercept egress traffic. To manipulate packets as they leave the server, we must attach our eBPF program to the Traffic Control (TC) egress hook.
When an eBPF program is attached to the TC egress hook, it executes after the Linux networking stack has processed the packet (including routing and Netfilter/iptables) but before the packet is handed to the network interface card (NIC) driver. This positioning makes it the ideal location for shaping, dropping, or rate-limiting outbound traffic based on complex, programmable logic.
Prerequisites for eBPF Compilation
To compile and load eBPF programs, your Linux environment must have the necessary compiler toolchain and kernel headers.
Install the required dependencies on a Debian/Ubuntu system:
sudo apt update
sudo apt install clang llvm libbpf-dev linux-headers-$(uname -r) iproute2
You will use clang to compile C code into eBPF bytecode, and the standard tc command from the iproute2 package to load the bytecode into the kernel.
Step 1: Writing the eBPF Rate Limiting Program
We will create a simple eBPF program in C that monitors egress traffic. For the purposes of this tutorial, the program will intercept packets and use an eBPF map to track byte counts, though hard enforcement in production usually involves manipulating the packet’s skb->tstamp for EDT (Earliest Departure Time) pacing. Here, we will demonstrate the fundamental attachment and packet interception.
Create a file named tc_egress.c:
#include <linux/bpf.h>
#include <linux/pkt_cls.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <bpf/bpf_helpers.h>
// Define an eBPF map to store byte counts
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 1);
} egress_bytes SEC(".maps");
SEC("tc")
int rate_limit_egress(struct __sk_buff *skb) {
__u32 key = 0;
__u64 *bytes;
// Lookup the current byte count
bytes = bpf_map_lookup_elem(&egress_bytes, &key);
if (bytes) {
// Increment the byte count by the length of the packet
__sync_fetch_and_add(bytes, skb->len);
// Example: If bytes exceed a massive threshold, drop (TC_ACT_SHOT)
// In reality, you would use bpf_ktime_get_ns() to calculate rate per second
}
// Allow the packet to proceed
return TC_ACT_OK;
}
char _license[] SEC("license") = "GPL";
Step 2: Compiling to eBPF Bytecode
Use clang to compile the C source file into an ELF object file containing the eBPF bytecode. The target architecture must be specified as bpf.
clang -O2 -target bpf -c tc_egress.c -o tc_egress.o
Ensure no compilation errors occurred. The resulting tc_egress.o file is the binary payload that the Linux kernel will load and verify.
Step 3: Attaching the eBPF Program via TC
To deploy the program, we use the standard tc utility. First, we must create a clsact qdisc (queueing discipline) on the target network interface. The clsact qdisc provides both ingress and egress hooks specifically designed for eBPF.
Assuming your network interface is eth0, add the qdisc:
sudo tc qdisc add dev eth0 clsact
Next, attach the compiled eBPF object file to the egress hook of the clsact qdisc. We specify the section name tc that we defined in our C code using the SEC("tc") macro.
sudo tc filter add dev eth0 egress bpf direct-action obj tc_egress.o sec tc
The direct-action flag (often abbreviated as da) is a critical optimization. It allows the eBPF program to directly return action codes like TC_ACT_OK or TC_ACT_SHOT without requiring a separate TC action module, drastically reducing latency.
Step 4: Verifying Deployment and Inspecting Maps
Once attached, every packet leaving eth0 is evaluated by your eBPF program. To verify that the program is loaded and active, inspect the TC filters on the interface:
sudo tc filter show dev eth0 egress
You should see output indicating that a BPF filter is attached, along with its unique program ID.
To read the accumulated byte count from the eBPF map, use the bpftool utility. First, find the map ID associated with your program:
sudo bpftool map list
Locate the map named egress_bytes. Assuming its ID is 15, you can dump its contents to see the live byte count:
sudo bpftool map dump id 15
Conclusion
By migrating egress rate limiting logic into eBPF TC programs, you bypass the overhead of traditional hierarchical queuing disciplines. While the example above demonstrates traffic accounting, this foundation can be expanded using Earliest Departure Time (EDT) pacing to implement strict, high-performance bandwidth throttling directly in the kernel datapath.