13
Help us understand the problem. What are the problem?

posted at

updated at

RustでeBPFを操れるAyaを触ってみた

この記事は Rust Advent Calendar 2021 11日目の記事です。

最新の情報に修正&追記しておきました。(2022.6.26)

はじめに

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)が実装されており、ネットワークインターフェースだけでなく、様々なトレースポイントにアタッチすることで、システムのパフォーマンスのモニタリングやセキュリティの制御に利用できる優れものです。Wireshark で、uid==1001 && syscall=="open" みたいなフィルタリングができるイメージです(Wiresharkでそれができるという意味ではありませんので、念のため)。

オリジナルの 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 用ライブラリで、ユーザレベルプログラムも BPF プログラムも Rust で書くことができます。(OSの標準ライブラリ以外の)C言語で書かれたライブラリや BCC には依存しない、100% pure Rust なライブラリです。ね、いい感じですよね。

他にも Rust から eBPF を触るライブラリはありますが、C言語で書かれた libbpf や BCC に依存しているようです。

それから、ビルドしてできたバイナリに eBPF バイトコードが組み込まれているため、実行時に C言語のビルド環境が必要ありません。さらに、ターゲットを x86_64-unknown-linux-musl にすることで、外部のライブラリに一切依存しないバイナリを生成することもできます。

現在、バージョン 1.0 に向けて絶賛開発中とのことですので、バク出し、改良、ドキュメンテーションなど、どしどし協力していきましょう!

では、さっそく使ってみましょう。

準備

今回の環境はこんな感じです。

  • Ubuntu 20.04.4 LTS (x86_64) / 22.04 LTS (aarch64)
  • Linux 5.13.0-51-generic / 5.15.0-40-generic
  • rustc 1.63.0-nightly (fdca237d5 2022-06-24)
  • cargo 1.63.0-nightly (a5e08c470 2022-06-23)

Building eBPF Programs with Aya を読みながら進めましょう。

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

以上で準備が整います。

ハードウェア環境が aarch64 の場合には、以下の LLVM 14 の開発用パッケージを最初にインストールします。

  • APT系 : llvm-14-dev と libclang-14-dev
  • RPM系 : llvm-devel と clang-devel

その上で、cargo install bpf-linker の代わりに

cargo install --git https://github.com/aya-rs/bpf-linker \
    --tag v0.9.4 --no-default-features --features system-llvm \
    -- bpf-linker

を実行します。

スケルトンプログラム

プロジェクトの作成

eBPF を使ったパケットフィルタリングの例は本家サイトにあるので、トレースポイントを触ってみることにします。まずは、killシステムコールの全ての呼び出しを拾い出してみましょう。

新たなプロジェクトを作成します。

cargo generate https://github.com/aya-rs/aya-template

とすると、プロジェクト名、eBPFプログラムタイプを聞いてくるので、それぞれ、killsnooptracepoint とキーイン、さらにトレースポイントのカテゴリと名前を聞いてくるので、それぞれ、syscallssys_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
      ├── main.rs
      └── run.rs

自動生成された主なファイルをみてみましょう。

まずは、eBPF プログラムです。

killsnoop-V0/killsnoop-ebpf/src/main.rs
#![no_std]
#![no_main]

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

#[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> {
    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プログラムタイプ(name="エントリポイント名")] で定義されるようです。crate aya_log_ebpf のマクロ info! を使って、ユーザレベルにログイベントを送り込んでいます。

次に、ユーザレベルプログラムです。

killsnoop-V0/killsnoop/src/main.rs
use aya::{include_bytes_aligned, Bpf};
use aya::programs::TracePoint;
use aya_log::BpfLogger;
use clap::Parser;
use log::info;
use simplelog::{ColorChoice, ConfigBuilder, LevelFilter, TermLogger, TerminalMode};
use tokio::signal;

