10 min read

Beyond the Kernel Bottleneck: Building Hyperscale Zero-Copy Data Planes with eBPF and XDP

Hyperscale Zero-Copy Data Planes with eBPF and XDP

Beyond the Kernel Bottleneck: Building Hyperscale Zero-Copy Data Planes with eBPF and XDP

Imagine you are managing a fleet of edge servers. It’s a typical Tuesday until a massive DDoS attack or a viral product launch hits your infrastructure. Suddenly, your ingress nodes are screaming. You look at top, and while your application logic is barely breaking a sweat, your CPU cores are pinned at 90% utilization. The culprit? ksoftirqd.

Your servers are spending nearly all their cycles simply moving packets from the Network Interface Card (NIC) into the Linux kernel’s networking stack. By the time a packet even reaches your load balancer in user space, it has been copied, encapsulated in a heavy sk_buff structure, and subjected to a gauntlet of interrupt handling and context switching. This is the “Linux Network Tax”—and at hyperscale, it’s a tax that can bankrupt your performance.

For years, the industry’s answer was DPDK (Data Plane Development Kit). It worked, but it was a “scorched earth” approach: it bypassed the kernel entirely, requiring dedicated CPU cores and specialized drivers, and essentially breaking standard Linux debugging tools.

Then came the paradigm shift: eBPF (extended Berkeley Packet Filter) and XDP (eXpress Data Path).

Today, we’re going deep into the architecture of zero-copy data planes. We’ll explore how to leverage XDP to process millions of packets per second before they even touch the kernel’s networking stack, and how to build a load balancer that rivals the performance of specialized hardware—all while staying within the safety and observability of the Linux ecosystem.


The Packet Path of Pain: Why Traditional Networking Fails at Scale

To understand why we need XDP, we have to look at what happens when a packet arrives at a standard Linux server.

  1. Hardware Interrupt: The NIC receives a packet and triggers an IRQ (Interrupt Request).
  2. SoftIRQ: The kernel schedules a software interrupt to handle the packet.
  3. skb Allocation: The kernel allocates a sk_buff (socket buffer). This is a complex, heavy-weight metadata structure (over 200 bytes) used to track the packet through the stack.
  4. Protocol Processing: The packet climbs through the IP layer, the TCP/UDP layer, and the firewall (iptables/nftables).
  5. Context Switch: Finally, the packet is copied from kernel space to user space so your load balancer (like Nginx or HAProxy) can actually look at it.

At 10Gbps or 100Gbps, this process is catastrophic. The sheer overhead of memory allocation and context switching creates a “bottleneck ceiling.” No matter how many cores you throw at it, the overhead of the stack itself becomes the limiting factor.

Enter XDP: The Fast Path

XDP changes the game by providing a hook at the lowest possible point in the software stack: the NIC driver itself, before the sk_buff is even allocated.

When an XDP program is attached to an interface, it runs as soon as the DMA (Direct Memory Access) transfer from the NIC is complete. The packet is just a raw chunk of memory. You have four choices for every packet:

  • XDP_DROP: Trash the packet immediately (perfect for DDoS mitigation).
  • XDP_PASS: Send it up to the normal kernel stack.
  • XDP_TX: Reflect the packet back out the same interface it came in (ideal for load balancing).
  • XDP_REDIRECT: Send the packet to a different NIC or a specific CPU core.

By making a decision here, you bypass the entire kernel networking overhead. We are talking about jumping from processing 1-2 million packets per second (PPS) per core to over 20 million PPS per core.


The Architecture of a Zero-Copy Load Balancer

Building a hyperscale load balancer with eBPF/XDP requires more than just a single script; it requires a coordinated architecture between the Data Plane (the fast path) and the Control Plane (the logic).

1. The Data Plane: The eBPF Program

The data plane is written in a restricted subset of C. Its job is simple: parse the headers, check a lookup table (a BPF Map), and rewrite the packet destination.

