はじめに
「RustでeBPFを操れるAyaを触ってみた」という記事で、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 を使うためには以下の準備が必要です。
$ rustup default nightly
$ rustup component add rust-src
$ cargo install bpf-linker
$ cargo install cargo-generate
スケルトンプログラムの作成
cargo generate
で Aya を使うスケルトンプログラムを作成して、それに機能を追加していくことにします。まずは、libc
の pthread_create
にアタッチするプログラムを作ってみます。プロジェクトの名前は threadsnoop
としてみました。
$ 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
というビルダーを利用し、
-
xtask
のビルド-
cargo build --package xtask
)
-
- eBPF プログラムをビルド
./target/debug/xtask build-ebpf
- ユーザレベルのソースコードに 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 としてログが出力されてしまうのが、かなり違和感です。もう少しなんとかならないものでしょうか。
では、ビルド&ランしてみましょう。なお、以下、...
は途中省略していることを表しています。
$ 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...
うーむ、いくら待ってもなんの応答もないですね。
ちなみに、生成されたユーザレベルプログラムは次のような感じです。
ユーザレベルプログラム
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(())
}
なんとなく program.attach(Some("pthread_create"), 0, "libc", pid)
があやしそう。試しに、"libc"
のかわりに "/lib/x86_64-linux-gnu/libc.so.6"
としてみると...
$ 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 のライブラリサーチの部分に弱点がありそうですが、使っている環境にも問題がある感じです。
$ 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
に問題がありそう。
$ 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]
を使って、イベントキューを作ってみます。
#[map]
static mut EVENTS: PerfEventArray<ThreadInfo> = PerfEventArray::<ThreadInfo>::new(0);
struct ThreadInfo
は eBPF とユーザレベルの共通部分に定義します。
#![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 関数を定義します。
#[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()
によってイベントキューに到着したデータを次々に読み込み、いい感じに表示します。
#[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(())
}
それでは、ビルド&ランしてみましょう。
$ 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 の機能を使ってみました。次回は、もう少し実用的な例がご紹介できたらと思っています。
なお、今回のプログラムのソースコードはこちらで公開しています。