#[derive(Debug, Parser)]
struct Opt {}

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

    TermLogger::init(
        LevelFilter::Debug,
        ConfigBuilder::new()
            .set_target_level(LevelFilter::Error)
            .set_location_level(LevelFilter::Error)
            .build(),
        TerminalMode::Mixed,
        ColorChoice::Auto,
    )?;

    // 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"
    ))?;
    BpfLogger::init(&mut bpf)?;
    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 ayaBpf::load() によって eBPF バイトコードがビルド時に読み込まれています。ユーザレベルプログラムの実行時に program.load() でバイトコードをカーネルに送り込み、syscalls カテゴリのトレースポイント sys_enter_kill にアタッチします。スケルトンですから、eBPF プログラムを起動して、Ctrl-C が押されるのを待つようになっています。

crate simplelogTermLogger::init でロガーを初期化し、crate aya_logBpfLogger::initaya-log-ebpf からのログイベントを受けられるようにします。

ビルド

eBPF プログラムのビルドは cargo xtask build-ebpf で行います。このコマンドは cd {プロジェクト名}-ebpf; cargo +nightly build --target=bpfel-unknown-none -Z build-std=core を起動するラッパになっています。
なお、eBPF プログラムのビルドのメッセージがちょっとうるさいと思う方は、xtask/src/build_ebpf.rs 中の "--verbose" の行をコメントアウトしましょう。

ユーザレベルプログラムのビルドは cargo build です。ユーザレベルプログラムに eBPF バイトコードが埋め込まれるので、eBPF プログラムを先にビルドする必要があります。

unused variable の警告が出ることがありますが、特に気にしなくても大丈夫な範囲です。

プログラムの起動は cargo xtask run です。sudo 経由でプログラムが起動されます。