SEC("xdp_lb")
int xdp_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 != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end)
        return XDP_PASS;

    // Perform a lookup in our backend map
    __u32 key = iph->daddr;
    struct backend_info *backend = bpf_map_lookup_elem(&backend_map, &key);

    if (backend) {
        // Rewrite MAC and IP (DSR or IP-in-IP)
        rewrite_packet(eth, iph, backend);
        return XDP_TX; // Send it back out
    }

    return XDP_PASS;
}

2. The Control Plane: The Brain

The control plane usually lives in user space (written in Go, Rust, or C++). It monitors backend health, manages the Maglev hashing tables, and pushes updates into the BPF Maps. Because the Data Plane and Control Plane share these maps in kernel memory, updates are nearly instantaneous and lock-free.

3. Consistent Hashing (Maglev)

In a hyperscale environment, you can’t afford “sticky sessions” stored in memory. If a load balancer node fails, you don’t want every connection to reset. This is where Maglev Hashing (pioneered by Google) comes in. It allows for a consistent lookup table that ensures that even if backends are added or removed, the majority of flows still map to the same backend, minimizing disruption.


The Magic of AF_XDP and Zero-Copy

While XDP_TX is great for simple L4 load balancing, sometimes you need to get the packet into user space for more complex processing (like TLS termination or L7 inspection). Historically, this meant moving back into the “slow path.”

AF_XDP (Address Family XDP) changed that. It provides a high-performance “express lane” into user space.

The UMEM Performance Secret

AF_XDP works by using a shared memory area called UMEM. Both the kernel and the user-space application have access to this memory. The architecture uses a set of circular rings (queues):

  1. Fill Ring: User space tells the kernel which memory buffers are ready to be filled with new packets.
  2. RX Ring: The kernel tells user space that new packets have arrived in specific buffers.
  3. TX Ring: User space tells the kernel which buffers are ready to be sent out.
  4. Completion Ring: The kernel tells user space it has finished transmitting a buffer.

The “Zero-Copy” magic happens because the NIC hardware writes the packet data directly into a UMEM buffer, and the user-space application reads it from that exact same memory location. There is no memcpy() from kernel space to user space.

This is the holy grail of high-performance networking.


Why the Hype? (And the Reality Check)

If you’ve been following tech news, you’ve seen eBPF everywhere. It’s been called “the most significant change to the Linux kernel in the last decade.” But why did it gain so much traction recently?

The hype is fueled by the collision of two trends: Microservices and Observed Performance Limits. When your architecture moves from one giant monolith to 500 microservices, the “network tax” is no longer a rounding error—it becomes 30-40% of your total infrastructure cost.

However, eBPF is not a magic wand. It comes with a steep learning curve and a very grumpy friend: The BPF Verifier.

The Verifier: Your Best Friend and Worst Enemy

The Linux kernel refuses to run eBPF code that could crash the system or loop forever. Every program must pass the Verifier, which checks:

  • No unbounded loops.
  • No out-of-bounds memory access.
  • The program must terminate within a certain number of instructions.

This means you can’t just write arbitrary C code. You have to prove to the kernel that your code is safe. This often leads to frustrating engineering hours spent trying to convince the verifier that your packet parsing is, in fact, safe.


Implementation Deep Dive: Handling 100Gbps Load Balancing

Let’s look at how a production-grade load balancer (like Meta’s Katran or Cloudflare’s Unimog) handles the actual packet manipulation.

Direct Server Return (DSR)

In a standard load balancer, the packet goes: Client -> LB -> Backend -> LB -> Client. The LB is the bottleneck for both directions.

With XDP, we typically use Direct Server Return (DSR). The LB receives the packet, encapsulates it (e.g., using GUE - Generic UDP Encapsulation or IP-in-IP), and sends it to the backend. The backend decapsulates it and responds directly to the client. The LB only ever sees the “request” path, which is usually much smaller than the “response” path. This allows a single LB node to handle the traffic of dozens of backend nodes.

BPF Tail Calls

As your load balancer grows in complexity (adding DDoS protection, rate limiting, and telemetry), your XDP program might become too large for a single function. eBPF solves this with Tail Calls.

