How to Use eBPF with Rust (aya)

Use the `aya` crate to write eBPF programs in Rust by compiling them with `clang` and loading them into the kernel via the `aya` runtime, which handles map management, program attachment, and event polling.

How to Use eBPF with Rust (aya)

Your web server is dropping packets. The logs show nothing. The network tool says traffic is arriving. The kernel is silently eating packets, and you have no idea why. You can't restart the production server to add debug logs. You can't patch the kernel. You need to inject a tiny program into the running kernel to count the drops, and you need it to be safe enough that a bug doesn't crash the whole machine.

eBPF solves this. eBPF stands for Extended Berkeley Packet Filter. The name comes from a 1980s network tool. The modern tool is a sandbox that lets you run custom code inside the kernel. The kernel runs your code in a cage. If your code tries to do something dangerous, the kernel stops it immediately. Your code can read kernel data, count events, and send results back to your userspace application. Your code cannot crash the system.

Rust writes the code. The aya crate handles the loading, the map management, and the communication with the kernel. You write the eBPF logic in Rust, compile it to a special format, and load it with aya.

Think of the kernel as a secure server room. You can't walk in. eBPF is a pneumatic tube system. You slide a sealed canister in. The canister contains instructions. The machine inside runs the instructions, but only on the data in the canister. If the instructions try to open the door or access the vault, the machine jams the canister and throws it out. Rust ensures your instructions are well-formed before you even slide the canister in.

eBPF lets you program the kernel without touching the kernel. That's the magic.

Minimal example

You need two parts. The eBPF program runs in the kernel. The userspace program loads the eBPF program and reads the results.

Create a Rust workspace. Add a crate for the eBPF code and a crate for the userspace code. The eBPF crate needs aya-ebpf and aya-log-ebpf. The userspace crate needs aya and aya-log.

The eBPF program counts file opens. It uses a map to store the count. Maps are the bridge between kernel space and userspace.

// bpf/src/main.rs
#![no_std]
#![no_main]

use aya_ebpf::{
    macros::{map, tracepoint},
    maps::HashMap,
    programs::TracePointContext,
};

/// Stores the count of file opens.
/// The key is 0. The value is the count.
#[map(name = "OPEN_COUNT")]
static mut OPEN_COUNT: HashMap<u32, u64> = HashMap::with_max_entries(1, 0);

/// Runs when a process calls openat.
/// Increments the counter in the map.
#[tracepoint("syscalls", "sys_enter_openat")]
pub fn count_openat(_ctx: TracePointContext) -> u32 {
    // SAFETY: We access a static map that the verifier guarantees
    // is safe to access from this program context.
    // The map is defined with a fixed size, so bounds are checked.
    unsafe {
        let key = 0u32;
        // Get the current count. Default to 0 if missing.
        let value = OPEN_COUNT.get(&key).copied().unwrap_or(0);
        // Increment and store back.
        OPEN_COUNT.insert(&key, &(value + 1), 0);
    }
    // Return 1 to indicate success to the kernel.
    1
}

The userspace program loads the eBPF object and attaches it to the tracepoint.

// app/src/main.rs
use aya::{include_bytes_aligned, Bpf};
use aya_log::EbpfLogger;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize logging for eBPF programs.
    EbpfLogger::init()?;

    // Load the compiled eBPF object file.
    // The path points to the build output of the bpf crate.
    let mut bpf = Bpf::load(include_bytes_aligned!(
        "../../target/bpfel-bpf/bpf.o"
    ))?;

    // Get the program by name.
    let program = bpf.program_mut("count_openat").unwrap();
    // Load the program into the kernel.
    program.load()?;
    // Attach to the syscalls tracepoint.
    program.attach("syscalls", "sys_enter_openat")?;

    println!("eBPF program attached. Waiting for events...");
    loop {
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

Compile the eBPF crate with the bpfel-unknown-none target. Compile the userspace crate normally. Run the userspace binary. You need root privileges to load eBPF programs.

How the pieces fit together

The build process has two sides. The eBPF side compiles to BPF bytecode. The userspace side compiles to native code. You need a special Rust target for the eBPF side. Add bpfel-unknown-none to your rust-toolchain.toml or Cargo.toml. The bpfel part means BPF little-endian. The unknown-none part means no standard library. The eBPF program runs in the kernel, so it can't use std. It uses no_std.

Convention aside: The target triple in aya projects is often bpfel-bpf. The second bpf is the environment. It looks redundant, but it's the convention. Copy it exactly. If you change it, the build tool might fail to find the right linker.

The aya build system uses clang under the hood. Rust compiles to LLVM IR. clang takes the LLVM IR and produces BPF bytecode in an ELF object file. The ELF file contains the bytecode, the maps, and metadata.

The userspace program loads the ELF file. The aya runtime parses the ELF. It extracts the programs and maps. It sends the programs to the kernel via syscalls.

The kernel verifier checks the bytecode. The verifier is the most important part of eBPF. It simulates the program execution. It checks for infinite loops, out-of-bounds access, and recursion. It proves the program is safe. If the verifier rejects the program, the load fails. The program never runs.

Convention aside: Use include_bytes_aligned! instead of include_bytes!. The kernel loader expects 4-byte alignment for the ELF header. If you use the wrong macro, the load fails with a cryptic alignment error. The community always uses the aligned version.

The verifier is the gatekeeper. If the bytecode doesn't pass, the program never runs. Write code the verifier can prove is safe.

Realistic usage with maps

The minimal example stores a count. Real applications need more data. Maps support different types. HashMap stores key-value pairs. RingBuf streams data to userspace. PerfEventArray sends events with low latency.

Here is a realistic example using a RingBuf. The eBPF program logs file paths. The userspace program reads the log.

// bpf/src/main.rs
#![no_std]
#![no_main]

use aya_ebpf::{
    macros::{map, tracepoint},
    maps::RingBuf,
    programs::TracePointContext,
};

/// Ring buffer for logging file paths.
#[map(name = "LOG")]
static mut LOG: RingBuf = RingBuf::with_max_entries(65536, 0);

/// Logs the filename when openat is called.
#[tracepoint("syscalls", "sys_enter_openat")]
pub fn log_openat(ctx: TracePointContext) -> u32 {
    // SAFETY: We access the ring buffer safely.
    // The verifier checks the buffer size.
    unsafe {
        // Get the filename from the tracepoint context.
        // This requires parsing the context structure.
        // For brevity, we log a fixed message.
        let msg = b"openat called\n";
        LOG.output(msg, 0);
    }
    1
}

The userspace program reads the ring buffer.

// app/src/main.rs
use aya::maps::RingBuf;
use aya::{include_bytes_aligned, Bpf};
use aya_log::EbpfLogger;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    EbpfLogger::init()?;

    let mut bpf = Bpf::load(include_bytes_aligned!(
        "../../target/bpfel-bpf/bpf.o"
    ))?;

    let program = bpf.program_mut("log_openat").unwrap();
    program.load()?;
    program.attach("syscalls", "sys_enter_openat")?;

    // Get the ring buffer map.
    let mut log = bpf.map_mut::<RingBuf>("LOG").unwrap();

    println!("Attached. Reading log...");
    loop {
        // Poll for data.
        if let Some(data) = log.poll().next() {
            let msg = core::str::from_utf8(data).unwrap_or("<invalid utf8>");
            print!("{}", msg);
        }
    }
}

