第694回 ではポータブルなBPFバイナリを作成できる、BPF CO-REのビルド方法を紹介しました。今回はより実践的なコードを使って、いろいろなツールを作ってみましょう。
BPF CO-REの基本と事前準備
BPF CO-RE(Compile Once - Run Everywhere)については第694回 でも言及しましたが、簡単に言うとeBPF とは「カーネルやプロセスの挙動を、それらを再コンパイルすることなく調べるためのツール」であり、BPF CO-RE が「移植性のあるeBPFバイナリを作成するための仕組み」になります。
また、カーネル内部で動くBPFオブジェクト は、libbpf によってカーネルにロードされます。よってユーザーランド側のツールは、libbpfを通してBPFオブジェクトをカーネルに渡し、BPFオブジェクトが生成したデータを収集しなくてはなりません。
BPFオブジェクト自体はClangを使ってビルドし、ユーザーランドツールは好みのコンパイラを使うのが一般的です。今回はどちらもC言語で記述しますが、ユーザーランド側のツールはRustなどを使うケース もあるようです。
第694回 で作成したプログラムは、単にカーネル内部で出力した文字列をトレースバッファーを経由して表示するだけのツールでした。今回はさらに発展して、ユーザーランドとカーネルの間でデータのやり取りができるようにしましょう。
BPF CO-REは比較的最近の仕組みであることから、今回もUbuntu 21.10ベースで話を進めます。あらかじめ次のパッケージをインストールしておいてください。
$ sudo apt install build-essential libbpf-dev clang llvm linux-tools-generic
また第694回 で作成した次のファイルを準備しておきます。
Makefile
:BPFバイナリとユーザーランドツールを作成するファイル
execsnoop.bpf.c
:BPFオブジェクトを作るためのC言語のコード
execsnoop.c
:BPFオブジェクトをロードするユーザーランドのコード
次の方法でgit cloneできるようにしておきました。
$ git clone https://gitlab.com/mtyshibata/bpf-core-sample.git
$ cd bpf-core-smaple
$ git checkout r694 v1.0
ちなみにmainブランチの最新の状態は、本記事での変更を反映したあとのものになっています。変更を反映する前、つまり前回第694回のものはv1.0
タグ を付けています。
このリポジトリは、make
コマンドだけでプログラムをビルドできるようになっています。
$ make
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
clang -g -O2 -Wall -target bpf -D__TARGET_ARCH_x86 -c execsnoop.bpf.c -o execsnoop.bpf.o
llvm-strip -g execsnoop.bpf.o
bpftool gen skeleton execsnoop.bpf.o > execsnoop.skel.h
libbpf: elf: skipping unrecognized data section(3) .rodata.str1.1
cc -g -O2 -Wall -c execsnoop.c -o execsnoop.o
cc -g -O2 -Wall execsnoop.o -lbpf -o execsnoop
実行には管理者権限が必要です。
$ sudo ./execsnoop
何がしかのプログラムがexecve
されると、データが記録されます。この時点でのコードでは、カーネルのトレースバッファーからしかその記録は取得できません。
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
byobu-status-3782063 [002] d... 242751.308364: bpf_trace_printk: Hi, execve!
byobu-status-3782064 [003] d... 242751.315108: bpf_trace_printk: Hi, execve!
byobu-status-3782065 [003] d... 242751.334648: bpf_trace_printk: Hi, execve!
最低限の仕組みはできたので、ここからは第690回 相当のスクリプトになるようカスタマイズしていきましょう。
ちなみに残念ながらlibbpfにはまだまともなAPIドキュメントがありません 。このため何かやりたい場合は、libbpfのコードやBCC/libbpf-tools以下のコードを参照しながら記述することになります。また、manpagesパッケージから提供されるbpf-helpers(7)マニュアル も参考になるはずです。
BPFマップを利用したBPFプログラムとユーザーランドツールの間のデータの送受信
まず最初に、ここまではBPFプログラムの結果をbpf_printk()
でトレースバッファーに出力していましたが、普段はユーザーランドに渡して、必要に応じて出力したいところです。そこで第690回 と同じようにリングバッファーに出力してみましょう[1] 。
[1] 厳密に言うと、第690回ではPERFイベントのバッファーを使っていて、今回はよりジェネリックなリングバッファーを使っています。PERFイベントバッファーはCPUごとにバッファーが独立しているのに対して、リングバッファーはすべてのCPUで同じイベントを共有しています。リングバッファーのほうがモダンで汎用性が高く効率の良い実装になっている ため、今後はリングバッファーを利用することが推奨されているようです。ちなみにlibbpf側のPERFイベントバッファー向けAPIはバージョン0.5あたりから大きく改造が入るようになりました。よってUbuntu 21.10のlibbpf 0.4でうまくいったコードも、より新しい環境でビルドすると「FOO is deprecated」の警告が出るようになるかもしれません。
この方法がわかれば、BPFプログラムから任意のデータ構造をユーザーランドが受け取れるようになります。
最初に比較的簡単な、BPFプログラム(execsnoop.bpf.c
)側の変更です。ここでは変更された箇所だけ抽出しています。
/* ユーザランドプログラムと送受信するデータ構造 */
struct event {
pid_t pid;
};
/* データを格納するリングバッファー */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024 /* 256 KiB */);
} events SEC(".maps");
SEC("tracepoint/syscalls/sys_enter_execve")
int syscalls__execve(struct trace_event_raw_sys_enter *ctx)
{
struct event event = {};
/* execve()呼び出し元のスレッドグループIDの取得 */
event.pid = bpf_get_current_pid_tgid() >> 32;
/* リングバッファーに保存 */
bpf_ringbuf_output(&events, &event, sizeof(event), 0);
return 0;
}
ポイントは次のとおりです。
BPFプログラムとユーザーランドの間でやりとりするデータ構造を決めておく
そのデータは.maps
セクションで定義されたバッファーに保存する
今回は「struct event
」としてデータ構造を作りました。この時点ではPIDのみを保存します。リングバッファーは.maps
セクションにその情報を保存します。実際に確保するのはカーネルの役目です。BPF_MAP_TYPE_RINGBUF
でリングバッファーであることを明示し、max_entries
でバッファーのサイズを指定しています。このサイズはbpf_map__set_max_entries()
で変更可能です。
bpf_get_current_pid_tgid()
は第690回 でも出てきましたね。実行中のタスクのPID/TGIDを取得するAPIで、今回はスレッドグループのIDを表示する想定で、上位32bitの値を使っています。
作成したデータは、bpf_ringbuf_output()
でリングバッファーに保存します。引数は順番に、リングバッファーのポインタ、保存するデータのポインタ、データのサイズ、フラグです。
最後のフラグはユーザーランドにイベントを通知する際のタイミングをコントロールする場合に使われます。具体的にはリングバッファーに保存したタイミングでユーザーランドにシグナルを送るのか、通知を送らないのかを決めます。0を指定した場合は、バッファーの状況に応じて適切にシグナルを送ります。基本は0のままで良いはずです。
次にユーザーランド側のプログラムを確認してみましょう。
/* ユーザランドプログラムと送受信するデータ構造 */
struct event {
pid_t pid;
};
/* リングバッファーからデータ受信時の処理 */
int handle_event_cb(void *ctx, void *data, size_t size)
{
struct event *event = data;
fprintf(stdout, "% 8d\n", event->pid);
return 0;
}
int main(void)
{
struct execsnoop_bpf *obj;
struct ring_buffer *rb = NULL;
int err;
obj = execsnoop_bpf__open();
if (!obj) {
fprintf(stderr, "failed to open BPF object\n");
return 1;
}
if (execsnoop_bpf__load(obj)) {
fprintf(stderr, "failed to load BPF object\n");
goto cleanup;
}
if (execsnoop_bpf__attach(obj)) {
fprintf(stderr, "failed to attach BPF object\n");
goto cleanup;
}
/* リングバッファーのオープン */
rb = ring_buffer__new(bpf_map__fd(obj->maps.events), handle_event_cb, NULL, NULL);
if (!rb) {
rb = NULL;
fprintf(stderr, "failed to open ring buffer: %d\n", err);
goto cleanup;
}
fprintf(stdout, "PID\n");
for (;;) {
/* リングバッファーをポーリングする処理 */
err = ring_buffer__poll(rb, 100 /* ms of timeout */);
if (err < 0 && errno != EINTR) {
fprintf(stderr, "failed perf_buffer_poll(): %s\n", strerror(errno));
goto cleanup;
}
}
cleanup:
/* リングバッファーの解放 */
ring_buffer__free(rb);
execsnoop_bpf__destroy(obj);
return 0;
}
こちらのポイントは次のとおりです。
リングバッファーを開いてポーリングする
データを受信したときのコールバック関数を用意する
リングバッファーは「obj->maps.バッファー名
」でアクセスできます。このバッファー名はBPFプログラム側で指定した.maps
セクション上のシンボル名です。ユーザーランドプログラムでは、このリングバッファーをbpf_map__fd()
でファイルディスクリプターとして開きます。ring_buffer__new()
にはこのファイルディスクリプターの他に、受信時のコールバック関数、コンテキスト、オプションを指定します。
コンテキストはコールバックの第一引数にそのまま渡されます。オプションは現状では何も使っていないようです。よってここではともにNULLを指定しています。
ring_buffer__poll()
でポーリングを行います。何かイベントが届いたら、指定したコールバック関数が呼び出されるというわけです。タイムアウト値はepoll_wait()
のそれと同じです。つまり「-1
」を指定したらイベントが来るまでずっと待ち続けます。
データ受信時の処理を担う、コールバック関数側は受け取ったデータを処理する部分です。リングバッファーでは可変長のデータが届くこともあるため、本来はsize
をチェックする必要があるのですが、今回は特に何もしていません。単に受け取ったデータをstruct event
だと決め打ちして、その中身を表示しています。
ここまでの変更点は前述のリポジトリで、v1.1
タグ を付けています。よってリポジトリをclone済みなら、次の方法でも取得可能です。
$ git checkout r695/1 v1.1
あとは実際にビルドして試してみましょう。
$ make
$ sudo ./execsnoop
PID
3967300
3967301
3967302
ひたすらPIDだけが表示されるようになりました。ちなみにbpf_printk()
を呼ばないようにしたため、同時に別端末で「sudo cat /sys/kernel/debug/tracing/trace_pipe
」を実行しても何も表示されなくなったはずです。
ring_buffer_reserve()
/ring_buffer_submit()
でより効率的に処理する
ここまで基本的なデータのやり取りの仕方がわかりました。次に実際にもっと詳細なデータを取得できるようになりたいところですが、その前により効率的なリングバッファーの使い方を学んでおきましょう。
先ほどのBPFプログラムでは、データを準備したらbpf_ringbuf_output()
を用いてリングバッファーにそのデータを保存していました。このとき、次のような問題が起きる可能性があります。
バッファーが既に一杯のときEAGAIN
でエラーになる
内部でデータのmemcpy()
が実施される
前者はEAGAIN
が返ってきたらもう一度実施すれば良いだけではあるのですが、どれくらい待てば良いかという問題があります。待つだけなら良いのですが、それだけ待ったあとのデータに意味があるのかどうかというのも検討すべきでしょう。後者については、メモリ効率や速度に影響する問題です。
そこで、より効率的なAPIとしてring_buffer_reserve()
/ring_buffer_submit()
が用意されています。
ring_buffer_reserve()
は指定したサイズの領域をリングバッファーに確保してくれるAPIです。戻り値として確保した領域のポインタが返されるため、そこにデータを直接書き込むことでメモリコピーの回数を減らせます。また、確保失敗時はエラーになるため、より早い段階で処理を継続するか諦めるかを判断できます。
ring_buffer_submit()
は渡されたリングバッファーのアドレスが確定したと判断し、必要に応じてユーザーランドにデータの受信を通知します。
他にも確保したものの、使用せずに解放したい場合はring_buffer_discard()
が使えます。
さて、実際にBPFプログラムのコードを書き換えると次のようになります。
int syscalls__execve(struct trace_event_raw_sys_enter *ctx)
{
struct event *event = NULL;
/* リングバッファーの領域確保 */
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 0;
event->pid = bpf_get_current_pid_tgid() >> 32;
/* リングバッファーの中身を確定し、ユーザーランドに通知 */
bpf_ringbuf_submit(event, 0);
return 0;
}
今回の例だとあまり利点は感じられないのですが、使用方法のイメージはつくと思います。ring_buffer_reserve()
の最後の引数は、現時点では使用しないため0にしておかなければなりません。0以外を指定するとエラーとなります。ring_buffer_submit()
の最後の引数は、ring_buffer_output()
と同じ通知タイミングを示すフラグです。
実際にビルドして実行した結果は、変更前と同じになることを確認しておきましょう。ここまでの変更点はリポジトリで、v1.2
タグ を付けています。
一点、注意しなければならないのは、ユーザープログラム側がリングバッファーにアクセスできるのは「予約した順」であり「コミットした順」ではない ということです。たとえば複数のBPFプログラムが同じリングバッファーを共有していて、あるプログラムAがring_buffer_reserve()
を呼んだあとに、別のプログラムBがring_buffer_reserve()
を呼ぶとします。プログラムBはすぐにring_buffer_submit()
でデータを送ったとしても、ユーザーランドプログラムにデータ受信が通知されるのは、プログラムAがring_buffer_submit()
を呼び出したあとになります。
ちなみにring_buffer_output()
は内部的に、ring_buffer_reserve()
/ring_buffer_submit()
と同等の処理を一度に行っています。
構造体データへの移植性の高いアクセス方法
BPFオブジェクトの再利用性を高めるために考えなくてはならないのが、「 構造体のメンバーへのアクセス方法」です。ユーザランドに公開されているデータ構造はともかくとして、カーネル内部で使われているデータ構造は比較的頻繁に更新されます。よってカーネルのバージョンごとのデータ構造を意識しなくてはなりません。
BPF CO-REではそのようなカーネルバージョンの差異を吸収するためのマクロを用意しています。マクロを使ってメンバーにアクセスすることで、BPFオブジェクトは異なるカーネルバージョンでも「それなりに動く」ようになります。
具体的な例として、現在のタスクの親プロセスのPID(PPID)を取得する方法を考えてみましょう。現在のプロセスのタスク構造体は「bpf_get_current_task()
」で取得できます。タスク構造体(task
)が取得できたら、あとは「task->real_parent->tgid
」とたどるだけで親プロセスのPID(この例ではThread Group ID)を取得できます。しかしながらこれだとタスク構造体の中身が変わってしまうときちんとアクセスできない可能性があります。
そこでBPF CO-REではbpf_core_read.h
で定義されている「BPF_CORE_READ()
」という可変長引数を受け取れるマクロを使います。いくつかの例を示します。前段がC言語での一般的な表現で、後段がBPF_CORE_READ()
マクロを利用した表現です。
例1:単純な構造体メンバーへのアロー演算子を利用したアクセス
src->a;
BPF_CORE_READ(src, a);
例2:多段のメンバーアクセス
src->a->b->c->d->e;
BPF_CORE_READ(src, a, b, c, d, e);
例3:アロー演算子とドット演算子の混在
src->a.b->c.d.e->f;
BPF_CORE_READ(src, a.b, c.d.e, f);
これだけ読めば、おおよそ使い方はイメージできるでしょう。なお、C言語のマクロ表現の制約上、アクセスできるメンバーの段数は9段までとなっています。またNULL終端された文字列を取得したいならBPF_CORE_READ_STR_INTO()
が、ユーザー空間のメモリ用にBPF_CORE_READ_USER()
とBPF_CORE_READ_USER_STR_INTO()
が用意されていますので、用途に応じて使い分けると良いでしょう。
実際にPPIDを取得するようにした場合の変更点を取り上げます。まず、BPFプログラムは次のようになります。
struct task_struct *task;
/* リングバッファーの領域確保 */
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 0;
event->pid = bpf_get_current_pid_tgid() >> 32;
/* 現在のタスク構造体取得 */
task = (struct task_struct *)bpf_get_current_task();
/* task->real_parent->tgidへのアクセス */
event->ppid = (pid_t)BPF_CORE_READ(task, real_parent, tgid);
ちなみに共通の構造体定義は「execsnoop.h
」として共通のヘッダーファイルにまとめ、execsnoop.bpf.c
とexecsnoop.c
の双方からインクルードするようにしました。ユーザーランドプログラム側のexecsnoop.h
はスケルトンヘッダーファイル(execsnoop.skel.h
)よりあとにインクルードするようにしてください。ヘッダーファイルは次のようになっています。
/* SPDX-License-Identifier: CC0-1.0 */
#ifndef __EXECSNOOP_H__
#define __EXECSNOOP_H__
struct event {
pid_t pid;
pid_t ppid;
};
#endif /* __EXECSNOOP_H__ */
ppid
メンバーが増えただけですね。またユーザーランドプログラム側も、そのPPIDも一緒に出力するようにしただけです。
int handle_event_cb(void *ctx, void *data, size_t size)
{
struct event *event = data;
fprintf(stdout, "% 8d % 8d\n", event->pid, event->ppid);
return 0;
}
int main(void)
(中略)
fprintf(stdout, "%-8s %-8s\n", "PID", "PPID");
実際にビルドして実行しみましょう。リポジトリではv1.3
タグ を付けています。
$ git checkout r695/3 v1.3
$ make
$ sudo ./execsnoop
PID PPID
6021 2276
6022 6021
6023 2276
6024 6022
PIDだけでなく、PPIDも表示されるようになりました。
文字列処理の追加
第690回 ではPIDとPPIDだけでなく、execve()
を呼び出したプログラム名(COMM)と、その引数である実行予定のファイル名(FNAME)も表示していました。これにも対応してみましょう。ここまでの内容を理解していたら、そこまで難しくはないでしょう。
まずexecsnoop.h
にデータ送受信用のメンバーであるcomm
とfname
を追加します。今回は単なる固定長の文字列です。comm
のほうはカーネルのタスク構造体に合わせたサイズにし、fname
のほうは適当な長さに決めました。
#define TASK_COMM_LEN 16 /* from linux/sched.h */
struct event {
pid_t pid;
pid_t ppid;
char comm[TASK_COMM_LEN];
char fname[32];
};
BPFプログラムであるexecsnoop.bpf.c
は、データを取得するAPIを呼ぶだけです。
int syscalls__execve(struct trace_event_raw_sys_enter *ctx)
(中略)
if (bpf_get_current_comm(&event->comm, sizeof(event->comm)) < 0)
goto err;
if (bpf_core_read_user_str(event->fname, sizeof(event->fname), (const char*)ctx->args[0]) < 0)
goto err;
bpf_ringbuf_submit(event, 0);
return 0;
err:
bpf_ringbuf_discard(event, 0);
return 0;
bpf_get_current_comm()
はBCCでも使っていたAPIがそのまま使えます。bpf_core_read_user_str()
は初出ですが、使い方自体はbpf_probe_read_user_str()
と同じで、指定した文字列変数にユーザー空間のアドレスにある文字列をコピーしてくれるだけです[2] 。また「_str
」で終わるAPIはNULL終端されていることが保証されます。「 ctx->args[0]
」はexecve()
の第一引数という意味です。
どちらのAPIも失敗時には負の値が返ってきます。そこでその際は何もせずに確保したバッファーをbpf_ringbuf_discard()
だけの処理にしました。
ユーザーランドプログラムであるexecsnoop.c
のほうは、受け取った結果をただ表示するだけです。
/* データの出力側 */
fprintf(stdout, "% 8d % 8d %-16s %-32s\n", event->pid, event->ppid, event->comm, event->fname);
/* 実行時に最初に表示されるヘッダー */
fprintf(stdout, "%-8s %-8s %-16s %-32s\n", "PID", "PPID", "COMM", "FNAME");
このあたりは好みに合わせて調整してください。毎度のことながら、実際にビルドして実行しみましょう。リポジトリではv1.4
タグ を付けています。
$ git checkout r695/4 v1.4
$ make
$ sudo ./execsnoop
PID PPID COMM FNAME
547716 2276 tmux: server /bin/sh
547717 547716 sh /usr/bin/byobu-status
547718 2276 tmux: server /bin/sh
547719 547717 byobu-status /usr/bin/sed
547720 547718 sh /usr/bin/byobu-status
これでほぼBCC/Python版だった第690回 と同じ出力結果になりました。
BPFプログラム間のデータのやり取り
BPFマップを使えば、異なるBPFプログラム間のデータのやりとりにも使えます。そこで最後にBPFマップを用いて、特定のトレースポイントで取得した情報を別のトレースポイントから参照する例として、execve()
を実行したPIDのプロセスが終了するまでの時間を取得してみましょう。
今回は少し変更点が多めです。例のごとくリポジトリにv1.5
タグ として保存していますので、実際のコードはそちらも参照してください。
今回の変更点のポイントは次のとおりです。
mapセクションにデータ保存用の連想配列を作る
execve()
が実行されたらその連想配列にPIDをキー、実行開始時刻をバリューとして保存する
sched_process_exit
トレースポイントから、プロセスの終了時の処理を追加する
上記処理にて連想配列からPIDにマッチする実行開始時刻を取得する
ユーザーランドにPIDと実行開始時刻その他の情報を通知する
まずexecsnoop.h
にデータ送受信用のメンバーであるduration
を追加します。今回はexecve()
が呼ばれたときと、プロセス終了時で同じデータ構造を使うことにします。execve()
が呼ばれたときは、duration
を0としておくことでユーザーランドプログラムはどちらのイベントかを判断できるわけです。ちなみにduration
の単位はナノ秒です。
struct event {
pid_t pid;
pid_t ppid;
char comm[TASK_COMM_LEN];
char fname[32];
__u64 duration;
};
次にBPFプログラムであるexecsnoop.bpf.c
の変更箇所です。
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start SEC(".maps");
int syscalls__execve(struct trace_event_raw_sys_enter *ctx)
{
struct event *event = NULL;
struct task_struct *task;
pid_t pid;
u64 ts;
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 0;
/* PIDの取得 */
pid = event->pid = bpf_get_current_pid_tgid() >> 32;
(中略)
/* durationの初期化およびマップへのPIDと開始時刻の保存 */
event->duration = 0;
ts = bpf_ktime_get_ns();
bpf_map_update_elem(&exec_start, &pid, &ts, BPF_ANY);
まず最初に時刻情報を保存するための「exec_start
」マップを作成しています。連想配列は「BPF_MAP_TYPE_HASH
」を指定することで作成でき、あとはサイズとキー・バリューそれぞれの型を指定しているだけです。今回はキーがexecve()
を実行したタスクのPID(一般的にはfork()
後のPID) 、バリューがexecve()
実行時の時刻情報です。時刻情報は「bpf_ktime_get_ns()
」で取得しています。
連想配列の保存は「bpf_map_update_elem(マップ名, キー, バリュー, フラグ)
」を使います。ここでフラグとして指定できるのは次の3種類です。
BPF_NOEXIST
:キーが既に存在したらエラーとする(上書き禁止)
BPF_EXIST
:キーが既に存在しないとエラーとする(新規作成禁止)
BPF_ANY
:キーの存在は問わない(新規作成と上書きどちらも許可)
bpf_map_update_elem()
が失敗したときは負の値が返ってきます。今回はそのまま処理を継続する方針で実装しています。
これで「execve()
」が呼ばれるたびに、PIDとその時刻が保存されるようになりました。次はプロセス終了時の処理です。こちらは完全に新規の関数になります。ファイルはexecsnoop.bpf.c
の中になります。
SEC("tracepoint/sched/sched_process_exit")
int sched_process_exit(struct trace_event_raw_sched_process_template* ctx)
{
pid_t pid;
u64 *start;
u64 end = 0, duration = 0;
struct event *event = NULL;
struct task_struct *task;
/* 呼び出し時刻とPIDの取得 */
end = bpf_ktime_get_ns();
pid = bpf_get_current_pid_tgid() >> 32;
/* PIDに一致するキーの、バリュー(開始時刻の情報)を取得。見つからなければ何もしない */
start = bpf_map_lookup_elem(&exec_start, &pid);
if (!start)
return 0;
duration = end - *start;
/* 連想配列からエントリーを削除 */
bpf_map_delete_elem(&exec_start, &pid);
/* ユーザーランドへの通知処理 */
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 0;
task = (struct task_struct *)bpf_get_current_task();
event->ppid = (pid_t)BPF_CORE_READ(task, real_parent, tgid);
if (bpf_get_current_comm(&event->comm, sizeof(event->comm)) < 0)
goto err;
event->fname[0] = '\0';
event->duration = duration;
bpf_ringbuf_submit(event, 0);
return 0;
err:
bpf_ringbuf_discard(event, 0);
return 0;
}
後半はexecve()
と同じであり、ポイントは前半の処理です。BPFマップの連想配列からは「bpf_map_lookup_elem(連想配列, キー)
」で検索できます。見つからなかったらNULLが返ります。戻ってくるのはバリューのポインタなのでそれをそのまま時刻情報として使っています。
エントリーが不要になったら「bpf_map_delete_elem(連想配列, キー)
」で削除してください。
あとはexecve()
と同じように通知するだけですね。今回はduration
に何らかの値が入っているはずです。
最後にユーザーランドプログラム側(execsnoop.c
)の変更点です。といってもこちらは今回も出力フォーマットとヘッダーを変えただけです。
/* データの出力側 */
fprintf(stdout, "%-5s % 8d % 8d % 12lld %-16s %-32s\n",
event->duration ? "END" : "START",
event->pid, event->ppid, event->duration,
event->comm, event->fname);
/* 実行時に最初に表示されるヘッダー */
fprintf(stdout, "%-5s %-8s %-8s %-12s %-16s %-32s\n",
"STATE", "PID", "PPID", "DURATION", "COMM", "FNAME");
実際に実行してみましょう。
$ git checkout r695/5 v1.5
$ make
$ sudo ./execsnoop
STATE PID PPID DURATION COMM FNAME
START 662566 2276 0 tmux: server /bin/sh
START 662567 662566 0 sh /usr/bin/byobu-status
START 662568 2276 0 tmux: server /bin/sh
START 662569 662567 0 byobu-status /usr/bin/sed
START 662570 662568 0 sh /usr/bin/byobu-status
END 0 662567 2197624 sed
START 662572 662567 0 byobu-status /usr/bin/tmux
START 662573 662570 0 byobu-status /usr/bin/sed
END 0 662570 2137350 sed
START 662575 662570 0 byobu-status /usr/bin/tmux
END 0 662567 6393921 tmux: client
END 0 662566 17840702 byobu-status
どうやら無事にプロセス終了時の経過時間が表示されているようです。
このようにeBPFを使えば、カーネルのさまざまな情報をユーザーランドから取得できるようになります。BPF CO-REに限って言うと、まだ使えるカーネルは限定的ではあるものの、Ubuntu 22.04 LTSが本格的に使われるようになる頃には、重要なツールになってくることでしょう。