writing your first ebpf program with aya in rust

system

what is ebpf

eBPF lets you run sandboxed programs inside the Linux kernel without modifying the kernel source or loading kernel modules. the kernel verifies your program before running it — it cannot crash the kernel, loop infinitely, or access arbitrary memory.

traditionally, eBPF programs were written in C and compiled with clang. the aya crate lets you write them in rust.

the architecture

an eBPF program has two parts:

  • kernel space: the eBPF program that runs inside the kernel, attached to a hook point
  • user space: the loader that compiles, loads, and communicates with the kernel program
user space program
  │  loads program via bpf() syscall
  │  reads data via BPF maps
  ▼
kernel verifier (validates program safety)
  │
  ▼
eBPF JIT compiler (compiles to native code)
  │
  ▼
hook point (kprobe, tracepoint, XDP, etc.)
  │  fires when event occurs
  ▼
eBPF program executes

setting up aya

# Cargo.toml (user space)
[dependencies]
aya          = "0.12"
aya-log      = "0.2"
tokio        = { version = "1", features = ["full"] }

# Cargo.toml (kernel space — separate crate)
[dependencies]
aya-bpf      = "0.1"
aya-log-ebpf = "0.1"

the kernel program: tracing open() calls

// src/main.rs (kernel space crate)
#![no_std]
#![no_main]

use aya_bpf::{
    macros::tracepoint,
    programs::TracePointContext,
    maps::perf::PerfEventArray,
};
use aya_log_ebpf::info;

// a BPF map for passing data to user space
#[map]
static mut EVENTS: PerfEventArray<u64> = PerfEventArray::new(0);

// attach to the sys_enter_openat tracepoint
#[tracepoint(name = "openat")]
pub fn trace_open(ctx: TracePointContext) -> u32 {
    // read the file descriptor argument from the tracepoint context
    let fd: u64 = unsafe {
        ctx.read_at(16).unwrap_or(0) // offset depends on tracepoint format
    };

    info!(&ctx, "openat called with fd={}", fd);

    // send event to user space via perf ring buffer
    unsafe { EVENTS.output(&ctx, &fd, 0) };

    0
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

the user space loader

// src/main.rs (user space)
use aya::{
    Bpf,
    programs::TracePoint,
    maps::perf::AsyncPerfEventArray,
    util::online_cpus,
};
use tokio::signal;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // load the compiled eBPF object file
    let mut bpf = Bpf::load(include_bytes_aligned!(
        "../../target/bpfel-unknown-none/release/my-ebpf-program"
    ))?;

    // get and load the tracepoint program
    let program: &mut TracePoint = bpf
        .program_mut("trace_open")
        .unwrap()
        .try_into()?;
    program.load()?;
    program.attach("syscalls", "sys_enter_openat")?;

    // read events from the perf ring buffer
    let mut perf_array = AsyncPerfEventArray::try_from(
        bpf.take_map("EVENTS").unwrap()
    )?;

    for cpu_id in online_cpus()? {
        let mut buf = perf_array.open(cpu_id, None)?;
        tokio::spawn(async move {
            let mut buffers = vec![bytes::BytesMut::with_capacity(1024); 10];
            loop {
                let events = buf.read_events(&mut buffers).await.unwrap();
                for i in 0..events.read {
                    let data = &buffers[i];
                    println!("openat event on cpu {}", cpu_id);
                }
            }
        });
    }

    println!("tracing openat() calls. press Ctrl+C to stop.");
    signal::ctrl_c().await?;
    Ok(())
}

bpf maps: communicating between kernel and user space

BPF maps are the data sharing mechanism between your kernel and user space programs:

// in kernel space: a hash map counting calls per PID
#[map]
static mut CALL_COUNT: HashMap<u32, u64> = HashMap::with_max_entries(1024, 0);

// increment the counter for this PID
let pid = ctx.pid();
unsafe {
    let count = CALL_COUNT.get(&pid).copied().unwrap_or(0);
    CALL_COUNT.insert(&pid, &(count + 1), 0).ok();
}

what you can build with ebpf

  • network observability: trace every packet, count by IP, detect anomalies
  • security: block syscalls for specific processes (like seccomp but programmable at runtime)
  • profiling: low-overhead continuous profiling without instrumenting your code
  • load balancing: XDP-based load balancer that processes packets before they enter the kernel network stack

Command Palette

Search for a command to run...