The ring buffer is efficient. The eBPF program writes to a shared buffer. The userspace program reads from the buffer. No context switches for each event. The kernel handles the synchronization.

Maps are the kernel's way of giving you dynamic storage. You define a map with a maximum size. The verifier checks that you never exceed it. You can share data between multiple eBPF programs using maps. You can share data between eBPF and userspace using maps.

Treat maps as shared memory with strict rules. The kernel enforces the rules. You just need to follow the API.

Pitfalls and verifier traps

eBPF has strict rules. The verifier enforces them. If you break a rule, the program fails to load.

The verifier rejects unbounded loops. You cannot write while true. You must write for i in 0..10. The verifier needs to know the maximum number of iterations to prove the program terminates. If you write an infinite loop, the verifier rejects the program with an error like "loop unroll limit exceeded".

The verifier rejects recursion. eBPF programs cannot call themselves. The kernel has limited stack space. Recursion could overflow the stack. The verifier bans it.

The verifier rejects dynamic memory allocation. You cannot use malloc. You must use fixed-size buffers or maps. Maps are the kernel's way of giving you dynamic storage. You define a map with a maximum size, and the verifier checks that you never exceed it.

The verifier rejects unknown function calls. You can only call functions that the kernel knows are safe. You can call helper functions provided by the kernel. You can call your own functions if the verifier can analyze them. You cannot call arbitrary C functions.

If you try to access a map key that doesn't exist, the verifier might reject the access if it can't prove the key is valid. You often need to use get and check for None. The aya map API returns Option types to help you handle this safely.

Compiler errors can also appear. If you use the wrong type for a map key, you get E0277 (trait bound not satisfied). The map requires keys to implement Copy and Clone. If you use a String as a key, the compiler rejects it. Use u32 or a fixed-size array.

Kernel version mismatches cause silent failures. eBPF features evolve rapidly. A program that works on kernel 6.5 might fail on kernel 5.15. The verifier rejects programs that use unsupported features. Check your kernel version. Use uname -r.

Treat the verifier errors as design feedback. The kernel is telling you what it won't allow. Adapt your logic, don't fight the rules.

Choosing your eBPF tool

Rust has multiple eBPF libraries. Pick the one that fits your needs.

Use aya when you want a high-level Rust API for eBPF with automatic map handling and type-safe loading. aya abstracts away the low-level kernel syscalls and gives you Rust structs for maps and programs. It's the best choice for building Rust applications that embed eBPF logic.

Use libbpf-rs when you need direct access to the libbpf C library bindings and prefer the BPF CO-RE approach with skeleton generation. libbpf-rs is a wrapper around libbpf. It supports CO-RE (Compile Once Run Everywhere) out of the box. If you are already using libbpf in C, libbpf-rs feels familiar.

Use bpftrace when you need a quick one-off diagnostic script and don't want to write a full Rust application. bpftrace is a high-level tracing language. You can write a one-liner to count syscalls or trace function arguments. It's perfect for interactive debugging.

Choose the tool that fits the job. aya for Rust-native tools, bpftrace for instant diagnostics.

Where to go next