
eBPF Load Balancer: XDP Forwarding at Line Rate
Run bpftool feature before you write any code, because an eBPF load balancer only earns its place when you actually need to forward or drop packets before the kernel's network stack sees them. Attached to XDP, a small eBPF program decides each packet's fate at the NIC driver, at line rate, with far less CPU than IPVS or a userspace proxy. That is the win. But it is not a drop-in you clone from GitHub and forget. Skip the program lifecycle, the map semantics, and the verifier, and you get silent packet loss and a 3am page you cannot read.
Last updated: 2026-07-22
What an eBPF load balancer is, and when it beats a userspace one
An eBPF load balancer is a program that runs inside the kernel, hooked early enough to rewrite and re-send packets before they climb the stack. The classic form attaches to XDP at the driver, hashes a flow, rewrites the destination, and bounces the packet straight back out. No socket. No conntrack traversal for the fast path. Cilium uses XDP exactly this way for packet filtering and load balancing.
Here is when it is worth the complexity. You need per-packet steering at rates where a userspace proxy burns whole cores just moving bytes. You are shedding attack traffic or spreading L4 flows across backends, not terminating TLS or rewriting HTTP headers.
Reach for HAProxy or nginx when you need L7 logic: path routing, header inspection, TLS termination. Those live in userspace for a reason, and XDP cannot see any of it. IPVS still fits plenty of L4 jobs and comes with tooling you already know. The real question is not "is XDP faster" but "does my work belong before the stack or after it." If it belongs after, an eBPF load balancer just adds a verifier you now have to fight. For the low-latency case, see why XDP earns its place for early packet work.
How does XDP load balancing actually work under the hood?
XDP runs at the NIC driver level, before the kernel network stack. That is the whole point: your program sees the raw frame while it is still a page in the driver's receive ring, and it returns an action code that says what to do next.
There are three attach modes, and they are not equal. Native XDP runs in the driver's poll routine and is what you want in production. Offloaded XDP pushes the program onto the NIC hardware itself, which almost nothing supports. Generic XDP runs later, in the stack, as a fallback for drivers that lack native support. Generic works for a lab but throws away most of the speed, so confirm your driver does native before you trust a benchmark.
The action codes are the vocabulary. XDP_DROP frees the packet immediately. XDP_PASS hands it up the stack as normal. XDP_REDIRECT sends it to another interface or a CPU. XDP_TX bounces the received packet-page back out the same NIC it arrived on, which is the cheap forward you build a balancer around.
So a minimal L4 balancer parses Ethernet, IPv4, and TCP with strict bounds checks, hashes the flow to pick a backend, rewrites the destination IP and MAC, fixes the checksum, and returns XDP_TX. Every field read must be checked against data_end first, or the verifier rejects the program before it ever loads.
Writing CO-RE programs that survive the next kernel upgrade
Compile Once, Run Everywhere (CO-RE) is what keeps your load balancer alive after a kernel bump. Without it, a program that reads struct fields by fixed offset breaks the moment those offsets shift, and kernel structs shift often.
The mechanism is BTF, the BPF Type Format, plus a generated vmlinux.h header that describes the running kernel's types. You access fields through BPF_CORE_READ and the bpf_core_read helpers instead of dereferencing raw pointers. At load time libbpf relocates every field access against the target kernel's BTF, so the offset is resolved on the box you run on, not the box you built on.
What this buys you: one compiled object that loads across kernels with different struct layouts. What it does not buy you: immunity from fields that were renamed or removed. When a field genuinely disappears, CO-RE cannot invent it, and you handle that with bpf_core_field_exists and a fallback path. Build without BTF and pin to one kernel and you have shipped a time bomb that goes off on the next apt upgrade.
Which balancing algorithm belongs in your BPF maps?
Use consistent hashing backed by a connection tracking map, not naive modulo. Plain hash % backend_count looks fine in a two-backend lab and falls apart the instant a backend leaves. Every flow remaps, live connections reset, and you learn about it from angry users, not logs.
The map layout that holds up in practice is two parts. A backend map (a BPF_MAP_TYPE_ARRAY or a hash) holds the current backend set and their addresses. A connection tracking map keyed on the 4-tuple remembers which backend a flow already went to, so mid-flight packets stay pinned even when the backend set changes.
Per-CPU maps matter here. A BPF_MAP_TYPE_PERCPU_HASH gives each CPU its own copy, so packet counters and per-flow state update without a lock or a cache-line fight between cores. The catch: reading a per-CPU map from userspace means summing across every CPU yourself, and forgetting that is why your counters read low. Use per-CPU for hot write-heavy state, and a regular shared map when every CPU must agree on the same value.
Size the maps for peak load. A conntrack map that runs out of entries under load silently drops new flows, and that failure looks exactly like a network problem until you check the map.
XDP or tc: where forwarding logic should live
Put ingress filtering and forwarding on XDP; put egress and anything L7-adjacent on tc. XDP fires before the stack allocates an sk_buff, so it is the cheapest place to drop or bounce a packet, but it only runs on the receive path. There is no XDP hook on egress.
tc with the clsall/clsact classifier runs later, after the packet is a full sk_buff, on both ingress and egress. That costs you the socket-buffer allocation XDP avoids, but it gives you egress-side redirect and access to metadata XDP never sees. If your balancer needs to rewrite packets on the way out, that logic lives in tc, full stop.
| Property | XDP | tc (clsact) |
|---|---|---|
| Runs at | NIC driver, before the stack | After sk_buff, in the stack |
| Direction | Ingress only | Ingress and egress |
| Cost per packet | Lowest | Higher, sk_buff allocated |
| Best for | Early drop, L4 forward, DDoS shedding | Egress rewrite, richer metadata |
Most real balancers split the work: XDP does the hot ingress forward, tc handles the cases XDP structurally cannot. For a DDoS-facing setup, drop the flood on XDP first and let tc mop up the rest.
How do you debug an eBPF load balancer when packets silently vanish?

