Ubuntu Weekly Recipe

第690回 BCCでeBPFのコードを書いてみる

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

Python側へ文字列以外のデータを通知する

ここまではカーネルとユーザーランドのやりとりは文字列のみで行っていました。つまりカーネル側の処理はtrace_pipeに文字列として結果を保存し,ユーザーランド側はそれを1行ずつ読み出して解釈していたのです。しかしながらこの方法だと,文字列化できないデータは受け取れません。

そこで最後の例として,C言語側で作成した任意のデータをPython側に受け渡してみましょう。execve(2)が呼び出されたときに,呼び出し元のPID,PPID,タスク名,実行しようとするファイル名を表示してみます。

実際のコードの内容

先にコード全体を掲載しておきます。これはexecsnoopをよりシンプルにしたようなコードになっています。

#!/usr/bin/python3
from bcc import BPF

bpf_text="""
#include <linux/sched.h>

struct data_t {
    u32 pid;
    u32 ppid;
    char comm[TASK_COMM_LEN];
    char fname[128];
};
BPF_PERF_OUTPUT(events);

int syscall__execve(struct pt_regs *ctx, const char __user *filename)
{
    struct data_t data = {};
    struct task_struct *task;

    data.pid = bpf_get_current_pid_tgid() >> 32;

    task = (struct task_struct *)bpf_get_current_task();
    data.ppid = task->real_parent->tgid;

    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    bpf_probe_read_user(data.fname, sizeof(data.fname), (void *)filename);

    events.perf_submit(ctx, &data, sizeof(struct data_t));
    return 0;
}
"""

b = BPF(text=bpf_text)
b.attach_kprobe(event=b.get_syscall_fnname("execve"), fn_name="syscall__execve")

print("PID      PPID     COMM             FNAME")
def print_event(cpu, data, size):
    event = b["events"].event(data)
    print("{:<8} {:<8} {:16} {}".format(event.pid, event.ppid, event.comm.decode(), event.fname.decode()))

b["events"].open_perf_buffer(print_event)
while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

最後の例では,これまでと違う部分がいくつか出ています。順番に説明していきましょう。

システムコール特有の話

まずattach_kprobe()fn_namehelloではなくsyscall__execveになりました。これはsystemcall trace pointという書式で,システムコールの引数をeBPFの中で取得したい場合はこの書式で関数名を書く必要があります。

システムコール以外のカーネルの関数をフックするだけなら,これまでどおり任意の関数名を利用可能です。

C言語部分の解説

次にC言語部分を見ていきます。data_t構造体は,eBPFからPythonへとデータをやりとりする際に使用するデータ構造です。今回はPID,PPID,タスク名,実行しようとするファイル名をメンバーにしました。

BPF_PERF_OUTPUTが重要ポイントです。これにより,引数に指定した名前の「BPFテーブル」と呼ばれるリングバッファーを作成します。C言語側にはこのリングバッファーにperf_submit()を使ってデータを登録していきます。

ちなみにより高機能なAPIとしてBPF_RINGBUF_OUTPUTも存在します。性能も向上しているみたいで,カーネル5.8以降であればこちらの利用が推奨されているようです。BPF_PERF_OUTPUTからBPF_RINGBUF_OUTPUTへの移行は,リンク先のサンプルを見れば十分にわかるでしょう。

syscall__execve()では各種データの情報を取得した上で,最終的にevents.perf_submit()data_t構造体のデータをバッファーに記録しています。

  • PIDはbpf_get_current_pid_tgid()で取得しています。今回はスレッドグループのIDを表示する想定で,上位32bitの値を使っています。
  • PPIDはbpf_get_current_taskで取得したタスク構造体から取得しています。
  • タスク名の取得方法はこれまでと同じbpf_get_current_comm()です。

ファイル名が今回のポイントその2です。このファイル名はexecve(2)の引数であり,さらにユーザー空間のアドレスとなっています。よってまず引数にアクセスできるよう,syscall__execve()の引数に,execve(2)の引数と同じconst char __user *filenameを指定しておきます。さらにユーザー空間のアドレスからbpf_probe_read_user()を用いて,その中身をコピーします。

ちなみに今回はユーザー空間の文字列だけでしたが,カーネル空間のデータをコピーするならbpf_probe_read_kernel()が使えます。

これでC言語側の準備はできました。これによりexecve(2)が呼ばれるたびに,data_t構造体のデータがリングバッファーに保存されることになります。次はそれを取り出すPython側のコードです。

Python部分の解説

説明の前に,改めてPython部分だけ再掲しておきましょう。

b = BPF(text=bpf_text)
b.attach_kprobe(event=b.get_syscall_fnname("execve"), fn_name="syscall__execve")

print("PID      PPID     COMM             FNAME")
def print_event(cpu, data, size):
    event = b["events"].event(data)
    print("{:<8} {:<8} {:16} {}".format(event.pid, event.ppid, event.comm.decode(), event.fname.decode()))

b["events"].open_perf_buffer(print_event)
while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

BPF_PERF_OUTPUTで作成したBPFテーブルは,PythonからだとBPFオブジェクトを使ってBPF["テーブル名"]としてアクセスできます。今回だとb["events"]がそれです。

まずopen_perf_buffer()で,リングバッファーを開き,データが届いたときに呼ばれるコールバック関数を指定します。今回の例だとprint_event(cpu, data, size)ですね。BPF_PERF_OUTPUTはCPUごとにバッファーが作られるため,それを受ける側もどのCPUのバッファーなのかがわかるようになっています。

コールバック関数の中ではb["events"].event(data)では受け取ったデータを,C言語側のデータ構造を元にBCCのデータに自動変換してくれます。構造体の各メンバーは,そのメンバーの名前を利用して,マップ型っぽくアクセス可能です。変換した結果はマップ型っぽく使えますが,実態はbcc.table型となります。

最後に「バッファーにデータを受信するまで待つ」処理が必要です。それがperf_buffer_poll()です。これにより開かれているリングバッファーのいずれかにデータが届いたら,適切なコールバック関数を呼ぶことになります。またtimeout=でタイムアウト値をミリ秒単位で指定できます。定期的に他の処理をしたい場合に便利でしょう。

そのほか,キーボード割り込みがきたらプログラムを修了するようにも設定しています。

実行結果

ここまでで最後のサンプルの説明は終えました。実際に動かしてみると,次のような形で起動したプログラムが表示されます。

PID      PPID     COMM             FNAME
2822958  5480     tmux: server     /bin/sh
2822960  2822958  sh               /usr/bin/byobu-status
2822959  5480     tmux: server     /bin/sh
2822961  2822960  byobu-status     /usr/bin/sed
2822963  2822959  sh               /usr/bin/byobu-status
2822964  2822960  byobu-status     /usr/bin/tmux

今回は引数や呼び出し時刻等は表示していません。このあたりも取得・表示すればより便利になるはずです。具体例はやはりexecsnoopが参考になるでしょう。

このように,BCCを使えばC言語とPythonを組み合わせることで,さまざまなカーネルのデータを柔軟に取得・解析・表示できるようになります。カーネル由来で何か困った状況になったときには,ぜひBCCを使った解析にチャレンジしてみてください。

著者プロフィール

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

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