Ubuntu Weekly Recipe

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

第688回のeBPFのコンパイラーに対応したツールでさまざまな挙動を可視化するではBPF Compiler Collectionに付属の各種サンプルツールの使い方を紹介しました。今回はコンパイラーを活用して、自分でeBPF用コードを書くための基礎を学んでみましょう。

BCCのインストールとドキュメント

第688回も紹介したように、カーネル3.15で追加されその後拡充を続けている「eBPF」は任意の外部プログラムをカーネルの中で、より安全に実行できる仕組みです。カーネルモジュールを作る代わりに、独自のバイトコードをコンパイラーで生成し、それをカーネル内部にロード・実行することになります。これを使えばシステムコールの先のカーネルの状態を、プログラマブルに解析可能になります。

eBPF自体はカーネルの仕組みであり、ユーザーランドから使うためには、eBPF用のバイトコードにコンパイルする必要があります。それを担ってくれるツールのひとつが、BPF Compiler Collection(BCC)です。前回同様、まずはBCCを導入しておきましょう。Ubuntu向けのパッケージとしては、apt版とsnap版の両方が用意されています。このうちsnap版はBCCが提供する各種ツールのみを提供しています。自分でプログラミングしたいなら、apt版のほうが必要です。

$ sudo apt instal bpfcc-tools

実際にコンパイルを行うのはPythonライブラリ側です。よって「BCCを使ってeBPFのコードを書く」場合、ほとんどのケースにおいて「Pythonプログラムの中でC言語のコードを書く」ような状態になります。純粋にC言語のみで書きたい場合は、LLVMなどを直接利用することになるでしょう。

ちなみにBCCを使ってeBPFのプログラムを書くだけであれば、実はbpfcc-toolsパッケージは不要です。本当に必要なのはpython3-bpfccパッケージとなります。ただしbpfcc-toolsに含まれる各種プログラムは実際のコーディングにあたって非常に参考になるため、特別な理由がない限りは一緒にインストールしておくと良いでしょう。

BCC向けのコードを書く場合、リファレンスガイドを参考にしながら記述していくことになります。リファレンスガイドには個別のAPIの説明だけでなく、そのAPIを利用したサンプルコードへのリンクも記述されているので、迷ったらまずはリファレンスガイドを参照すると良いでしょう。ここではリファレンスガイドを読むために必要な最低限の情報を説明していくことにします。

BCCではC言語でカーネル内部のロジックとデータの処理を実装し、それをPythonをスクリプトを使ってコンパイル・カーネルに流し込みます。このうちPython側にはC言語で記述する部分を肩代わりしてくれるラッパー機能も存在します。

  • BPF C:C言語部分のリファレンス
  • BCC Python:Python部分のリファレンス

kprobeで特定の関数呼び出し時の処理を追加する

まずはBCCのサンプルにあるhello_world.pyを読み解くことにしましょう。これはシステムコールclone(2)が呼ばれたときに、標準出力にHello, World!を表示するだけのシンプルなプログラムです。コメント等を省いたコードの部分だけ抜粋します。

from bcc import BPF

BPF(text='int kprobe__sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; }').trace_print()

ちなみにBCCのサンプルはPython 2を想定して書かれたスクリプトが多いようです。よってそのまま実行権限を与えても、Python 2が/usr/bin/pythonが)インストールされていない環境だとうまく動かないかもしれません。ただしPython 3にも対応するよう書かれているため、とりあえずは「sudo python3 サンプルプログラム」のように実行すると良いでしょう。今回紹介するコードについては、Python 3で動かすことを想定しています。

さてまずはPython部分を説明します。BCC Pythonでは、カーネルの処理についてBPFと呼ばれるオブジェクト経由でeBPFのデータを処理します。ユーザーランドプログラムを処理する場合はUSDTを使います。BCCを使う場合はBPFかUSDTのどちらかのオブジェクトを作ることになるでしょう。

BPFの場合はインスタンス作成時のtextパラメーターで、eBPFのC言語部分を渡します。他にもsrc_fileでC言語のコードを直接渡すことも可能です。またcflags="文字列"で渡したコードをビルドする際のコンパイルオプションを、debug=数字でデバッグレベルも設定できます。

