第688回 と第690回 では、カーネルのトレーシングツールとして注目されているeBPFを活用するためのツールとしてBCC を紹介しました。しかしながら、BCCだけがeBPFを扱えるツールというわけではありません。今回はツールなしに利用できるsysfsや、よりユーザーフレンドリーなトレーシングツールであるbpftoolを紹介します。
Python版BCCの問題点
これまで紹介していたBPF Compiler Collection(BCC )のツールはいずれもフロントエンドとしてPythonを使っていました。つまり利用者はまずPythonスクリプトを起動し、その中でeBPFのオブジェクトをコンパイルし、ロードすることでようやくトレースが始まっていたのです。
実行環境でBPFオブジェクトをビルドする必要があるこの方法にはいくつかの問題点が存在します。
実行環境にコンパイラをインストールする必要がある
実行環境にカーネルのヘッダーファイルが必要になる
実行時にコンパイルという重い処理が走る
この問題は日常的に開発に利用しているデスクトップ環境やサーバーならそこまで影響はありません。しかしながらプロダクション用途となると話は別です。セキュティ的にもメンテナンス的にもできる限り余計なものはインストールしたくないでしょう。障害の事前検知手段としてeBPFを使いたいと考えたときに、そのトレースツールの処理の重さが新たな障害のトリガーなってしまっては目も当てられません。サーバーのリソースモニタリングや、ネットワークフレームの処理などにeBPFを使用したいとき、上記のような制約が存在すると導入の障害になるのです[1] 。
対応策として、いくつかの方法が考えられます。
Clang等でBPFオブジェクトをコンパイルし、別途ロードする
sysfsの機能を用いてトレーシングする
bpftrace 等の別のツールを使う
BPF CO-REによりポータブルなバイナリを生成する
Clang等でBPFオブジェクトをコンパイルし、別途ロードする
Clang等を使えばBPFオブジェクトをコンパイルできます。これをさらにカーネルにロードしてしまえば、BCCのバックエンドと同じことを実現できます。しかしながらきちんと動くBPFオブジェクトを作るにはそれなりの知識が必要です。そこが大変だからこそ、Python版のBCCが登場し、広く使われたという経緯が存在します。よって最初の選択肢は、条件次第では採用できるものの、多くのケースにおいては他の選択肢を使ったほうが「楽ができる」でしょう。
sysfsの機能を用いてトレーシングする
2番目の選択肢としてカーネルのsysfsにあるトレース機能を使ってトレーシングを行う方法が考えられます。この方法ならシステムに追加でソフトウェアをインストールしなくても、トレーシングできるというメリットがあります。ただし制約はいくつか存在します。一番大きいのが、トレース結果をtrace_pipe
でしか取得できないことでしょう。つまりユーザーランド側はtrace_pipe
の出力結果を解析する必要があります。表示するデータの量は変更可能ではあるのですが、あくまでprintfによる出力であるため、渡せるデータの内容やその方法については限定されます。それでも結局のところ、デバッグ目的ならなんらかの出力は必須であるため、簡単に調査する分には問題とはならないでしょう。
bpftrace等の別のツールを使う
より高機能なトレーシングツールとしてbpftrace が存在します。内部的にやっていることはBCCと同じでその場でコンパイルすることになるのですが、カーネルのBTF (BPF Type Format)を活用することでその依存関係を極力減らしています。また独自の言語を採用することで、トレーシングの表現がしやすくなっているのが特徴のひとつです。ちなみに現在はBCCと同じIO Visorプロジェクトで開発が行われています。eBPFを「トレーシング目的」で使うなら、BCCよりもbpftraceを使うほうが簡単かもしれません。独自言語の学習コストはありますが、そこまで難しくはないのですぐに理解できるようになるでしょう。最新版がsnapパッケージ として提供されているため、導入も容易です。
BPF CO-REによりポータブルなバイナリを生成する
最後の方法が現在注目されている、BPF CO-RE(Compile Once – Run Everywhere)を活用する方法です。つまり一度特定の環境でBPFを利用したプログラムバイナリをビルドしておけば、そのバイナリをたとえカーネルのバージョンが異なっていても他の環境で実行できる仕組みです。これにより実行環境にはバイナリだけコピーすれば良いことになります。
BPF CO-REもbpftraceと同じくカーネルのBTFを活用します。BTFは端的に言うとBPFプログラムを動かすために必要な、実行中のカーネルの各種情報を提供する仕組みです。最近のカーネルはこのBTFを大幅に拡張し、BPFプログラムが実行時に自動的にカーネルのバージョン間の差異を自動的に調整できるようになりました。さらにClangはそのBTFを活用したBPFオブジェクトを生成できるようになり、そんなBPFオブジェクトをロードするためのライブラリであるlibbpf は、ロード時にBTFとBPFオブジェクトを適切に結びつけるようになりました。
つまり「BPF CO-RE」とはBPFオブジェクトを再利用可能にするために各種ツールをアップデートした状態をまとめたものであり、BPF CO-REというソフトウェアが存在するというわけではありません。
結局何を使うべきか
このように、BCC以外にも複数の選択肢が存在します。またここでは紹介しなかったツールや、トレーシング以外にeBPFを使うツールも存在します。結局のところ、やりたいことに合わせてツールを変えるのがベストな方法です。
今回例をあげたもの中だと、BPF CO-REは注目株になっています。ただしBPF CO-RE(や最新のBTF)を活用するためには、そこそこ新しいカーネルやツールチェインが必要です。Ubuntuだと最初に動くようになったのが、20.10の頃になります。つまり現時点で最新のLTSであるUbuntu 20.04 LTSでは、BPF CO-REの恩恵は受けられません。また、BPF/BTF自体がまだ活発に機能拡張されているため、本格的に使うならより新しいカーネルやツールチェインを使いたいところです。よってUbuntuの場合は、2022年4月にリリースされる次のLTSであるUbuntu 22.04 LTSぐらいから、プロダクション用途でも使われるようになっていくのではないでしょうか[2] 。
今回はsysfsとbpftraceを使う方法を紹介します。BPF CO-REについては次回以降に解説予定です。
今一度execveをトレースする
まずは最初に第690回 で解説した、execve()
をトレースするPython版BCC向けのコード(execve.py
)を再掲しておきましょう。これは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()
BCC(bpfcc)がインストールされている環境で上記を実行すると、次のような結果が表示されます。
$ sudo apt instal python3-bpfcc
$ sudo python3 execve.py
(中略)
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
sysfsの機能を用いてトレーシングする
シンプルなトレーシングなら余計なツールをインストールすることなく、カーネルのsysfsとシェルスクリプトだけでも十分です。たとえば単純にexecve()
システムコールの呼び出しを記録する方法を実現してみましょう。ちなみにこの節の説明はbpftraceの利用とは関係ないため、bpftraceの使い方だけを知りたい場合は、読み飛ばしてもかまいません。
/sys/kernel/debug/tracingの活用
まずはsysfsを利用したトレーシングの基本からです。単純にexecve()
システムコールの呼び出しを記録するだけなら、次の3行で実現できます。
$ sudo mkdir /sys/kernel/debug/tracing/instances/execve
$ echo 'sys_enter_execve' | sudo tee -a /sys/kernel/debug/tracing/instances/execve/set_event
$ echo 1 | sudo tee /sys/kernel/debug/tracing/instances/execve/events/syscalls/sys_enter_execve/enable
まず、「 /sys/kernel/debug/
」以下は管理者しかアクセスできません。タブ補完なども動かないので注意してください。面倒ならあらかじめ「sudo -i
」でrootになってしまうというのもひとつの手でしょう。
/sys/kernel/debug/tracing/instances
以下に任意の名前のディレクトリを作ることで、個別のトレーサーを作成できます。さらにset_event
にはイベントトレーサーを記録できます。ここではシステムコールの「sys_enter_execve
」を設定しています。利用可能なイベントの名前は「/sys/kernel/debug/tracing/instances/execve/events/
」以下を見るといいでしょう。
最後に、登録したイベントを有効化してます。ここまですればトレースバッファーに結果が表示されます。あらかじめクリアしてから見ると、読みやすくなります。
$ echo 1 | sudo tee /sys/kernel/debug/tracing/instances/execve/free_buffer
$ sudo cat /sys/kernel/debug/tracing/instances/execve/trace_pipe
sh-666208 [005] .... 1313488.957135: sys_execve(filename: 561bf66f3280, argv: 561bf57cd8c8, envp: 561bf66f3078)
byobu-status-666209 [002] .... 1313488.958351: sys_execve(filename: 55d8463f94d0, argv: 55d846404450, envp: 55d8463f92c8)
tmux: client-666211 [004] .... 1313488.959675: sys_execve(filename: 55d846406910, argv: 55d8464065f0, envp: 55d846406708)
(後略)
イベントごとの出力フォーマットは次の方法で確認できます。このあたりの書式や使い方はカーネルドキュメントの「Event Tracing 」を参照してください。
$ sudo cat /sys/kernel/debug/tracing/instances/execve/events/syscalls/sys_enter_execve/format
name: sys_enter_execve
ID: 707
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int __syscall_nr; offset:8; size:4; signed:1;
field:const char * filename; offset:16; size:8; signed:0;
field:const char *const * argv; offset:24; size:8; signed:0;
field:const char *const * envp; offset:32; size:8; signed:0;
print fmt: "filename: 0x%08lx, argv: 0x%08lx, envp: 0x%08lx", ((unsigned long)(REC->filename)), ((unsigned long)(REC->argv)), ((unsigned long)(REC->envp))
この出力フォーマットを見る限り、残念ながらファイル名はポインタとしてしか表示してくれないようです。
作成したイベントを止め、削除するには次のコマンドを実行してください。
$ echo 0 | sudo tee /sys/kernel/debug/tracing/instances/execve/events/syscalls/sys_enter_execve/enable
$ echo '!sys_enter_execve' | sudo tee -a /sys/kernel/debug/tracing/instances/execve/set_event
$ sudo rmdir /sys/kernel/debug/tracing/instances/execve
「/sys/kernel/debug/tracing/instances/execve
」ディレクトリの削除は本当に不要になったタイミングで問題ありません。
システムコール以外をトレースしてファイル名を表示する
せっかくなのでファイル名を表示させてみましょう。この場合、syscallではなくカーネル内の関数を使って「Kprobe Event Tracing 」を行います。まず対象となるカーネルの関数を決めます。
$ grep execve /proc/kallsyms
0000000000000000 t audit_log_execve_info
0000000000000000 t __do_execve_file.isra.0
0000000000000000 T __ia32_compat_sys_execve
0000000000000000 T __ia32_compat_sys_execveat
0000000000000000 T __ia32_sys_execve
0000000000000000 T __ia32_sys_execveat
0000000000000000 T __x32_compat_sys_execve
0000000000000000 T __x64_sys_execve
0000000000000000 T __x64_sys_execveat
0000000000000000 T __x32_compat_sys_execveat
0000000000000000 T do_execve_file
0000000000000000 T do_execve
0000000000000000 T do_execveat
0000000000000000 d event_exit__execveat
0000000000000000 d event_enter__execveat
(後略)
第690回 でも少し触れたように、実行中のカーネルのシンボル名は/proc/kallsyms
で確認できます。ちなみに最初のアドレスフィールドは管理者権限でアクセスしないと表示できません。2番目のフィールドはnmコマンド のマニュアルを参照してください。たとえばTだとテキストセクションにあるシンボルを意味し、大文字だとexternalで、小文字だとstaticになります。
システムコールのシンボルは環境に依存し、amd64なら「__x64_sys_execve
」となります。しかしながらシステムコールの引数を今回のようにkprobeで解釈するのはやっかいです。そこでシステムコールから呼ばれる関数を使いましょう。このあたりはカーネルのバージョンに依存するのですが、Ubuntu 20.04 LTSのカーネル5.4なら__do_execve_file.isra.0
、より新しいカーネルならdo_execveat_common
あたりが使えます。
$ echo 'p:execve __do_execve_file.isra.0 comm=$comm file=+0(+0($arg2)):string' | \
sudo tee /sys/kernel/debug/tracing/kprobe_events
$ sudo cat /sys/kernel/debug/tracing/events/kprobes/execve/format
name: execve
ID: 1641
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:unsigned long __probe_ip; offset:8; size:8; signed:0;
field:__data_loc char[] comm; offset:16; size:4; signed:1;
field:__data_loc char[] file; offset:20; size:4; signed:1;
print fmt: "(%lx) comm=\"%s\" file=\"%s\"", REC->__probe_ip, __get_str(comm), __get_str(file)
kprobe_events
に対象となる関数とそのときに出力する内容を設定します。具体的な書式は「Kprobe Event Tracing 」の「Synopsis of kprobe_events
」を参照してください。ここでは出てくるもののみを説明します。
最初に「p:
」の後ろの「execve
」はイベント名です。/sys/kernel/debug/tracing/events/kprobes/
以下にイベント名のディレクトリが作られます。今後はそのディレクトリ以下のファイルを操作することになります。
「__do_execve_file.isra.0
」がターゲットとするシンボル名です。「 +offs
」の書式でオフセットを指定することも可能です。そこから先は可変長の引数として使います。ここの書き方は若干理解が難しいポイントです。
「NAME=引数
」で引数にラベルがつけられてそれがそのまま出力されます。「 comm=$comm
」の後者の「$comm
」は特殊な変数で、前述の「Synopsis of kprobe_events
」にあるように、カレントタスクのコマンド名となります。次の「file=+0(+0($arg2)):string
」が__x64_sys_execve
に渡されたファイル名を表示する部分です。まず、__do_execve_file.isra.0
の定義を見てみましょう。
static int __do_execve_file(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags, struct file *file)
ここで欲しいのは2番目の引数にあります。N番目の引数は「$argN
」で指定できます[3] 。これはポインタになっているため、ポインタの先を参照するためには「+0($argN)
」と表記します。「 +OFFS(ADDR)
」や「-OFFS(ADDR)
」で指定したアドレスに対する指定したオフセットのアドレスが示す先のメモリーにアクセスしてくれます。他にも「+uOFFS(ADDR)
」とすることで、該当するアドレスをユーザーランドのアドレスとして扱います。
しかしながら2番目の引数の型である「struct filename
」は次のような構造体です。
struct filename {
const char *name; /* pointer to actual string */
const __user char *uptr; /* original userland pointer */
int refcnt;
struct audit_names *aname;
const char iname[];
};
つまり文字列を取得するためには、再度参照外しをしなくてはなりません。結果的に「+0(+0($arg2))
」というアクセスになっています。最後に「:string
」と付けることでそのアドレスの型が文字列であることを指定しています。
さて実際にトレーサーを起動してみましょう。次のようにenableに1を書くことでトレーサーを起動できます。あとはシステムコール版と使い方は同じです。
$ echo 1 | sudo tee /sys/kernel/debug/tracing/events/kprobes/execve/enable
$ echo 1 | sudo tee /sys/kernel/debug/tracing/free_buffer
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
cat-980 [000] .... 4396.341029: execve: (__do_execve_file.isra.0+0x0/0x840) comm="bash" file="/usr/bin/cat"
<...>-981 [000] .... 4399.065705: execve: (__do_execve_file.isra.0+0x0/0x840) comm="bash" file="/usr/bin/cat"
bash-982 [000] .... 4401.941474: execve: (__do_execve_file.isra.0+0x0/0x840) comm="bash" file="/usr/bin/ls"
bash-983 [000] .... 4404.274790: execve: (__do_execve_file.isra.0+0x0/0x840) comm="bash" file="/usr/bin/touch"
無事にコマンド名と実行しょうとしているファイル名が表示されるようになりました。また、トレーサーを止めて消すには次のように実行します。
$ echo 0 | sudo tee /sys/kernel/debug/tracing/events/kprobes/execve/enable
$ echo '' | sudo tee /sys/kernel/debug/tracing/kprobe_events
上記の場合、登録済みのkprove_event
をすべて消してしまうので注意してください。
ちなみにシステムコールから先の関数として何が呼ばれるかはカーネルのバージョンやコンパイラのバージョンに依存します。たとえばUbuntu 21.04のカーネル5.10だとdo_execveat_common()
を呼び出していることがわかります。よってより新しいカーネルだと次のようなコマンドになります。
$ echo 'p:execve do_execveat_common comm=$comm file=+0(+0($arg2)):string' | \
sudo tee /sys/kernel/debug/tracing/kprobe_events
このようにsysfsを利用したトレーシングは、追加でパッケージをインストールできない環境で、ちょっと動作確認したい際にはこの方法がもっともお手軽で便利です。ただしカーネルのコードをある程度調べる必要があり、さらに複雑なトレーシングをしようとすると途端に難易度があがってしまいます。
bpftraceを用いてトレーシングする
ソフトウェアの依存関係を少なくトレーシングを行いたいならbpftraceも選択肢に入ってきます。パッケージのインストールは必要であるものの、直接sysfsを操作するよりははるかに簡単にトレーシングできます。
bpftraceパッケージはUbuntuリポジトリにもあるものの、最新版を使いたいならsnap版をインストールするのが便利です。
$ sudo snap install bpftrace
$ sudo snap connect bpftrace:system-trace
たとえばexecve()
システムコールの呼び出し時に、呼び出し元のコマンド名とロードする予定のファイル名を出力してみましょう。
$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve
{ printf("%s %s\n", comm, str(args->filename)); }'
tmux: server /bin/sh
byobu-status /usr/bin/sed
sh /usr/bin/byobu-status
byobu-status /usr/bin/tmux
説明が不要なぐらいとても簡単ですね。「 -e
」は渡した文字列をeBPF用に評価するオプションです。そして文字列そのものはbpftrace独自の書式となります。まずは「リファレンスガイド 」の前半を一通り読んでおくと、イメージがつきやすいでしょう。
bpftraceの書式の簡単な説明
まずはイベントやトレースポイントなどのプローブ対象の文字列を書きます。「 execve」が含まれるプローブ対象は、「 -l
」オプションでリストアップ可能です。
$ sudo bpftrace -l '*execve*'
kprobe:__do_execve_file.isra.0
kprobe:__ia32_compat_sys_execve
kprobe:__ia32_compat_sys_execveat
kprobe:__ia32_sys_execve
kprobe:__ia32_sys_execveat
kprobe:__x32_compat_sys_execve
kprobe:__x32_compat_sys_execveat
kprobe:__x64_sys_execve
kprobe:__x64_sys_execveat
kprobe:audit_log_execve_info
kprobe:do_execve
kprobe:do_execve_file
kprobe:do_execveat
tracepoint:kprobes:execve
tracepoint:syscalls:sys_enter_execve
tracepoint:syscalls:sys_enter_execveat
tracepoint:syscalls:sys_exit_execve
tracepoint:syscalls:sys_exit_execveat
システムコールとしては「tracepoint:syscalls:sys_enter_execve
」がエントリーポイントとなり、sysfsのときのようにカーネルの内部関数である「kprobe:__do_execve_file.isra.0
」を使うことも可能です。
ちなみにUbuntu 20.10以降であれば、個々のプローブ対象の詳細を次のように確認できます。
$ sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_execve'
ERROR: Permission denied: /proc/sys/kernel/randomize_va_space
tracepoint:syscalls:sys_enter_execve
int __syscall_nr
const char * filename
const char *const * argv
const char *const * envp
最初のエラーは、ASLR(Address Space Layout Randomization)が有効化どうかをチェックする際に、/proc/sys/kernel/randomize_va_space
の読み込みに失敗したときに表示されます。snapパッケージの場合はこのファイルへのアクセスは許容されていないようです。実害はありませんが、気になるようなら実行時に環境変数BPFTRACE_CACHE_USER_SYMBOLS=0
を明示的に指定しておくと良いでしょう。
eBPFの処理部分は「Action Blocks 」の中に書くことになります。文は改行かセミコロンで区切ります。今回はprintfだけの単文のブロックです。プローブ対象の引数は「args->NAME
」でアクセスできますし、sysfsのときのようにカレントタスクの情報は「comm
」で取得できます。「 char *
」はstr()
で文字列として使えるようになります。ただし設定しない限り、最大64文字です。
出力結果をわかりやすくする
せっかくなので、第690回 のBCCでの出力結果に合わせてみましょう。
$ sudo bpftrace -e '#include <linux/sched.h>
BEGIN { printf("PID PPID COMM FNAME\n") }
tracepoint:syscalls:sys_enter_execve {
$task = (struct task_struct *)curtask;
printf("%-8d %-8d %-16s %s\n", pid, $task->real_parent->tgid, comm, str(args->filename)); }'
Attaching 2 probes...
PID PPID COMM FNAME
1968127 5480 tmux: server /bin/sh
1968128 5480 tmux: server /bin/sh
1968129 1968127 sh /usr/bin/byobu-status
1968130 1968129 byobu-status /usr/bin/sed
1968131 1968128 sh /usr/bin/byobu-status
1968133 1968129 byobu-status /usr/bin/tmux
こちらもほぼ説明が不要なシンプルなコードですね。ポイントは次のとおりです。
task_struct
構造体のために、linux/sched.h
ヘッダーをインクルード
BEGIN
イベント でトレース開始時にヘッダーを出力
組み込み変数 であるcurtask
でカレントタスクの情報を取得し$task
として保存
同じく組み込み変数であるpid
でスレッドグループのIDを取得
カレントタスクから「$task->real_parent->tgid
」で親プロセスのスレッドグループIDを取得
このようにbpftraceを使えば、多少複雑な処理もワンライナーでかけてしまいます。ただしinclude文の後ろには改行が必要です。またbpftraceはスクリプトの中身をファイルとして扱うこともサポートしています。
$ cat <<'EOF' > execve.bt
#include <linux/sched.h>
BEGIN { printf("PID PPID COMM FNAME\n") }
tracepoint:syscalls:sys_enter_execve {
$task = (struct task_struct *)curtask;
printf("%-8d %-8d %-16s %s\n", pid, $task->real_parent->tgid, comm, str(args->filename));
}
EOF
このように「execve.bt」という任意のファイル名でスクリプトの中身を保存しておけば、bpftraceコマンドにファイル名を渡すだけで、さきほどと同じトレースが実行されます。
$ bpftrace execve.bt
Attaching 2 probes...
PID PPID COMM FNAME
2228 852 bash /usr/bin/ls
つまりbpftraceは再利用性も十分確保されているのです。おそらくトレーシング目的であれば、現時点ではbpftraceがもっとも使いやすいのではないでしょうか。