第688回の「eBPFのコンパイラーに対応したツールでさまざまな挙動を可視化する」ではBPF Compiler Collectionに付属の各種サンプルツールの使い方を紹介しました。今回はコンパイラーを活用して、自分でeBPF用コードを書くための基礎を学んでみましょう。
BCCのインストールとドキュメント
第688回も紹介したように、カーネル3.15で追加されその後拡充を続けている「eBPF」は任意の外部プログラムをカーネルの中で、より安全に実行できる仕組みです。カーネルモジュールを作る代わりに、独自のバイトコードをコンパイラーで生成し、それをカーネル内部にロード・実行することになります。これを使えばシステムコールの先のカーネルの状態を、プログラマブルに解析可能になります。
eBPF自体はカーネルの仕組みであり、ユーザーランドから使うためには、eBPF用のバイトコードにコンパイルする必要があります。それを担ってくれるツールのひとつが、BPF Compiler Collection(BCC)です。前回同様、まずはBCCを導入しておきましょう。Ubuntu向けのパッケージとしては、apt版とsnap版の両方が用意されています。このうちsnap版はBCCが提供する各種ツールのみを提供しています。自分でプログラミングしたいなら、apt版のほうが必要です。
実際にコンパイルを行うのはPythonライブラリ側です。よって「BCCを使ってeBPFのコードを書く」場合、ほとんどのケースにおいて「Pythonプログラムの中でC言語のコードを書く」ような状態になります。純粋にC言語のみで書きたい場合は、LLVMなどを直接利用することになるでしょう。
ちなみにBCCを使ってeBPFのプログラムを書くだけであれば、実はbpfcc-toolsパッケージは不要です。本当に必要なのはpython3-bpfccパッケージとなります。ただしbpfcc-toolsに含まれる各種プログラムは実際のコーディングにあたって非常に参考になるため、特別な理由がない限りは一緒にインストールしておくと良いでしょう。
BCC向けのコードを書く場合、リファレンスガイドを参考にしながら記述していくことになります。リファレンスガイドには個別のAPIの説明だけでなく、そのAPIを利用したサンプルコードへのリンクも記述されているので、迷ったらまずはリファレンスガイドを参照すると良いでしょう。ここではリファレンスガイドを読むために必要な最低限の情報を説明していくことにします。
BCCではC言語でカーネル内部のロジックとデータの処理を実装し、それをPythonをスクリプトを使ってコンパイル・カーネルに流し込みます。このうちPython側にはC言語で記述する部分を肩代わりしてくれるラッパー機能も存在します。
kprobeで特定の関数呼び出し時の処理を追加する
まずはBCCのサンプルにある「hello_world.py
」を読み解くことにしましょう。これはシステムコール「clone(2)
」が呼ばれたときに、標準出力に「Hello, World!
」を表示するだけのシンプルなプログラムです。コメント等を省いたコードの部分だけ抜粋します。
ちなみに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言語のコードを指定しています。
まずは関数名の「kprobe__sys_clone
」です。BCCでは「イベント名__関数名
」という書式で、カーネル内部の任意の関数のイベントをフックして処理を追加できるようになっています。このタイプで記述できるイベントは「kprobes」と「kretprobes」の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
に出力されたデータを読み込んで表示するだけの関数です。
実際にこのコードを実行してみましょう。
バックグラウドで何かプログラムが動くたびに「Hello, World!
」が表示されます。他の出力はカーネル側が自動的につけています。個々のフィールドの意味は次のとおりです。
追加でタスク名を表示してみる
たとえばタスクの名前を、出力のほうにも追加してみましょう。
今回はbpf_text
としてC言語部分をヒアドキュメント化してみました。長めのコードを書くなら、この形式のほうが読みやすいでしょう。
bpf_get_current_comm()
はカレントタスクのプログラム名を文字列にコピーしてくれるBCCのAPIです。あとは「%s
」で表示すれば完了となります。もともとtrace_pipe
にはタスク名が表示されるため、情報量は変わりませんが雰囲気はわかるかと思います。
このようにeBPFのC言語部分は、普通のC言語のプログラムとして拡充できます。
出力フォーマットをカスタマイズする
trace_pipe
をそのまま表示するだけだと表示が複雑になってしまうため、もう少し設定できるようにしてみましょう。
最初に、trace_print()
は引数から出力するフィールド値やフォーマットを書き換えられます。
これだけでも、出力がより読みやすくなります。
さらにtrace_fields()
を使えば、Pythonの変数に分解できます。
これまではBPFで生成したオブジェクトから直接メソッドを呼び出していましたが、今回は複数のメソッドを呼び出すため、オブジェクトを変数「b
」として再利用しています。
b.trace_fileds()
はtrace_pipe
から1行読み込み、それをtrace_pipe
のフォーマットに準じたタプル型に変換してくれます。たとえばtaskはバイトオブジェクトになりますし、pidやcpu、tsは周囲の装飾文字を刈り取った上で数値型に変換してくれます。あとはPythonの流儀に合わせて使えるというわけです。
ここではバイトオブジェクトはprint()したときの見た目のためにdecode()
メソッドで文字列に変換しています。また、キーボード割り込みがきたときは終了し、trace_pipe
を変換できなかったときは無視しています。結果的に、その出力は次のようにより読みやすくなりました。
PythonAPIを用いて再利用性を高める
ここまでの例だと、トレースしたいカーネル関数ごとに関数を増やさなくてはなりません。そこでBCCのPythonバインディングを活用して、再利用性を高めましょう。
たとえば「attach_kprobe()
」は指定した関数を、特定のカーネル関数のkprobeイベントに紐付けてくれるAPIです。これを使えば、複数のカーネル関数に同じ処理を行うコードを簡単に書けます。
C言語部分の関数名が変わっていることに注意してください。これはattach_kprobe()
側のfn_name
に渡す名前で、他と被らない限りは任意の名前を利用可能です。また、今回はclone()
だけでなくexecve()
もトレースの対象にしてみました。
__x64_sys_clone
はamd64環境におけるclone(2)
の表記方法です。環境ごとの名前は/proc/kallsyms
で確認できます。また、単純にsys_clone
を使う場合、環境によっては次のようなエラーになります。
要するにこの環境だと「sys_clone
」という名前の関数はカーネルに存在しないということです。実際BCC側がカーネルに問い合わせたときのエラーが次のように残っています。
これはカーネルのバージョンによって起こりうる問題です。環境ごとの名前は先ほど言及したように、/proc/kallsyms
で確認できます。ただしこれを使うと、環境に依存したコードになってしまいます。そこで便利なのがget_syscall_fnname()
です。このメソッドを使うと、該当部分は次のように書き直せます。
Python側へ文字列以外のデータを通知する
ここまではカーネルとユーザーランドのやりとりは文字列のみで行っていました。つまりカーネル側の処理はtrace_pipe
に文字列として結果を保存し、ユーザーランド側はそれを1行ずつ読み出して解釈していたのです。しかしながらこの方法だと、文字列化できないデータは受け取れません。
そこで最後の例として、C言語側で作成した任意のデータをPython側に受け渡してみましょう。execve(2)
が呼び出されたときに、呼び出し元のPID、PPID、タスク名、実行しようとするファイル名を表示してみます。
実際のコードの内容
先にコード全体を掲載しておきます。これはexecsnoopをよりシンプルにしたようなコードになっています。
最後の例では、これまでと違う部分がいくつか出ています。順番に説明していきましょう。
システムコール特有の話
まずattach_kprobe()
のfn_name
がhello
ではなく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
構造体のデータをバッファーに記録しています。
ファイル名が今回のポイントその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部分だけ再掲しておきましょう。
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=
」でタイムアウト値をミリ秒単位で指定できます。定期的に他の処理をしたい場合に便利でしょう。
そのほか、キーボード割り込みがきたらプログラムを修了するようにも設定しています。
実行結果
ここまでで最後のサンプルの説明は終えました。実際に動かしてみると、次のような形で起動したプログラムが表示されます。
今回は引数や呼び出し時刻等は表示していません。このあたりも取得・表示すればより便利になるはずです。具体例はやはりexecsnoopが参考になるでしょう。
このように、BCCを使えばC言語とPythonを組み合わせることで、さまざまなカーネルのデータを柔軟に取得・解析・表示できるようになります。カーネル由来で何か困った状況になったときには、ぜひBCCを使った解析にチャレンジしてみてください。