Attach an eBPF Classifier to Linux Traffic Control with tc

Before you write a real eBPF policy for tc, it is worth proving the plumbing works with a classifier that does nothing clever at all. This compiles one, attaches it to an existing traffic-control parent, inspects the installed filter, and removes it again. The classifier matches every packet and returns the default class identifier, so it is a wiring test, not a real policy. These examples use the locally installed iproute2 package version 6.1.0-1ubuntu6.4, whose tc reports iproute2 6.1.0.

Allow about 20 minutes if the compiler and kernel support are already present. You need an eBPF-capable kernel, tc, LLVM's clang and llc, and a traffic-control parent such as a classful egress qdisc. Loading or removing a filter changes packet handling and normally needs elevated privileges. Test on a disposable interface or maintenance window before touching a production link.

1. Check the local prerequisites

First confirm the tools and the kernel you are about to use:

$ tc -V
tc utility, iproute2-6.1.0, libbpf 1.3.0
$ uname -r
6.8.0-139-generic
$ command -v clang llc tc

The exact kernel release will differ. The important checks are that the commands resolve and that your kernel provides the bpf(2) system call and the traffic-control BPF support named cls_bpf and act_bpf by the manpage. A missing compiler is a build problem; a verifier or kernel-module error appears later when tc loads the object.

Checkpoint: choose the interface and parent before writing a command. The examples below use IFACE and PARENT as placeholders because guessing either value could install a policy on the wrong link.

2. Write the smallest useful classifier

Create bpf.c in a scratch directory with this program:

#include <linux/bpf.h>

#ifndef __section
# define __section(x) __attribute__((section(x), used))
#endif

__section("classifier") int cls_main(struct __sk_buff *skb)
{
    return -1;
}

char __license[] __section("license") = "GPL";

The classifier section is the default section name for a filter. The program returns -1, which tells tc to use the default class identifier supplied on the command line. A return value of 0 means no match; another value can override the default class identifier. This sample therefore tests attachment and classification plumbing, not packet selection.

The licence section is not decoration. The kernel may restrict some helpers to GPL-compatible programs, and a program without a suitable licence can be rejected even when its C compiles.

3. Compile and inspect the object

Use the compilation pipeline documented by this installed manpage:

$ clang -O2 -emit-llvm -c bpf.c -o - | \
    llc -march=bpf -filetype=obj -o bpf.o
$ objdump -h bpf.o

In the section listing, look for classifier and license. The object must be an ELF file containing eBPF instructions. If clang cannot find linux/bpf.h, install the kernel development headers through your normal distribution process, then rerun the same command. Do not solve a compiler error by silently switching to an unrelated host header.

Checkpoint: do not attach an object until the section is present. If you deliberately name the function section something else, pass that name with sec SECTION in the next step. Omitting sec means tc looks for classifier for a filter and action for an action.

4. Record the current traffic-control state

Before changing anything, inspect the target interface:

$ sudo tc qdisc show dev IFACE
$ sudo tc filter show dev IFACE parent PARENT

Replace IFACE with an actual device name and PARENT with the existing parent handle, such as 1:. Save this output somewhere safe. The filter command below adds state to that parent. It does not create a missing class or qdisc, and the default class identifier must make sense for the qdisc already installed.

For a read-only discovery pass, ip link show lists usable interface names. If the chosen parent does not exist, stop and configure the qdisc separately using a design appropriate for the machine. Do not add a root qdisc as a quick fix on a live interface: replacing queueing can affect latency and packet delivery.

5. Attach the filter

With the object and parent checked, load the classifier:

$ sudo tc filter add dev IFACE parent PARENT bpf \
    obj bpf.o flowid 1:1

Replace 1:1 with a class identifier that really exists below your selected parent. obj is the short form of object-file; flowid supplies the default class identifier. Because this sample returns -1, matching packets use that flowid. The command may print nothing on success.

For a non-default ELF section, use the explicit form:

$ sudo tc filter add dev IFACE parent PARENT bpf \
    obj bpf.o sec mycls flowid 1:1

Do not use skip_sw casually. It forces hardware offload and fails instead of installing the filter if the hardware cannot offload the program. skip_hw explicitly disables an offload attempt. Leave both unset while proving the software path.

6. Verify the installed section and parent

Ask tc what it installed:

$ sudo tc filter show dev IFACE parent PARENT
filter parent PARENT: protocol all pref 49152 bpf
filter parent PARENT: protocol all pref 49152 bpf handle 0x1 flowid 1:1 bpf.o:[classifier]

The preference and handle can differ from this representative output. Verify the device, parent, object name, section and flowid instead. If loading fails, rerun the add command with verbose to request the eBPF verifier log:

$ sudo tc filter add dev IFACE parent PARENT bpf \
    obj bpf.o flowid 1:1 verbose

A verifier rejection is a program or kernel-interface problem, not evidence that the filter was partially installed. Confirm the current state with tc filter show before trying another add. Repeated adds can create multiple filters with different preferences and make later debugging harder.

7. Remove the test filter

When testing is complete, remove the filter using the same identifying details:

$ sudo tc filter del dev IFACE parent PARENT bpf
$ sudo tc filter show dev IFACE parent PARENT

Check that the test filter is gone. If the parent contained other filters, do not use a broad delete command until you have read its output and confirmed exactly what the local tc syntax selects. Removing a filter can immediately change classification, policing, redirection or dropping behaviour.

If your experiment also created an ingress qdisc with tc qdisc add dev IFACE handle ffff: ingress, remove that qdisc only when it was created by your test and nothing else depends on it:

$ sudo tc qdisc del dev IFACE ingress

Ingress has no underlying queueing discipline. It can classify, mangle, redirect or drop, but queueing ingress traffic requires redirecting to an ifb device. That is a separate design, not an implicit capability of this filter.

Done means