Read the verifier log first. Skip the Stack Overflow search. When a program fails to load, libbpf dumps the full verifier trace, and it names the exact instruction and register state that killed it. Nine times out of ten it is an unchecked pointer: you read past data_end and the verifier refused. The log tells you which line. Read it.
If the program loads but packets disappear, the next tool is bpftool prog tracelog plus bpf_printk in the code. bpf_trace_printk() emits to /sys/kernel/debug/tracing/trace_pipe, so you drop a print at the map lookup and at the action return and watch which branch each packet actually takes. That tells you whether the hash picked a dead backend or the rewrite ran at all.
Then check the action counts. XDP_ABORTED means your program hit an error path and the packet was dropped, and it is easy to return by accident from a helper that failed. bpftool prog show gives you run counts; a tracepoint on xdp:xdp_exception catches the aborts. If forwarded packets vanish on a veth peer, you forgot the stub: XDP_TX on one side of a veth needs a program returning XDP_PASS on the other side, or the frame lands nowhere. That one costs everybody an evening exactly once.
Measuring real throughput instead of trusting the benchmark in the README

Generate real load and read perf. Ignore the number from someone's blog. A synthetic pktgen run with 64-byte packets and one flow tells you almost nothing about your traffic, and vendor throughput charts are chosen to flatter.
Start with perf stat around a sustained load and watch cycles-per-packet and cache misses. That metric predicts whether you scale. Raw packets-per-second on an idle box does not. If cycles-per-packet climbs as you add flows, your map lookups or your hash are the cost, and perf record on the program points at the hot instruction.
Confirm the driver ran native XDP and not generic, because generic mode quietly halves your ceiling and every "XDP is slow" complaint I have chased traced back to it. Compare against your actual current stack under the same load, same NIC, same core count. A gain that only shows up in the author's lab is not a gain you can bank.
Monitoring in real time without polling blind
Push events from the kernel with a BPF ring buffer. Polling a map on a timer just hopes the sample lines up with the incident. The BPF_MAP_TYPE_RINGBUF gives you a lock-free channel from the program to userspace, so you emit a record per interesting event and read them as they happen.
For counters, per-CPU maps read periodically are fine, as long as you remember to sum across CPUs. For events you care about the moment they occur, a dropped flow, a backend marked dead, a checksum failure, the ring buffer beats polling because you see the event itself, before an average smooths it away. Attach a tracepoint or a perf event when you need to correlate with the rest of the system.
The mistake I see is metrics with no time anchor. A counter that says "40 million drops" is useless; a stream that says "drops started at 03:12 when backend 2 stopped answering" is the whole investigation. Tie every metric to a timestamp or you are guessing.
What breaks first when you scale to production traffic
Map exhaustion breaks first, every time. The conntrack map fills, new flows have nowhere to land, and they drop silently while your CPU graph looks bored. Size for peak concurrent flows with headroom, and export the map's used-entry count as a metric so you see it fill before users do.
The verifier's complexity ceiling breaks second. As you add backend selection logic, retries, and header variants, the number of instruction paths the verifier must walk grows fast, and it will reject a program that is merely too complex to prove safe. You fix that by splitting logic across tail calls and keeping loops bounded. Fighting the same rejection all night fixes nothing.
Multi-NIC and multi-queue coordination breaks third. Native XDP runs per receive queue, so per-CPU state is genuinely per-CPU, and a flow that arrives on a different queue after a rebalance can miss its conntrack entry. Pin flows with RSS or accept that your state has to be reachable across queues. None of these show up in a two-namespace lab, which is exactly why people ship them. If you want the production-hardened version of this design, study how Katran forwards packets at kernel scale, and read the Cilium documentation on XDP for how a large project actually wires it.
FAQ
Can I build and test an eBPF load balancer without spare hardware?
Yes. Network namespaces and veth pairs give you isolated network environments and direct Layer 2 links entirely in software, so you run a client, the balancer, and two backends on one machine. Keep a setup and teardown script so the topology is reproducible. Just remember generic XDP on veth will not tell you anything real about production throughput.
Why does my XDP program load fine but forward zero packets?
Check whether you are on a veth peer without the return-path stub. XDP_TX needs a program returning XDP_PASS on the opposite interface, or the bounced frame is dropped with no error you would notice. After that, add a bpf_printk at the action return and read trace_pipe to see which branch each packet takes.
Is XDP always faster than IPVS?
No. XDP wins when the work fits before the stack and the driver supports native mode. For plenty of L4 balancing, IPVS is fast enough and far easier to operate, and it comes with tooling you already have. Measure your actual workload before you rewrite anything into the kernel.
How do I keep a running eBPF program working after a kernel upgrade?
Compile it with CO-RE and BTF so field offsets are relocated against the kernel it loads on. Access struct fields through the BPF_CORE_READ helpers, never by hardcoded offset, and guard fields that may not exist with bpf_core_field_exists. Without that, the next kernel bump can break loading with no code change on your side.
What does XDP_ABORTED actually mean?
It means your program hit an error path and the kernel dropped the packet as a result. It usually comes from returning early after a failed helper call or a bad bounds check. Trace it with the xdp:xdp_exception tracepoint and count it, because a rising abort count is packet loss you can otherwise miss entirely.
