Ubuntu Weekly Recipe

第695回入門BPF CO-RE

第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]⁠。

この方法がわかれば、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.cexecsnoop.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にデータ送受信用のメンバーであるcommfnameを追加します。今回は単なる固定長の文字列です。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が本格的に使われるようになる頃には、重要なツールになってくることでしょう。

おすすめ記事

記事・ニュース一覧