$ cargo xtask run
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/xtask run`
    Finished dev [optimized + debuginfo] target(s) in 0.09s
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
[sudo] password for xxxxxx:
07:30:25 [DEBUG] (1) aya::bpf: [/home/xxx/.cargo/registry/src/github.com-1ecc6299db9ec823/aya-0.11.0/src/bpf.rs:106] [FEAT PROBE] BPF program name support: true
07:30:25 [DEBUG] (1) aya::bpf: [/home/xxx/.cargo/registry/src/github.com-1ecc6299db9ec823/aya-0.11.0/src/bpf.rs:109] [FEAT PROBE] BTF support: true
...
...
07:30:25 [DEBUG] (1) aya::obj::relocation: [/home/xxx/.cargo/registry/src/github.com-1ecc6299db9ec823/aya-0.11.0/src/obj/relocation.rs:363] finished relocating program killsnoop function killsnoop
07:30:25 [INFO] killsnoop: [killsnoop/src/main.rs:43] Waiting for Ctrl-C...

このようにしばらく DEBUG のログが吐かれた後に、INFO でログ出力が止まります。

別のターミナルから、適当に kill コマンドでシグナルを送ると次のようなログが出力され、正しく動作していることがわかります。

07:30:42 [INFO] killsnoop: [src/main.rs:19] tracepoint sys_enter_kill called
07:32:06 [INFO] killsnoop: [src/main.rs:19] tracepoint sys_enter_kill called

別のターミナルから bpftool を使って eBPF プログラムの登録状況を確認してみます。

$ sudo bpftool prog list
[sudo] password for xxxxxx:
...
1020: tracepoint  name killsnoop  tag 58eca218b890c41e  gpl
	loaded_at 2022-06-26T16:37:04+0900  uid 0
	xlated 2392B  jited 1346B  memlock 4096B  map_ids 13,14,16,15

トレースポイントにアタッチされた eBPF プログラムがあるのがわかります。

というわけで、cargo install 2回と cargo generate 1回で、Rust で eBPF をプログラミングする準備が整います。なかなかいい感じです。

eBPFプログラムからユーザレベルへイベントを送信

次に、aya-log を使わずに、ユーザレベルへイベントを送信して、ユーザレベルでメッセージを表示するようにしてみましょう。

イベントの型定義

共通部分にイベントの型を定義します。

killsnoop-V1/killsnoop-common/src/lib.rs
#![no_std]

use aya_bpf::cty::c_char;

#[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: [c_char; 16], // Command Name
}

#[cfg(feature = "user")]
unsafe impl aya::Pod for SignalLog {}

ここでは PerfEventArray を利用して、ユーザレベルにイベントを送信することにします。

killsnoop-V1/killsnoop-ebpf/src/main.rs
#[map(name = "EVENTS")]
static mut EVENTS: PerfEventArray<SignalLog> = PerfEventArray::<SignalLog>::with_max_entries(1024, 0);

情報の抽出とイベントの送信

トレースポイントから得られる情報は /sys/kernel/debug/tracing/events/syscalls/sys_enter_kill/format に記載されています。

/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 から必要な情報を抜き出し、イベントに組み立て、送信します。

killsnoop-V1/killsnoop-ebpf/src/main.rs
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() でオフセットを指定していますが、ここは自動的に設定して欲しいところです。もしかして、他の eBPFプログラムタイプや Aya の他の機能を使えば良いのかもしれません。

イベントの受信

イベントは非同期的に処理します。スケルトンが tokio を使うようになっていますので、そのまま使っていきましょう。

tokio:main の中身は次のようになります。

killsnoop-V1/killsnoop/src/main.rs
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {

    // eBPFバイトコードをロードしてアタッチする

    let mut perf_array = AsyncPerfEventArray::try_from(bpf.map_mut("EVENTS")?)?;

    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 を用います。そして、CPU ごとに perf buffer を開き、タスクを起こし、イベントを待ち、イベントが到着したら、それらを処理していきます。

ユーザレベルプログラムの全体
killsnoop-V1/killsnoop/src/main.rs
use aya::{
    Bpf,
    include_bytes_aligned,
    maps::perf::AsyncPerfEventArray,
    programs::TracePoint,
    util::online_cpus,
};
use bytes::BytesMut;
use chrono::Local;
use std::convert::{TryFrom, TryInto};
use tokio::{signal, task};

use killsnoop_common::SignalLog;

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    #[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.map_mut("EVENTS")?)?;

    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:
23:31:37 PID 488315(bash) -> -1565926, SIG 1
23:31:46 PID 488315(bash) -> -1565926, SIG 19
23:31:54 PID 488315(bash) -> -1565930, SIG 19
23:31:59 PID 488315(bash) -> -1565930, SIG 18
23:32:08 PID 488315(bash) -> -1565930, SIG 9
23:32:14 PID 488315(bash) -> -1565934, SIG 15
00:09:53 PID 1578244(code) -> 1578195, SIG 0
00:09:53 PID 1578244(code) -> 1578195, SIG 0
00:09:53 PID 1578280(code) -> 1578227, SIG 0
00:09:54 PID 1578244(code) -> 1578195, SIG 0
00:09:54 PID 1578244(code) -> 1578195, SIG 0
00:09:55 PID 1578244(code) -> 1578195, SIG 0
00:09:55 PID 1578244(code) -> 1578195, SIG 0
...
^CExiting...

別のターミナルから、sleep 1000 & sleep 1000 & sleep 1000 & を実行し、kill -HUP, kill -STOP, kill -CONT などをしてみましたが、その模様が見えています。ただ、Visual Studio Code を動かしだすと、大量に 0 のシグナルが送られはじめて、延々と続きます。子プロセスの生死をまめに確認しているのでしょうか。0 のシグナルはみなくてもいい場合には、eBPF コードの方を直してしまいましょう。

killsnoop-V1/killsnoop-ebpf/src/main.rs
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 プログラムの全体
killsnoop-V1/killsnoop-ebpf/src/main.rs
#![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(name = "EVENTS")]
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 に上げておきます。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
13
Help us understand the problem. What are the problem?