RustでeBPFを操れるAyaでuprobeしてみる

# はじめに

「[RustでeBPFを操れるAyaを触ってみた](https://qiita.com/moriai/items/c96636d35ae39809b45e)」という記事で、Rust で eBPF のプログラムを書いて、Linux カーネルが提供する tracepoint にアタッチしてシステムコールの呼び出しをトレースするという話を書きましたが、引き続き、ユーザレベルのライブラリコールをトレースする話を書いてみたいと思います。

# 準備

今回の環境は以下の通りです。

+ Ubuntu 24.04.1 LTS (amd64) / Ubuntu 22.04.5 LTS (aarch64)
+ Linux 6.12.7 (メインラインのソースを独自ビルド) / 6.8.0-50-generic
+ rustc 1.85.0-nightly (dd84b7d5e 2024-12-27)
+ cargo 1.85.0-nightly (c86f4b3a1 2024-12-24)

Aya を使うためには以下の準備が必要です。

```shell
$ rustup default nightly
$ rustup component add rust-src
$ cargo install bpf-linker
$ cargo install cargo-generate
```

# スケルトンプログラムの作成

`cargo generate` で Aya を使うスケルトンプログラムを作成して、それに機能を追加していくことにします。まずは、`libc` の `pthread_create` にアタッチするプログラムを作ってみます。プロジェクトの名前は `threadsnoop` としてみました。

```shell
$ mkdir aya-rs
$ cd aya-rs
$ cargo generate https://github.com/aya-rs/aya-template
⚠️   Favorite `https://github.com/aya-rs/aya-template` not found in config, using it as a git repository: https://github.com/aya-rs/aya-template
🤷   Project Name: threadsnoop
🔧   Destination: /home/xxxxxx/aya-rs/threadsnoop ...
🔧   project-name: threadsnoop ...
🔧   Generating template ...
✔ 🤷   Which type of eBPF program? · uprobe
🤷   Target to attach the (u|uret)probe? (e.g libc): libc
🤷   Function name to attach the (u|uret)probe? (e.g getaddrinfo): pthread_create
🔧   Moving generated files into: `/home/xxxxxx/aya-rs/threadsnoop`...
🔧   Initializing a fresh Git repository
✨   Done! New project created /home/xxxxxx/aya-rs/threadsnoop
$ cd threadsnoop
$ tree
$ tree
.
├── Cargo.toml
├── README.md
├── rustfmt.toml
├── threadsnoop               --- ユーザレベルプログラム
│   ├── Cargo.toml
│   ├── build.rs
│   └── src
│       └── main.rs
├── threadsnoop-common        --- eBPFとユーザレベルで共有する定義を記述
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── threadsnoop-ebpf          --- eBPFプログラム
    ├── Cargo.toml
    ├── build.rs
    └── src
        ├── lib.rs
        └── main.rs

7 directories, 12 files
```

以前はあった `xtask` ディレクトリと配下のファイルがなくなってしまいました。これまでは `xtask` というビルダーを利用し、

1. `xtask` のビルド
    - `cargo build --package xtask`）
2. eBPF プログラムをビルド
    - `./target/debug/xtask build-ebpf`
3. ユーザレベルのソースコードに eBPF プログラムを組み込んでユーザレベルのプログラムをビルド
    - `./target/debug/xtask build`

という手順で Aya のアプリケーションをビルドするようになっていました。1〜3 をまとめて `cargo xtask build` で実行できますが、ビルドに時間がかかるし、`cargo clean` をした後、毎回 `xtask` がビルドされるし、やはり、`cargo build` 一発でビルドしたいですよね。

ということで、昨年の秋ごろに Aya の中にビルドシステムを作り込み、`build.rs` 経由で `cargo +nightly build --package eBPFパッケージ名 -Z build-std=core --bins --message-format=json --release --target=bpfel-unknown-none` を起動して、eBPFプログラムをビルドするようになりました。このおかげで、`cargo build` 一発で全体がビルドできるようになったため、`xtask` 以下のファイルが不要となりました。ですが、`build.rs` 経由のビルドだと、リアルタイムにビルド中のメッセージが出力されず、ビルド完了後に一気に warning としてログが出力されてしまうのが、かなり違和感です。もう少しなんとかならないものでしょうか。

では、ビルド＆ランしてみましょう。なお、以下、`...` は途中省略していることを表しています。

```shell
$ RUST_LOG=info cargo run --release --config 'target."cfg(all())".runner="sudo -E"'
   Compiling proc-macro2 v1.0.92
   Compiling unicode-ident v1.0.14
   ...
   Compiling threadsnoop v0.1.0 (/home/xxxxxx/aya-rs/threadsnoop/threadsnoop)
warning: threadsnoop@0.1.0:    Compiling proc-macro2 v1.0.92
warning: threadsnoop@0.1.0:    Compiling unicode-ident v1.0.14
   ...
warning: threadsnoop@0.1.0:     Finished `release` profile [optimized] target(s) in 7.78s
    Finished `release` profile [optimized] target(s) in 17.73s
     Running `sudo -E target/release/threadsnoop`
[sudo] password for xxxxxx: 
Waiting for Ctrl-C...
^CExiting...
```

うーむ、いくら待ってもなんの応答もないですね。

ちなみに、生成されたユーザレベルプログラムは次のような感じです。

<details><summary>ユーザレベルプログラム</summary><div>

```rust:threadsnoop/threadsnoop/src/main.rs
use aya::programs::UProbe;
use clap::Parser;
#[rustfmt::skip]
use log::{debug, warn};
use tokio::signal;

#[derive(Debug, Parser)]
struct Opt {
    #[clap(short, long)]
    pid: Option<i32>,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let opt = Opt::parse();

    env_logger::init();

    // Bump the memlock rlimit. This is needed for older kernels that don't use the
    // new memcg based accounting, see https://lwn.net/Articles/837122/
    let rlim = libc::rlimit {
        rlim_cur: libc::RLIM_INFINITY,
        rlim_max: libc::RLIM_INFINITY,
    };
    let ret = unsafe { libc::setrlimit(libc::RLIMIT_MEMLOCK, &rlim) };
    if ret != 0 {
        debug!("remove limit on locked memory failed, ret is: {}", ret);
    }

    // This will include your eBPF object file as raw bytes at compile-time and load it at
    // runtime. This approach is recommended for most real-world use cases. If you would
    // like to specify the eBPF program at runtime rather than at compile-time, you can
    // reach for `Bpf::load_file` instead.
    let mut ebpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
        env!("OUT_DIR"),
        "/threadsnoop"
    )))?;
    if let Err(e) = aya_log::EbpfLogger::init(&mut ebpf) {
        // This can happen if you remove all log statements from your eBPF program.
        warn!("failed to initialize eBPF logger: {}", e);
    }
    let Opt { pid } = opt;
    let program: &mut UProbe = ebpf.program_mut("threadsnoop").unwrap().try_into()?;
    program.load()?;
    program.attach(Some("pthread_create"), 0, "libc", pid)?;

    let ctrl_c = signal::ctrl_c();
    println!("Waiting for Ctrl-C...");
    ctrl_c.await?;
    println!("Exiting...");

    Ok(())
}
```
</div></details>


なんとなく `program.attach(Some("pthread_create"), 0, "libc", pid)` があやしそう。試しに、`"libc"` のかわりに `"/lib/x86_64-linux-gnu/libc.so.6"` としてみると...

```shell
$ RUST_LOG=info cargo run --release --config 'target."cfg(all())".runner="sudo -E"'
...
    Finished `release` profile [optimized] target(s) in 1m 13s
     Running `sudo -E target/release/threadsnoop`
