この記事は Rust Advent Calendar 2021 11日目の記事です。
「Ayaとは」以降を2024年8月末現在の情報にアップデートしました。(2024.8.31)
はじめに
LKML(Linux Kernel Mailing List)に Rust support パッチシリーズ v2 が最近流れましたね。Linux カーネルを Rust で直接触れるようになる日ももうすぐかなと、クリスマス以上に待ち遠しい限りです。その前に、Linux カーネルの勉強をし直さなくちゃ。
Rust support パッチシリーズ v7 が2022年5月23日に流れ、Linus Torvalds 氏からマージ向けたコメントがありますので、期待して良さそうですね。
もうひとつ、カーネル向けの Rust の営みとして以前から注目していたのが、Rust で eBPF を操ることができる Aya というプロジェクトです。良い機会なので、ちょっとだけ触ってみました。
eBPF とは
eBPF の元となった BPF は、その名前の由来である The BSD Packet Filter(Berkley Packet Filter とも呼ばれる)が示す通り、BSD 版 Unix に実装されたレジスタベースの仮想マシンによるパケットフィルタ機構です。安全かつ効率的にネットワークからのパケットをカーネル内でフィルタリングし、ユーザレベルに送ることができるものです。
現在の Linux カーネルには BPF を再設計した extended BPF(通称 eBPF)が実装されており、ネットワークインターフェースだけでなく、様々なトレースポイントにアタッチすることで、システムのパフォーマンスのモニタリングやセキュリティの制御に利用できる優れものです。
オリジナルの BPF はパケットフィルタリング専用32bit仮想マシンですが、eBPF は JIT コンパイラ付きの汎用64bit仮想マシンになっていて、カーネルに新たな機能を色々と放り込んで遊べそうな感じです。また、BPF verifier を通過した BPF のコード(バイトコードとも呼ばれる)しかロードできないように、しっかりと安全性が保たれるようになっているので、その点も安心です。
eBPF を利用するためには、BCC(BPF Compiler Collection) を用い、C言語で書いた BPF プログラムを BPF バイトコードにコンパイルし、Python、Lua、C++ の何かで書いたユーザレベルのプログラムで、バイトコードのカーネルへのロードとアタッチ、カーネルから取り出した情報の出力を行うという形で使われることが多いようです。ユーザレベルを Go にした gobpf もあります。
ですが、Rustacean としては、ユーザレベルはもちろんのこと、BPF バイトコードも Rust で書かないとね。
くどくど書いてしまいましたが、eBPF について詳しく知りたい方は、ぜひ eBPF.io をお訪ね下さいませ。
Aya とは
Aya は、Alessandro Decina さんが中心となって開発中の eBPF 用ライブラリで、ユーザレベルプログラムも eBPF プログラムも Rust で書くことができます。(OSの標準ライブラリ以外の)C言語で書かれたライブラリや BCC には依存しない、100% pure Rust なライブラリです。ね、いい感じですよね。
他にも Rust から eBPF を触るライブラリはありますが、C言語で書かれた libbpf や BCC に依存しているようです。
それから、ビルドしてできたバイナリに eBPF バイトコードが組み込まれているため、実行時に C言語のビルド環境が必要ありません。さらに、ターゲットを x86_64-unknown-linux-musl にすることで、外部のライブラリに一切依存しないバイナリを生成することもできます。
現在、バージョン 1.0 に向けて絶賛開発中とのことですので、バク出し、改良、ドキュメンテーションなど、どしどし協力していきましょう!
では、さっそく使ってみましょう。
準備
今回の環境はこんな感じです。
- Ubuntu 22.04.4 LTS (x86_64 / aarch64)
- Linux 6.8.0-40-generic
- rustc 1.82.0-nightly (0d634185d 2024-08-29)
- cargo 1.82.0-nightly (8f40fc59f 2024-08-21)
Building eBPF Programs with Aya を読みながら進めましょう。
rustup default nightly
rustup component add rust-src
cargo install bpf-linker
cargo install cargo-generate
以上で準備が整います。
ハードウェア環境が aarch64 の場合には、LLVM 19 の開発用パッケージ
- APT系 : llvm-19-dev, libclang-19-dev, libpolly-19-dev
- RPM系 : llvm-devel, clang-devel
をインストールしてから、cargo install bpf-linker
の代わりに cargo install bpf-linker --no-default-features
にてインストールするよう、bpf-linker の README には書かれています。
ただ、最近の bpf-linker のバージョンと rust nightly の場合、aarch64 環境でも LLVM 19 をインストールせずに cargo install bpf-linker
だけでインストールできるようです。
スケルトンプログラム
プロジェクトの作成
eBPF を使ったパケットフィルタリングの例は本家サイトにあるので、トレースポイントを触ってみることにします。まずは、kill
システムコールの全ての呼び出しを拾い出してみましょう。
新たなプロジェクトを作成します。
cargo generate https://github.com/aya-rs/aya-template
とすると、プロジェクト名を聞いてくるので killsnoop
とキーイン、eBPFプログラムタイプのメニューから tracepoint
を選択、さらにトレースポイントのカテゴリと名前を聞いてくるので、それぞれ、syscalls
、sys_enter_kill
とキーインします。トレースポイントのカテゴリと名前の一覧は /sys/kernel/debug/tracing/events
をみましょう。
$ 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 : killsnoop
🔧 Basedir: /home/xxx/Work/ebpf/killsnoop ...
🔧 Generating template ...
✔ 🤷 Which type of eBPF program? · tracepoint
🤷 Which tracepoint name? (e.g sched_switch, net_dev_queue) : sys_enter_kill
🤷 Which tracepoint category? (e.g sched, net etc...) : syscalls
...
...
🔧 Moving generated files into: `/home/xxx/Work/ebpf/killsnoop`...
💡 Initializing a fresh Git repository
✨ Done! New project created /home/xxx/Work/ebpf/killsnoop
途中、... で省略しましたが、次のようなプログラムのスケルトンが一気に作られます。スケルトンと言いましたが、そのまま build&run できるコードです。
killsnoop
├── Cargo.toml
├── README.md
├── killsnoop --- ユーザレベルプログラム
│ ├── Cargo.toml
│ └── src
│ └── main.rs
├── killsnoop-common --- eBPFとユーザレベルで共有する定義を記述
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── killsnoop-ebpf --- eBPFプログラム
│ ├── Cargo.toml
│ ├── rust-toolchain.toml
│ └── src
│ └── main.rs
└── xtask --- ビルドツールのソースコード
├── Cargo.toml
└── src
├── build_ebpf.rs
├── build.rs
├── main.rs
└── run.rs
自動生成された主なファイルをみてみましょう。
まずは、eBPF プログラムです。
#![no_std]
#![no_main]
use aya_ebpf::{
macros::tracepoint,
programs::TracePointContext,
};
use aya_log_ebpf::info;
#[tracepoint]
pub fn killsnoop(ctx: TracePointContext) -> u32 {
match try_killsnoop(ctx) {
Ok(ret) => ret,
Err(ret) => ret,
}
}
fn try_killsnoop(ctx: TracePointContext) -> Result<u32, u32> {
info!(&ctx, "tracepoint sys_enter_kill called");
Ok(0)
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
eBPF は OS や std ライブラリなしの環境で動かすので、#![no_std]
になります。エントリポイントは #[eBPFプログラムタイプ]
で定義されるようです。crate aya_log_ebpf
のマクロ info!
を使って、ユーザレベルにログイベントを送り込んでいます。
次に、ユーザレベルプログラムです。
use aya::programs::TracePoint;
use aya::{include_bytes_aligned, Bpf};
use aya_log::BpfLogger;
use log::{info, warn, debug};
use tokio::signal;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
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.
#[cfg(debug_assertions)]
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/debug/killsnoop"
))?;
#[cfg(not(debug_assertions))]
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/killsnoop"
))?;
if let Err(e) = BpfLogger::init(&mut bpf) {
// This can happen if you remove all log statements from your eBPF program.
warn!("failed to initialize eBPF logger: {}", e);
}
let program: &mut TracePoint = bpf.program_mut("killsnoop").unwrap().try_into()?;
program.load()?;
program.attach("syscalls", "sys_enter_kill")?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
crate aya
の Bpf::load()
によって eBPF バイトコードがビルド時に読み込まれています。ユーザレベルプログラムの実行時に program.load()
でバイトコードをカーネルに送り込み、syscalls
カテゴリのトレースポイント sys_enter_kill
にアタッチします。スケルトンですから、eBPF プログラムを起動して、Ctrl-C が押されるのを待つようになっています。
ビルド
eBPF プログラムのビルドは cargo xtask build-ebpf
で行います。このコマンドは cd {プロジェクト名}-ebpf; cargo +nightly build --target=bpfel-unknown-none -Z build-std=core
を起動するラッパになっています。
ユーザレベルプログラムのビルドは cargo build
です。ユーザレベルプログラムに eBPF バイトコードが埋め込まれるので、eBPF プログラムを先にビルドする必要があります。
プログラムの起動は RUST_LOG=info cargo xtask run
です。sudo
経由でプログラムが起動されます。
$ RUST_LOG=info cargo xtask run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/xtask run`
Finished `dev` profile [optimized] target(s) in 0.04s
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
[sudo] password for xxxxxx:
[2024-08-31T12:19:26Z INFO killsnoop] Waiting for Ctrl-C...
このよう、INFO でログ出力が止まります。
別のターミナルから、適当に kill
コマンドでシグナルを送ると次のようなログが出力され、正しく動作していることがわかります。
[2024-08-31T12:19:35Z INFO killsnoop] tracepoint sys_enter_kill called
[2024-08-31T12:20:01Z INFO killsnoop] tracepoint sys_enter_kill called
[2024-08-31T12:20:03Z INFO killsnoop] tracepoint sys_enter_kill called
別のターミナルから bpftool
を使って eBPF プログラムの登録状況を確認してみます。
$ sudo bpftool prog list
[sudo] password for xxxxxx:
...
80: tracepoint name killsnoop tag 61ebd1d183566d51 gpl
loaded_at 2024-08-31T21:22:03+0900 uid 0
xlated 1608B jited 952B memlock 4096B map_ids 46,44,45,47
トレースポイントにアタッチされた eBPF プログラムがあるのがわかります。
というわけで、cargo install
2回と cargo generate
1回で、Rust で eBPF をプログラミングする準備が整います。なかなかいい感じです。
eBPFプログラムからユーザレベルへイベントを送信
次に、ユーザレベルへイベントを送信して、ユーザレベルでメッセージを表示するようにしてみましょう。
イベントの型定義
共通部分にイベントの型を定義します。
#![no_std]
#[repr(C)]
#[derive(Copy,Clone)]
pub struct SignalLog {
pub pid: u32, // Process ID
pub tid: u32, // Thread ID
pub tpid: i32, // Target PID
pub tsig: u32, // Signal
pub comm: [u8; 16], // Command Name
}
#[cfg(feature = "user")]
unsafe impl aya::Pod for SignalLog {}
ここでは PerfEventArray
を利用して、ユーザレベルにイベントを送信することにします。
#[map]
static mut EVENTS: PerfEventArray<SignalLog> = PerfEventArray::<SignalLog>::with_max_entries(1024, 0);
情報の抽出とイベントの送信
トレースポイントから得られる情報は /sys/kernel/debug/tracing/events/syscalls/sys_enter_kill/format
に記載されています。
name: sys_enter_kill
ID: 184
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int __syscall_nr; offset:8; size:4; signed:1;
field:pid_t pid; offset:16; size:8; signed:0;
field:int sig; offset:24; size:8; signed:0;
print fmt: "pid: 0x%08lx, sig: 0x%08lx", ((unsigned long)(REC->pid)), ((unsigned long)(REC->sig))
これを元に、ctx
から必要な情報を抜き出し、イベントに組み立て、送信します。
unsafe fn try_killsnoop(ctx: TracePointContext) -> Result<u32, u32> {
let pid = (bpf_get_current_pid_tgid() >> 32).try_into().unwrap();
let tid = bpf_get_current_pid_tgid() as u32;
let tpid = ctx.read_at::<i64>(16).unwrap() as i32;
let tsig = ctx.read_at::<u64>(24).unwrap() as u32;
let comm = bpf_get_current_comm().unwrap();
let log_entry = SignalLog { pid, tid, tpid, tsig, comm };
EVENTS.output(&ctx, &log_entry, 0);
Ok(0)
}
tracefs の情報をみながら、Rust の型に置き換え、read_at()
でオフセットを指定してみました。
イベントの受信
イベントは非同期的に処理します。スケルトンが tokio
を使うようになっていますので、そのまま使っていきましょう。
tokio:main
の中身は概ね次のようになります。
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// eBPFバイトコードをロードしてアタッチする
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)?;
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 SignalLog;
// ptr から指される SignalLog の内容を処理する
}
}
});
}
signal::ctrl_c().await.expect("failed to listen for event");
}
非同期的にイベントを取り出すために AsyncPerfEventArray
を用います(Cargo.toml
で aya = { version = "0.12", features = ["async_tokio"] }
などと指定する必要があります)。そして、CPU ごとに perf buffer を開き、タスクを起こし、イベントを待ち、イベントが到着したら、それらを処理していきます。
ユーザレベルプログラムの全体
use aya::{
Bpf,
include_bytes_aligned,
maps::perf::AsyncPerfEventArray,
programs::TracePoint,
util::online_cpus,
};
use bytes::BytesMut;
use chrono::Local;
use std::convert::TryInto;
use log::debug;
use tokio::{signal, task};
use killsnoop_common::SignalLog;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// 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.
#[cfg(debug_assertions)]
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/debug/killsnoop"
))?;
#[cfg(not(debug_assertions))]
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/killsnoop"
))?;
let program: &mut TracePoint = bpf.program_mut("killsnoop").unwrap().try_into()?;
program.load()?;
program.attach("syscalls", "sys_enter_kill")?;
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)?;
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 SignalLog;
let data = unsafe { ptr.read_unaligned() };
let comm = data.comm.iter().map(|&s| (s as u8) as char).collect::<String>();
let time = Local::now().format("%H:%M:%S").to_string();
println!("{} PID {}({}) -> {}, SIG {}",
time, data.pid, comm, data.tpid, data.tsig);
}
}
});
}
signal::ctrl_c().await.expect("failed to listen for event");
println!("Exiting...");
Ok(())
}
ビルド&ラン
今度は一気に build&run してみましょう。
$ cargo xtask run
...
...
[sudo] password for xxxxxx:
21:38:45 PID 43837(node) -> 43813, SIG 0
21:38:46 PID 43848(node) -> 43813, SIG 0
21:38:46 PID 43734(bash) -> -45585, SIG 19
21:38:46 PID 43865(node) -> 43813, SIG 0
21:38:46 PID 43837(node) -> 43813, SIG 0
...
21:38:56 PID 43837(node) -> 43813, SIG 0
21:38:56 PID 43837(node) -> 43813, SIG 0
21:38:57 PID 43734(bash) -> -45585, SIG 18
21:38:57 PID 43837(node) -> 43813, SIG 0
21:38:57 PID 43837(node) -> 43813, SIG 0
...
^CExiting...
別のターミナルから、sleep 1000 &
を実行し、kill -STOP
, kill -CONT
などをしてみましたが、その模様が見えています。ただ、Visual Studio Code を動かしていると、大量に 0 のシグナルが送られ、延々と続きます。子プロセスの生死をまめに確認しているのでしょうか。こういうときは、eBPF コードの方で 0 のシグナルを無視するようにしてしまいましょう。
unsafe fn try_killsnoop(ctx: TracePointContext) -> Result<u32, u32> {
let tsig = ctx.read_at::<u64>(24).unwrap() as u32;
if tsig == 0 { return Ok(0); }
let tpid = ctx.read_at::<i64>(16).unwrap() as i32;
let pid = (bpf_get_current_pid_tgid() >> 32).try_into().unwrap();
let tid = bpf_get_current_pid_tgid() as u32;
let comm = bpf_get_current_comm().unwrap();
let log_entry = SignalLog { pid, tid, tpid, tsig, comm };
EVENTS.output(&ctx, &log_entry, 0);
Ok(0)
}
eBPF を使うことで、必要としない情報をカーネルレベルで落とすことができるようになります。狙った情報だけをフィルタリングして取得できるので、システムの負荷が高い時に何が起きているかを調べる場合に好都合だと思います。
eBPF プログラムの全体
#![no_std]
#![no_main]
use aya_bpf::{
helpers::{bpf_get_current_pid_tgid, bpf_get_current_comm},
macros::{tracepoint, map},
maps::PerfEventArray,
programs::TracePointContext,
};
use core::convert::TryInto;
use killsnoop_common::SignalLog;
#[map]
static mut EVENTS: PerfEventArray<SignalLog> = PerfEventArray::<SignalLog>::with_max_entries(1024, 0);
#[tracepoint(name="killsnoop")]
pub fn killsnoop(ctx: TracePointContext) -> u32 {
match unsafe { try_killsnoop(ctx) } {
Ok(ret) => ret,
Err(ret) => ret,
}
}
unsafe fn try_killsnoop(ctx: TracePointContext) -> Result<u32, u32> {
let tsig = ctx.read_at::<u64>(24).unwrap() as u32;
if tsig == 0 { return Ok(0); }
let tpid = ctx.read_at::<i64>(16).unwrap() as i32;
let pid = (bpf_get_current_pid_tgid() >> 32).try_into().unwrap();
let tid = bpf_get_current_pid_tgid() as u32;
let comm = bpf_get_current_comm().unwrap();
let log_entry = SignalLog { pid, tid, tpid, tsig, comm };
EVENTS.output(&ctx, &log_entry, 0);
Ok(0)
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
おわりに
今回は Aya のごく一部のトレースポイントのところを触ってみただけですが、なかなか面白い感じです。まだまだたくさんの機能がすでに実装されていますし、aya-gen や aya-log の機能もまだみていません。今後ご紹介できたらなと思っています。
なお、コード全体は GitHub に上げておきます。