第694回ではポータブルなBPFバイナリを作成できる、
BPF CO-REの基本と事前準備
BPF CO-RE
また、
BPFオブジェクト自体はClangを使ってビルドし、
第694回で作成したプログラムは、
BPF CO-REは比較的最近の仕組みであることから、
$ sudo apt install build-essential libbpf-dev clang llvm linux-tools-generic
また第694回で作成した次のファイルを準備しておきます。
Makefile
:BPFバイナリとユーザーランドツールを作成するファイルexecsnoop.
:BPFオブジェクトを作るためのC言語のコードbpf. c execsnoop.
:BPFオブジェクトをロードするユーザーランドのコードc
次の方法でgit cloneできるようにしておきました。
$ git clone https://gitlab.com/mtyshibata/bpf-core-sample.git $ cd bpf-core-smaple $ git checkout r694 v1.0
ちなみにmainブランチの最新の状態は、v1.
タグを付けています。
このリポジトリは、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!
最低限の仕組みはできたので、
ちなみに残念ながらlibbpfにはまだまともなAPIドキュメントがありません。このため何かやりたい場合は、
BPFマップを利用したBPFプログラムとユーザーランドツールの間のデータの送受信
まず最初に、bpf_
でトレースバッファーに出力していましたが、
この方法がわかれば、
最初に比較的簡単な、execsnoop.
)
/* ユーザランドプログラムと送受信するデータ構造 */
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
」.maps
セクションにその情報を保存します。実際に確保するのはカーネルの役目です。BPF_
でリングバッファーであることを明示し、max_
でバッファーのサイズを指定しています。このサイズはbpf_
で変更可能です。
bpf_
は第690回でも出てきましたね。実行中のタスクのPID/
作成したデータは、bpf_
でリングバッファーに保存します。引数は順番に、
最後のフラグはユーザーランドにイベントを通知する際のタイミングをコントロールする場合に使われます。具体的にはリングバッファーに保存したタイミングでユーザーランドにシグナルを送るのか、
次にユーザーランド側のプログラムを確認してみましょう。
/* ユーザランドプログラムと送受信するデータ構造 */
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.バッファー名
」.maps
セクション上のシンボル名です。ユーザーランドプログラムでは、bpf_
でファイルディスクリプターとして開きます。ring_
にはこのファイルディスクリプターの他に、
コンテキストはコールバックの第一引数にそのまま渡されます。オプションは現状では何も使っていないようです。よってここではともにNULLを指定しています。
ring_
でポーリングを行います。何かイベントが届いたら、epoll_
のそれと同じです。つまり-1
」
データ受信時の処理を担う、size
をチェックする必要があるのですが、struct event
だと決め打ちして、
ここまでの変更点は前述のリポジトリで、v1.
タグを付けています。よってリポジトリをclone済みなら、
$ git checkout r695/1 v1.1
あとは実際にビルドして試してみましょう。
$ make $ sudo ./execsnoop PID 3967300 3967301 3967302
ひたすらPIDだけが表示されるようになりました。ちなみにbpf_
を呼ばないようにしたため、sudo cat /sys/
」
ring_buffer_reserve()
/ring_buffer_submit()
でより効率的に処理する
ここまで基本的なデータのやり取りの仕方がわかりました。次に実際にもっと詳細なデータを取得できるようになりたいところですが、
先ほどのBPFプログラムでは、bpf_
を用いてリングバッファーにそのデータを保存していました。このとき、
- バッファーが既に一杯のとき
EAGAIN
でエラーになる - 内部でデータの
memcpy()
が実施される
前者はEAGAIN
が返ってきたらもう一度実施すれば良いだけではあるのですが、
そこで、ring_
/ring_
が用意されています。
ring_
は指定したサイズの領域をリングバッファーに確保してくれるAPIです。戻り値として確保した領域のポインタが返されるため、buffer_ reserve() そこにデータを直接書き込むことでメモリコピーの回数を減らせます。また、 確保失敗時はエラーになるため、 より早い段階で処理を継続するか諦めるかを判断できます。 ring_
は渡されたリングバッファーのアドレスが確定したと判断し、buffer_ submit() 必要に応じてユーザーランドにデータの受信を通知します。
他にも確保したものの、ring_
が使えます。
さて、
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_
の最後の引数は、ring_
の最後の引数は、ring_
と同じ通知タイミングを示すフラグです。
実際にビルドして実行した結果は、v1.
タグを付けています。
一点、ring_
を呼んだあとに、ring_
を呼ぶとします。プログラムBはすぐにring_
でデータを送ったとしても、ring_
を呼び出したあとになります。
ちなみにring_
は内部的に、ring_
/ring_
と同等の処理を一度に行っています。
構造体データへの移植性の高いアクセス方法
BPFオブジェクトの再利用性を高めるために考えなくてはならないのが、
BPF CO-REではそのようなカーネルバージョンの差異を吸収するためのマクロを用意しています。マクロを使ってメンバーにアクセスすることで、
具体的な例として、bpf_
」task
)task->real_
」
そこでBPF CO-REではbpf_
で定義されているBPF_
」BPF_
マクロを利用した表現です。
例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);
これだけ読めば、BPF_
が、BPF_
とBPF_
が用意されていますので、
実際にPPIDを取得するようにした場合の変更点を取り上げます。まず、
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.
」execsnoop.
とexecsnoop.
の双方からインクルードするようにしました。ユーザーランドプログラム側のexecsnoop.
はスケルトンヘッダーファイルexecsnoop.
)
/* SPDX-License-Identifier: CC0-1.0 */
#ifndef __EXECSNOOP_H__
#define __EXECSNOOP_H__
struct event {
pid_t pid;
pid_t ppid;
};
#endif /* __EXECSNOOP_H__ */
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.
タグを付けています。
$ git checkout r695/3 v1.3 $ make $ sudo ./execsnoop PID PPID 6021 2276 6022 6021 6023 2276 6024 6022
PIDだけでなく、
文字列処理の追加
第690回ではPIDとPPIDだけでなく、execve()
を呼び出したプログラム名
まずexecsnoop.
にデータ送受信用のメンバーである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.
は、
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_
はBCCでも使っていたAPIがそのまま使えます。bpf_
は初出ですが、bpf_
と同じで、_str
」ctx->args[0]
」execve()
の第一引数という意味です。
どちらのAPIも失敗時には負の値が返ってきます。そこでその際は何もせずに確保したバッファーをbpf_
だけの処理にしました。
ユーザーランドプログラムであるexecsnoop.
のほうは、
/* データの出力側 */
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.
タグを付けています。
$ 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/
BPFプログラム間のデータのやり取り
BPFマップを使えば、execve()
を実行したPIDのプロセスが終了するまでの時間を取得してみましょう。
今回は少し変更点が多めです。例のごとくリポジトリにv1.
タグとして保存していますので、
今回の変更点のポイントは次のとおりです。
- mapセクションにデータ保存用の連想配列を作る
execve()
が実行されたらその連想配列にPIDをキー、実行開始時刻をバリューとして保存する sched_
トレースポイントから、process_ exit プロセスの終了時の処理を追加する - 上記処理にて連想配列からPIDにマッチする実行開始時刻を取得する
- ユーザーランドにPIDと実行開始時刻その他の情報を通知する
まずexecsnoop.
にデータ送受信用のメンバーである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.
の変更箇所です。
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_
」BPF_
」execve()
を実行したタスクのPIDfork()
後のPID)、execve()
実行時の時刻情報です。時刻情報はbpf_
」
連想配列の保存はbpf_
」
BPF_
:キーが既に存在したらエラーとするNOEXIST (上書き禁止) BPF_
:キーが既に存在しないとエラーとするEXIST (新規作成禁止) BPF_
:キーの存在は問わないANY (新規作成と上書きどちらも許可)
bpf_
が失敗したときは負の値が返ってきます。今回はそのまま処理を継続する方針で実装しています。
これでexecve()
」execsnoop.
の中になります。
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_
」
あとはexecve()
と同じように通知するだけですね。今回はduration
に何らかの値が入っているはずです。
最後にユーザーランドプログラム側execsnoop.
)
/* データの出力側 */
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を使えば、