[sudo] password for xxxxxx:
Waiting for Ctrl-C...
[INFO  threadsnoop] function pthread_create called by libc
[INFO  threadsnoop] function pthread_create called by libc
[INFO  threadsnoop] function pthread_create called by libc
[INFO  threadsnoop] function pthread_create called by libc
[INFO  threadsnoop] function pthread_create called by libc
...
^CExiting...
```

`pthread_create` が呼び出されるたびに、ログが出力されるようになりました。

ということで、Aya のライブラリサーチの部分に弱点がありそうですが、使っている環境にも問題がある感じです。

```shell
$ ldconfig -p | grep libc.so.6
	libc.so.6 (libc6,x32) => /libx32/libc.so.6
	libc.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libc.so.6
	libc.so.6 (libc6) => /lib32/libc.so.6
```

ということで、32bit 環境の libc も ld.so.cache に載ってきています。これは、`ld.so.conf` に問題がありそう。

```shell
$ ls -l /etc/ld.so.conf.d
total 20
-rw-r--r-- 1 root root  38 Mar  6  2022 fakeroot-x86_64-linux-gnu.conf
-rw-r--r-- 1 root root  44 Dec 16  2020 libc.conf
-rw-r--r-- 1 root root 100 Mar  4  2022 x86_64-linux-gnu.conf
-rw-r--r-- 1 root root  56 May  7  2024 zz_i386-biarch-compat.conf
-rw-r--r-- 1 root root  58 Aug  8 23:47 zz_x32-biarch-compat.conf
$ cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf
```

私がいじったわけではなく、もともとそうなっていたのですが、`zz_*.conf` でコメントアウトしたつもりが、そうはなっていない、ということですね。`zz_*.conf` を `*.conf.disabled` などとリネームし、`sudo ldconfig` すれば、元のままの `program.attach(Some("pthread_create"), 0, "libc", pid)` で問題なく動作します。

# 機能の追加

それでは、スケルトンプログラムに機能を追加して、`pthread_create` 以外に `pthread_detach`、`pthread_join` などもトレースし、かつ、それぞれの引数や呼び出された時刻なども表示できるようにしてみましょう。

まず、eBPF プログラムからユーザレベルへイベントを通知する仕組みを作っていきます。ここでは、Aya が提供する `#[map]` を使って、イベントキューを作ってみます。