You can think of a tail call like a longjmp. You finish one part of the processing (say, DDoS filtering) and then “jump” to another eBPF program (the load balancer logic). This keeps the code modular and helps stay within the Verifier’s complexity limits.

struct bpf_map_def SEC("maps") jmp_table = {
    .type = BPF_MAP_TYPE_PROG_ARRAY,
    .key_size = sizeof(__u32),
    .value_size = sizeof(__u32),
    .max_entries = 8,
};

// In the main program:
bpf_tail_call(ctx, &jmp_table, NEXT_PROGRAM_INDEX);

Infrastructure Considerations: RSS and Multi-Queue NICs

At hyperscale, a single CPU core cannot handle a 100Gbps stream, even with XDP. We need parallelism.

Modern NICs support RSS (Receive Side Scaling), which uses hardware-level hashing to distribute incoming packets across multiple hardware queues. Each queue is mapped to a different CPU core.

When implementing XDP, you must ensure that your BPF maps are globally accessible across all cores or properly partitioned to avoid cache-line contention. Per-CPU maps are a frequent choice here. They allow each core to maintain its own statistics or state without needing atomic operations or locks, which would kill performance.


The Engineering Curiosity: XDP Offloading

If 20 million PPS isn’t enough, we can go even lower: Hardware Offloading.

Some SmartNICs (like those from Netronome or NVIDIA/Mellanox) allow you to load your eBPF/XDP program directly into the NIC’s NPU (Network Processing Unit). In this scenario, the packet doesn’t even reach the host CPU. The NIC itself executes the BPF instructions and makes the routing decision.

This effectively turns a standard commodity server into a high-end, programmable ASIC-based router.


Challenges and “Gotchas”

  1. Driver Support: XDP requires driver support to run in “Native Mode.” While most 10G/40G/100G drivers (i40e, mlx5, etc.) support it, some cloud environments (like certain AWS EC2 instances) require “Generic XDP,” which runs slightly later in the stack and loses some performance benefits.
  2. The MTU Headache: If you are using encapsulation (like IP-in-IP for DSR), you are adding bytes to the packet. If the packet is already at the MTU limit (usually 1500 bytes), your encapsulated packet will be dropped. You need to account for this via MSS (Maximum Segment Size) clamping or by enabling Jumbo Frames in your VPC.
  3. Observability: Traditional tools like tcpdump won’t see packets dropped by XDP_DROP. You need to build custom observability into your eBPF program using bpf_trace_printk (for debugging) or, preferably, BPF_MAP_TYPE_PERF_EVENT_ARRAY to stream telemetry to user space.

The Strategic Advantage

Why are engineering giants moving to zero-copy eBPF data planes? It’s not just about speed; it’s about agility.

In the past, if you wanted to change how your load balancer handled a new protocol, you had to wait for hardware vendors to release a firmware update or the Linux kernel community to merge a new feature. With eBPF, the network is software. You can deploy a new load balancing algorithm, a new security filter, or a new telemetry probe across your entire global fleet in seconds, without ever rebooting a machine.

We are seeing the “Software Defined Networking” (SDN) dream finally realized, not in complex proprietary controllers, but in the heart of the Linux kernel.

Summary of the Hyperscale Stack

To build a modern, zero-copy data plane, the blueprint is clear:

  • XDP for the initial entry point and high-speed filtering.
  • AF_XDP for high-performance user-space transition where needed.
  • Maglev Hashing in BPF maps for resilient state management.
  • DSR (Direct Server Return) to offload the response heavy-lifting.
  • Prometheus/Grafana integration via eBPF perf buffers for real-time visibility.

The move to eBPF and XDP represents a fundamental shift in how we think about the “operating system.” The kernel is no longer a static gatekeeper; it’s a programmable platform. For engineers building the next generation of hyperscale infrastructure, mastering the zero-copy data plane isn’t just an optimization—it’s a requirement.

The Linux Network Tax is officially optional. It’s time to stop paying it.


More to explore

Keep diving in