今回はtextを使って直接次のようなC言語のコードを指定しています。

int kprobe__sys_clone(void *ctx)
{
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}

まずは関数名のkprobe__sys_cloneです。BCCではイベント名__関数名という書式で、カーネル内部の任意の関数のイベントをフックして処理を追加できるようになっています。このタイプで記述できるイベントはkprobeskretprobesの2種類です。前者が関数が呼び出される前に実行されるイベントで、後者が関数から戻る時に実行されるイベントです。

今回だとkprobe__sys_cloneという名前であるため、sys_clone()が呼び出される時に指定したコードが実行されることになります。kprobe/kretprobesでは必ずBPFコンテキストのレジスターを保存するstruct pt_regs *ctxを第一引数として指定します。今回は使用しないためvoid *としています。また、任意の数の対象関数の引数を渡すことも可能です。

関数の中ではカーネルのAPIやBCCのAPIを呼び出せます。bpf_trace_printk()はBCC側のAPIで、/sys/kernel/debug/tracing/trace_pipeに指定した文字列を出力します。ただし引数は最大3個とか、%sは1個までとか、並列処理すると出力が混じってしまうとかいろいろ制約が存在する点については注意が必要です。⁠とりあえず出力してみたい」という用途にのみ有用で、本格的にはBPF_PERF_OUTPUTなどを使うことになるのでしょう。

これでC言語の部分の説明は完了です。最後に残ったのはBPF().trace_print()だけです。trace_print()bpf_trace_printk()trace_pipeに出力されたデータを読み込んで表示するだけの関数です。

実際にこのコードを実行してみましょう。

$ sudo python3 hello_world.py
(BCCによるコンパイルログ)
b'    tmux: server-5480    [001] d... 626046.065863: bpf_trace_printk: Hello, World!'
b''
b'    tmux: server-5480    [001] d... 626046.071380: bpf_trace_printk: Hello, World!'
b''

バックグラウドで何かプログラムが動くたびにHello, World!が表示されます。他の出力はカーネル側が自動的につけています。個々のフィールドの意味は次のとおりです。

      TASK         PID     CPU   FLAG TIMESTAMP      FUNCTION
b'    tmux: server-5480    [001] d... 626046.065863: bpf_trace_printk: Hello, World!'

追加でタスク名を表示してみる

たとえばタスクの名前を、出力のほうにも追加してみましょう。

from bcc import BPF

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

int kprobe__sys_clone(void *ctx)
{
    char comm[TASK_COMM_LEN];
    bpf_get_current_comm(&comm, sizeof(comm));
    bpf_trace_printk("Hello, World!: %s\\n", comm);
    return 0;
}
"""

BPF(text=bpf_text).trace_print()

今回はbpf_textとしてC言語部分をヒアドキュメント化してみました。長めのコードを書くなら、この形式のほうが読みやすいでしょう。

bpf_get_current_comm()はカレントタスクのプログラム名を文字列にコピーしてくれるBCCのAPIです。あとは%sで表示すれば完了となります。もともとtrace_pipeにはタスク名が表示されるため、情報量は変わりませんが雰囲気はわかるかと思います。

このようにeBPFのC言語部分は、普通のC言語のプログラムとして拡充できます。

出力フォーマットをカスタマイズする

trace_pipeをそのまま表示するだけだと表示が複雑になってしまうため、もう少し設定できるようにしてみましょう。

最初に、trace_print()は引数から出力するフィールド値やフォーマットを書き換えられます。

trace_print(fmt="TASK={0} PID={1} MESSAGE={5}")

これだけでも、出力がより読みやすくなります。

TASK=b'tmux: server' PID=5480 MESSAGE=b'Hello, World!: tmux: server'
TASK=b'byobu-status' PID=2597659 MESSAGE=b'Hello, World!: byobu-status'

さらにtrace_fields()を使えば、Pythonの変数に分解できます。

from bcc import BPF

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

int kprobe__sys_clone(void *ctx)
{
    char comm[TASK_COMM_LEN];
    bpf_get_current_comm(&comm, sizeof(comm));
    bpf_trace_printk("Hello, World!: %s\\n", comm);
    return 0;
}
"""

