I'm getting started with eBPF programming with Aya. The idea behind this series of articles is to get you started too.
In this article, we'll explain what eBPF is, what Aya is and what you need to know to use Aya. Finally, we'll set up an environment for developing with Aya.
FYI, this is the English version of an article originally published in French.
What is eBPF?
Definition
I'll try to answer that question throughout the articles. In a few words, I'd say it's programming in the Linux kernel. It allows you to create a kernel program dynamically, i.e. without having to recompile it and reboot the computer to boot on this new kernel. So don't expect to easily create a video game like doom with eBPF.
The first advantage is that the development cycle is much shorter than if you had to modify the Linux kernel directly.
Origin Story
eBPF stands for extended Berkerley Packet Filter. Before eBPF, there was BPF. Berkerley is a university where BPF was founded in 1992, and was concerned solely with the network. You'll see that eBPF (which appeared in 2014 in linux kernel 3.18) allows you to go beyond the network in the kernel. It's a silent revolution on a par with the cgroups and namespaces that led to the creation of Docker and Kubernetes. To find out more about its history, take a look at eBPF's Wikipedia entry or the film:
Examples of simple programs
To give you a more concrete idea of what can be done with eBPF, let's give you a non-exhaustive list of small programs that could be done with eBPF:
- The top or ps commands, which allow you to watch processes and what they occupy in terms of CPU and RAM
- The tcpdump command, which allows you to watch network flows on a server
- The strace command, which allows you to see a process's system calls
- The iptables-type command, which allows you to manage network flows
- fail2ban, which prevents brute force attacks
Examples of well-known software that use eBPF
It would be hard not to talk about eBPF without mentioning Cilium, a CNI plugin for Kubernetes that I've written about in numerous articles. Indeed, Cilium is based on eBPF. Its project is the driving force behind eBPF. Its main aim is to improve network flow performance: unlike other solutions based on iptables, which move back and forth between user space and kernel space, Cilium uses eBPF to stay in kernel space for as long as possible, thereby improving performance. But let's not forget Cilium's observability with the Hubble project, which allows you to see live all the flows between each pod: in a way, a tcpdump for Kubernetes. I could also talk about Cilium's security with Cilium Network Policy, but we're here to talk about eBPF.
There are also other programs you probably know that use eBPF, such as systemd, the initialization system, or falco, a security tool. Your Linux server or Android phone probably has eBPF programs already installed without you even knowing it! Speaking of operating systems, eBPF was originally designed to run exclusively on Linux. But there's a Microsoft-backed project that will enable eBPF to be used on Windows. When will Cilium run on a Windows server?
As you can see, a wide variety of programs can be created. There are three kinds of eBPF software:
- Network (Cilium, load balancer)
- Observability and tracing (top, ps, tcpdump, Hubble)
- Security (fail2ban, falco, systemd)
How do you actually program the kernel?
In a traditional program, we have a source code. From this source code, we create the program, compiling it if it's a compiled language:
In eBPF, there is not one program but two:
- an eBPF code that will be compiled in user space, but which can only run in kernel space
- a user-space code that mainly loads the eBPF binary just compiled into the kernel
Next level
We'll see later that it's possible to communicate between kernel space and user space (via an eBPF map) and also to communicate between kernel space eBPF programs (via tail calls).
Let's talk about Aya
Aya is an eBPF framework that lets you write eBPF programs exclusively in Rust. This simplifies the creation of eBPF programs. For example, the program can be compiled and started from a single command line: everything is transparent.
If you don't know Rust, don't worry, I'll give you the basics for writing eBPF programs. I didn't know Rust at all before writing these articles. So I was at the same point not so long ago. Here are some nice programs written with Aya:
- blixt: a level 4 load balancer created by the Kubernetes SIGs (Special Interest Groups) group ;
- kunai: a security software program for threat management and security monitoring, similar to Falco or Tetragon;
- mybee: a mySQL profiler
You can find more on the dedicated page.
Other choices than Aya
Before deciding which eBPF framework to choose for articles, I spent a long time thinking about which would be the most relevant.
There are two types of eBPF framework:
- those that use two different programming languages: one for kernel space and another for user space
- those that use a single language for both kernel and user space.
For example, Cilium uses the ebpf-go framework. It uses:
- Golang for user space
- C for kernel space
I've opted for the second type of eBPF framework, the one with a single language, to avoid having to teach you two different languages. Unfortunately, there aren't many to choose from:
- libbpf: for C/C++ enthusiasts (first framework release: June 2019, v1: 2022)
- Aya: for shellfish eaters 🦀 (first framework release: June 2021)
- zbpf: for Zig adventurers (first framework version: December 2023)
I was more interested in learning a new language, so I ruled out C. I was tempted by the Zig language because I'd heard good things about it. But I was seduced by Aya because it's starting to mature and has a bigger community than zbpf. There are lots of examples of Aya code all over the Internet. That's important, especially when you're just starting out.
I think Aya is a good compromise between simplicity, modernity and maturity. I was just a little worried about the simplicity of the Rust language. Well, we're going to learn some Rust basics in the next section.
Rust: a complicated language?
In the technical part of this article, I assume you know at least one programming language (at least one like Python). I'm going to show you the syntax and some of its subtleties.
Installation and operation
I installed Rust with rustup.
To compile a file, just do:
rustc hello.rs
To run it:
./hello
Hello Rust
It's traditional when learning a language to see the “hello world”.
fn main(){
println!("Hello World");
}
What's relevant in this program?
- Functions have the identifier fn
- Every program must have a main function
- You can see an exclamation mark at the println “function”
- Don't forget the semicolon at the end of each instruction
Variable in mutation
Now we'll see how to define a variable:
fn main(){
let m = "World";
println!("Hello {}", m);
}
This piece of code does exactly the same thing as before.
What's relevant in this program?
- a variable is defined with the let keyword
- you can add arguments to println. In fact, println isn't really a function, it's a macro from the standard library. These are identified by the exclamation mark.
Now we're going to try and change the value of variable m:
fn main(){
let m = "World";
m = "Aya";
println!("Hello {}", m);
}
This code is incorrect, and the error message is as follows:
If you still don't understand, Rust offers documentation at the end:
rustc --explain E0384
In fact, by default, variables are immutable, i.e. their value cannot be changed. You need to add the mut keyword to make this possible:
fn main(){
let mut m = "World";
m = "Aya";
println!("Hello {}", m);
}
This will now do “Hello Aya”. At compile time, there's a warning because Rust finds it odd to directly assign another value to m without doing a few things first. For example, there will be no warning with:
fn main(){
let mut m = "World";
println!("Hello {}", m);
m = "Aya";
println!("Hello {}", m);
}
There are also constants in Rust, identified by the keyword const. The difference between constants and non-mutable variables is that constants are evaluated at program compilation time, whereas variables are evaluated at program execution time, which is less efficient.
Cargo: the rusty Swiss Army knife
We rarely use rustc directly for “real” projects. Instead, we use cargo. It allows you to compile, download libraries (crates), launch the program, etc.: it's the Rust project manager.
cargo new project
cd project
In Cargo.toml, you can define Rust dependencies.
If you run:
cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.14s
Running `target/debug/project`
Hello, world!
You end up with a “hello world” program. With this command, everything is done automatically: compilation and execution. It's almost as if you were coding in an interpreted language.
To find the binary you've created:
./target/debug/project
Hello, world!
Functions
It's hard to escape functions when you're trying to make clean code that goes beyond a “hello world”.
Here's an example of a function:
fn aya(m: &str){
println!("Hello {}", m);
}
fn main() {
let m = "aya";
aya(m);
}
Note that it is essential to have the type of the arguments: variable_name: variable_type. This is usually the main difficulty when writing a function: “Oh yes, what's the type again?”.
Game, Set and
match is widely used in Rust and therefore in the Aya framework, as it makes conditions clearer than if conditions:
fn main() {
let greeting = "bonjour";
match greeting {
"bonjour" => println!("Hello!"),
"au revoir" => println!("Goodbye!"),
_ => println!("I don't know what you mean."),
}
}
The equivalent code with if:
fn main() {
let greeting = "bonjour";
if greeting == "bonjour" {
println!("Hello!");
} else if greeting == "au revoir" {
println!("Goodbye!");
} else {
println!("I don't know what you mean.");
}
}
The code can be made even more succinct by mixing match and variable:
fn main() {
let greeting = "bonjour";
let trad = match greeting {
"bonjour" => "Hello!",
"au revoir" => "Goodbye!",
_ => "I don't know what you mean.",
};
println!("{}", trad);
}
Remix
Let's finish with a slightly more real-life example that mixes many of the concepts we've already seen with others: reading a text file.
The following code will read the contents of the example.txt file:
use std::fs::File;
use std::io::{self, Read};
fn read_file_contents(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_file_contents("example.txt") {
Ok(contents) => println!("File reading with success:\n{}", contents),
Err(e) => eprintln!("Error during the reading: {}", e),
}
}
the first two lines allow you to use the standard library. It's a condensed notation. It's equivalent to:
use std::fs::File;
use std::io;
use std::io::Read;
In the main function, we have the match we saw earlier, but with a function as parameter. This function will try to read the file. If it succeeds, it passes to Ok(...), otherwise to Err(e), for example if the example.txt file doesn't exist.
Let's look at the read_file_contents function: it returns a Result. In other words, it returns either an Ok(string (the file contents)) if ok, or an error. There's a little subtlety in this line:
let mut file = File::open(path)?;
The question mark. Equivalent to:
let mut file = match File::open(path) {
Ok(f) => f,
Err(e) => return Err(e),
};
We try to open the file, but if we can't, we return an error.
Same thing for:
file.read_to_string(&mut contents)?;
We'll try to convert the file to string and put it in the contents variable, otherwise we'll return an error.
One last subtlety:
Ok(contents)
Notice that there's no semicolon at the end, which isn't a mistake. It's idiomatic. It's the equivalent of:
return Ok(contents);
As you can see, with a 'simple' example, it's easy to read, but if you look a little deeper, there are a lot of Rust notions at play.
We've only partially seen the basics of Rust. We'll continue learning Rust in the next few parts. If you want to learn Rust for larger programs, I recommend you read the Rust book.
Development environment for Aya
We're now going to create our development environment for Aya.
Operating system
I assume you're running Linux (I installed Debian 12). If you're running MacOSX, I recommend lima to create a VM. Otherwise, I've created a lab that follows this tutorial on the Killercoda site:
The basics
You already need to install Rust and all its software (especially cargo) with rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
. "$HOME/.cargo/env" #To have the good PATH
Since Aya is relatively new, we need to configure Rust to access features that are still in development (nightly):
rustup default stable
rustup toolchain add nightly
rustup component add rust-src --toolchain nightly
Libraries for Aya
We're now going to install some Rust libraries.
For the installation of cargo-generate (allowing to create an Aya environment from template), I already have to install ssl libraries:
apt install openssl libssl-dev pkg-config -y
cargo install cargo-generate
You also need to install bpf-linker:
cargo install bpf-linker
Testing your environment
We're now going to check that the installation went smoothly.
We'll clone a repo containing a few examples of Aya programs:
git clone https://github.com/littlejo/aya-examples
cd aya-examples/tracepoint-binary
Finally, we'll start the program:
RUST_LOG=info cargo run
- the RUST_LOG environment variable is used to display information logs directly in the console
- if you forget this environment variable, nothing will be displayed, which can be disturbing.
This will take quite some time: downloading the libraries, compiling them, and so on. Once it's done:
On another terminal, run shell commands such as ls.
At the terminal where the eBPF program was launched, you should see something similar:
This program allows you to observe all the programs running on the machine.
That's all for this part. I hope you've enjoyed the start of this journey into the world of eBPF with Aya.
In the next part, we'll get down to the nitty-gritty and write our first Aya program.
Top comments (0)