```rust:threadsnoop-ebpf/src/main.rs
#[map]
static mut EVENTS: PerfEventArray<ThreadInfo> = PerfEventArray::<ThreadInfo>::new(0);
```

`struct ThreadInfo` は eBPF とユーザレベルの共通部分に定義します。

```rust:threadsnoop-common/src/lib.rs
#![no_std]

pub enum ThreadFunc {
    Create, Detach, Exit, Join,
}

#[cfg(feature = "user")]
impl ThreadFunc {
    pub fn name(self) -> &'static str {
        match self {
            ThreadFunc::Create  => "create",
            ThreadFunc::Detach  => "detach",
            ThreadFunc::Exit    => "exit",
            ThreadFunc::Join    => "join",
        }
    }
}

#[repr(C)]
pub struct ThreadInfo {
    pub ts: u64,            // Timestamp
    pub pid: u32,           // Process ID
    pub tid: u32,           // Thread ID
    pub comm: [u8; 16],     // Command Name
    pub target: u64,        // Target thread
    pub func: ThreadFunc,   // Thread function
}
```

`target` のところは、`union` か `enum` にしたいところですが、取り急ぎ動けば良いということで、お許しください。

pthread の関数ごとに（興味のある引数が異なるので）uprobe するための eBPF 関数を定義します。

```rust:threadsnoop-ebpf/src/main.rs
#[uprobe]
pub fn probe_pthread_create(ctx: ProbeContext) -> u32 {
    let target = ctx.arg(2).unwrap_or(0u64);
    match try_probe_pthread_func(ctx, target, ThreadFunc::Create) {
        Ok(ret) => ret,
        Err(ret) => ret,
    }
}

#[uprobe]
pub fn probe_pthread_detach(ctx: ProbeContext) -> u32 {
    let target = ctx.arg(0).unwrap_or(0u64);
    match try_probe_pthread_func(ctx, target, ThreadFunc::Detach) {
        Ok(ret) => ret,
        Err(ret) => ret,
    }
}

...

fn try_probe_pthread_func(ctx: ProbeContext, target: u64, func: ThreadFunc) -> Result<u32, u32> {
    let ts = unsafe { bpf_ktime_get_ns() };
    let tid = bpf_get_current_pid_tgid() as u32;
    let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
    let comm = bpf_get_current_comm().unwrap();

    let info = ThreadInfo { ts, pid, tid, comm, target, func};
    unsafe { EVENTS.output(&ctx, &info, 0) };

    Ok(0)
}

#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}
```