b = BPF(text=bpf_text)

while True:
    try:
        (task, pid, cpu, flags, ts, msg) = b.trace_fields()
        print("PID={}, CPU={}, MSG={}".format(pid, cpu, msg.decode()))
    except KeyboardInterrupt:
        exit()
    except ValueError:
        continue

これまではBPFで生成したオブジェクトから直接メソッドを呼び出していましたが、今回は複数のメソッドを呼び出すため、オブジェクトを変数bとして再利用しています。

b.trace_fileds()trace_pipeから1行読み込み、それをtrace_pipeのフォーマットに準じたタプル型に変換してくれます。たとえばtaskはバイトオブジェクトになりますし、pidやcpu、tsは周囲の装飾文字を刈り取った上で数値型に変換してくれます。あとはPythonの流儀に合わせて使えるというわけです。

ここではバイトオブジェクトはprint()したときの見た目のためにdecode()メソッドで文字列に変換しています。また、キーボード割り込みがきたときは終了し、trace_pipeを変換できなかったときは無視しています。結果的に、その出力は次のようにより読みやすくなりました。

PID=2723064, CPU=3, MSG=Hello, World!: tmux: server
PID=2723066, CPU=7, MSG=Hello, World!: byobu-status
PID=2723065, CPU=3, MSG=Hello, World!: byobu-status

PythonAPIを用いて再利用性を高める

ここまでの例だと、トレースしたいカーネル関数ごとに関数を増やさなくてはなりません。そこでBCCのPythonバインディングを活用して、再利用性を高めましょう。

たとえばattach_kprobe()は指定した関数を、特定のカーネル関数のkprobeイベントに紐付けてくれるAPIです。これを使えば、複数のカーネル関数に同じ処理を行うコードを簡単に書けます。

from bcc import BPF

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

int hello(void *ctx)
{
    char comm[TASK_COMM_LEN];
    bpf_get_current_comm(&comm, sizeof(comm));
    bpf_trace_printk("Hello, World!: %s\\n", comm);
    return 0;
}
"""

b = BPF(text=bpf_text)
b.attach_kprobe(event="__x64_sys_clone", fn_name="hello")
b.attach_kprobe(event="__x64_sys_execve", fn_name="hello")
b.trace_print(fmt="TASK={0} PID={1} MESSAGE={5}")

C言語部分の関数名が変わっていることに注意してください。これはattach_kprobe()側のfn_nameに渡す名前で、他と被らない限りは任意の名前を利用可能です。また、今回はclone()だけでなくexecve()もトレースの対象にしてみました。

__x64_sys_cloneはamd64環境におけるclone(2)の表記方法です。環境ごとの名前は/proc/kallsymsで確認できます。また、単純にsys_cloneを使う場合、環境によっては次のようなエラーになります。

cannot attach kprobe, probe entry may not exist
Traceback (most recent call last):
  File "/home/shibata/temp/bpfcc/hello_world.py", line 24, in <module>
    b.attach_kprobe(event="sys_clone", fn_name="hello")
  File "/usr/lib/python3/dist-packages/bcc/__init__.py", line 683, in attach_kprobe
    raise Exception("Failed to attach BPF program %s to kprobe %s" %
Exception: Failed to attach BPF program b'hello' to kprobe b'sys_clone'

要するにこの環境だとsys_cloneという名前の関数はカーネルに存在しないということです。実際BCC側がカーネルに問い合わせたときのエラーが次のように残っています。

$ sudo tail /sys/kernel/debug/tracing/error_log
  [644916.964850] trace_kprobe: error: Invalid probed address or symbol
    Command: p:kprobes/p_sys_clone_bcc_2652701 sys_clone
                                               ^

これはカーネルのバージョンによって起こりうる問題です。環境ごとの名前は先ほど言及したように、/proc/kallsymsで確認できます。ただしこれを使うと、環境に依存したコードになってしまいます。そこで便利なのがget_syscall_fnname()です。このメソッドを使うと、該当部分は次のように書き直せます。

b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")
b.attach_kprobe(event=b.get_syscall_fnname("execve"), fn_name="hello")

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を使った解析にチャレンジしてみてください。

おすすめ記事

記事・ニュース一覧