Ubuntu Weekly Recipe

第695回 入門BPF CO-RE

この記事を読むのに必要な時間:およそ 10.5 分

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が本格的に使われるようになる頃には,重要なツールになってくることでしょう。

著者プロフィール

柴田充也(しばたみつや)

Ubuntu Japanese Team Member株式会社 創夢所属。数年前にLaunchpad上でStellariumの翻訳をしたことがきっかけで,Ubuntuの翻訳にも関わるようになりました。