ユーザレベルプログラムでは、`aya::Ebpf::load(aya::include_bytes_aligned!())` によって eBPF プログラムをプログラム中に埋め込み、uprobe を libc の関数にアタッチした後、`buf.read_events()` によってイベントキューに到着したデータを次々に読み込み、いい感じに表示します。

```rust:threadsnoop/src/main.rs
#[tokio::main]
async fn main() -> anyhow::Result<()> {

    let mut ebpf = aya::Ebpf::load(aya::include_bytes_aligned!(concat!(
        env!("OUT_DIR"),
        "/threadsnoop"
    )))?;

    let program_0: &mut UProbe = ebpf.program_mut("probe_pthread_create").unwrap().try_into()?;
    program_0.load()?;
    program_0.attach(Some("pthread_create"), 0, "libc", pid)?;

    let program_1: &mut UProbe = ebpf.program_mut("probe_pthread_detach").unwrap().try_into()?;
    program_1.load()?;
    program_1.attach(Some("pthread_detach"), 0, "libc", pid)?;

    ...

    let start = gettime();
    let ctrl_c = signal::ctrl_c();
    println!("Waiting for Ctrl-C...");

    let mut perf_array = AsyncPerfEventArray::try_from(ebpf.take_map("EVENTS").unwrap())?;

    for cpu_id in online_cpus().map_err(|(_, error)| error)? {
        let mut buf = perf_array.open(cpu_id, None)?;

        task::spawn(async move {
            let mut buffers = (0..10)
                .map(|_| BytesMut::with_capacity(1024))
                .collect::<Vec<_>>();

            loop {
                let events = buf.read_events(&mut buffers).await.unwrap();
                for i in 0..events.read {
                    let buf = &mut buffers[i];
                    let ptr = buf.as_ptr() as *const ThreadInfo;
                    let threadinfo: ThreadInfo = unsafe { ptr.read_unaligned() };
                    // threadinfo の中身をいい感じに出力する
                }
            }
        });
    }
    ctrl_c.await?;
    println!("Exiting...");
    Ok(())
}
```

それでは、ビルド＆ランしてみましょう。

```shell
$ cargo run --release --config 'target."cfg(all())".runner="sudo -E"'
...
    Finished `release` profile [optimized] target(s) in 17.82s
     Running `sudo -E target/release/threadsnoop`
[sudo] password for xxxx: 
Waiting for Ctrl-C...
  2.738185926  987204  987204 polkitd          create 0x5653f0238700
  2.738405633  987204  987204 polkitd          join   0x76513b7fe6c0
  2.811372755  987204  987204 polkitd          create 0x5653f0238700
  2.811561353  987204  987204 polkitd          join   0x76513b7fe6c0
  3.157561281 3352487 3352487 fprintd          create 0x79ee5c8c6c30
  3.157647544 3352487 3352487 fprintd          create 0x79ee5c8c6c30
  3.158670044 3352487 3352487 fprintd          create 0x79ee5c8c6c30
  3.160144069 3352487 3352487 fprintd          create 0x79ee58ef2d30
  3.168543630 3352487 3352487 fprintd          create 0x79ee5c8c6c30
  3.178979995    3289    3305 pool-spawner     create 0x78f89a654c30
  3.183159993    3289    3305 pool-spawner     create 0x78f89a654c30
...
```

出力の各カラムは、プログラム実行時からの相対時刻、PID、TID、コマンド名（プログラム系）、スレッドのcreate/join/detach/exit の種別、ターゲットアドレス（createの場合はスレッドとして起動する関数のアドレス、それ以外は `pthread_t` の値）です。ターゲットアドレスはシンボル名に変換して、わかりやすくしたいところですが、汎用的なデバッガを作りたいわけではないので、目的に合わせて、最適な出力となるような工夫をしたいところです。

# おわりに

今回は Aya の uprobe の機能を使ってみました。次回は、もう少し実用的な例がご紹介できたらと思っています。
なお、今回のプログラムのソースコードは[こちら](https://github.com/moriai/threadsnoop)で公開しています。