Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載。今回は、BPFを使ったLinuxにおけるトレーシングの基礎について。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載「Berkeley Packet Filter(BPF)入門」。今回から数回にわたり、BPFの代表的な応用先の一つであるトレーシングに焦点を当て、その動作と利用方法を解説していこうと思います。今回はLinuxにおけるトレーシングの基礎を説明します。
まず、そもそも「トレーシング」とは何でしょうか。字義的には「跡を追う」という意味ですが、転じて一般には「イベント発生の記録」を意味します。例えばHTTPサーバを考えると、イベントとしてはコネクションの接続や切断、あるいはクライアントからのデータの受信などがあります。ソフトウェアのバグの調査やパフォーマンス改善には、こうしたイベントの発生情報の取得が必要不可欠です。
Linuxカーネルにはシステムやアプリケーションをトレーシングするための機構が複数備わっており、それらをBPFから利用できるようになっています。
これまでの連載で説明してきたように、BPFプログラムは特定のイベントに応じて呼び出され、処理を行います。そこで、トレースしたいイベントにBPFプログラムをひも付け、イベント発生時にそのBPFプログラムから記録を取ればトレーシングができることになります。イベントの記録はBPFマップを利用するか、あるいはヘルパー関数経由から追記専用のバッファーにデータを書き込むことで行います。ユーザーは記録したデータにシステムコール経由でアクセスすることができます。
以下にBPFによるトレーシングの概略図を示します。
この方法には以下のような特徴・利点があります。
BPFにひも付けられるイベント(を提供する機能)としては、主に以下のものがあります。
BPFでトレースを行う場合、BPFのプログラム作成方法に加え、どのようなイベントが利用可能であるかも知る必要があります。以下で、それぞれのイベントの概要を説明します。
なお、これらの機能はいずれも(e)BPF登場前からLinuxで開発されてきた機能であり、BPF固有のものではありません。BPF以外にも例えば自分でカーネルモジュールを書いてそこからこれらの機能を利用することができます。
TracepointsはLinuxのコード内にイベントのフックポイントを作成するための仕組みです。例えば、Tracepointsによって以下のように「arg1」と「arg2」を引数とするイベントを定義できます。
void func(void) { ... trace_subsys_eventname(arg1, arg2); ... }
こうして作成したTracepointsは、専用のAPIを用いて特定の関数とひも付けることができ、この「trace_subsys_eventname()」のコードが実行されたときにそのひも付けた関数が呼び出されます。Linuxのソースコード内には、このようにTracepointsによって定義されたイベントが数百以上存在します。それらのイベントは「ls /sys/kernel/debug/tracing/events/」から確認できます。
もう少し具体例を見てみましょう。例えばプロセス切り替え時には「shced/sched_switch」というTracepointsが呼び出されます。このときの引数は以下のように確認できます。
% sudo cat /sys/kernel/debug/tracing/events/sched/sched_switch/format name: sched_switch ID: 319 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:char prev_comm[16]; offset:8; size:16; signed:1; field:pid_t prev_pid; offset:24; size:4; signed:1; field:int prev_prio; offset:28; size:4; signed:1; field:long prev_state; offset:32; size:8; signed:1; field:char next_comm[16]; offset:40; size:16; signed:1; field:pid_t next_pid; offset:56; size:4; signed:1; field:int next_prio; offset:60; size:4; signed:1;
Tracepointsから呼び出される関数は、ポインタデータを受け取ります。プログラムは上のオフセットやサイズの情報を参考にしてデータにアクセスします。上記の情報から、「sched_switch」のTracepointsには対象となるプロセスのプロセス名(「prev_comm」「next_comm」)やPID(「prev_pid」「next_pid」)が含まれていることが分かります。これらの情報から、どのプロセスからどのプロセスへコンテキストスイッチが発生したのかなどの情報が記録できます。
Linuxでは、Tracepointsは原則ユーザーに対するAPIとして見なされます。これはつまり、カーネルバージョンが変わってもイベントの引数は変わらないということです(ただし現実には、まれに変わったことがあります)。
このような特徴から、Tracepointsは「静的トレーシング(static tracing)」と呼ばれることがあります。Linuxのカーネル関数はよく引数が変わったり、なくなったり、新たな関数が加えられたりするので、安定して引数にアクセスできるのはトレーシングツールを開発する側からすると大きな利点です。
なお、定義したTracepointsのイベントを発生させる部分は「nop」としてコンパイルされ、対象となるTracepointsを有効にしたときのみ動的にそのコードが書き換えられて処理が実行されます。従ってTracepoints無効時(通常時)のオーバーヘッドは実質0です。
kprobeはカーネル内のほぼ全ての箇所における実行のトレースを実現するための仕組みです。具体的には、ブレークポイント命令を利用することでコードの任意の箇所をフックし、そこから事前にひも付けられたコールバック関数を呼び出します。また、(利用ケースの多い)関数の呼び出しのトレースには、「ftrace」という関数呼び出しをフックするための機構が有効であれば、そちらを利用します。
ftraceはコンパイル時に関数先頭にnopを埋め込んでおき、有効時にのみそこをパッチしてコールバック関数を呼び出します。こちらの方がブレークポイント命令を利用しない分、トレーシングのオーバヘッドが小さくなります。
またkretprobeは関数の戻り(リターン)をトレースするための仕組みで、内部的にはkprobeを利用して実装されています。
kprobeから呼び出された関数は、そのkprobeを実行する時点のレジスタ値(「pt_regs」)にアクセスできます。例として、「vfs_open()」というカーネル内関数を考えます。この関数はLinux 5.5時点で「int vfs_open(const struct path *path, struct file *file, const struct cred *cred)」と定義されています。ここで、kprobeを利用してこのカーネル関数の呼び出しをフックしたとします。このとき、コールバック関数からはレジスタの値を参照することでこの関数の引数にアクセスします。例えば「path」を参照したい場合、x86_64であればABIで第1引数は「RDIレジスタ」と決まっているので、その値を参照すれば目的の引数にアクセスできます。
Tracepointsと比較して、kprobeはカーネルの任意の箇所をトレースできるため、「動的トレーシング(dynamic tracing)」と呼ばれることがあります。kprobeの柔軟性は非常に強力ですが、一方で、前述のようにカーネル内関数はよく変更されるため、カーネルバージョンが変わった場合に同じkprobeのコールバック関数が動作する保証はどこにもありません。コールバック関数から目的の引数にアクセスするためには適切にレジスタ値を参照する必要があります。一般にはTracepointsが利用可能な場合はそれを、そうでない場合にkprobeを利用するといいでしょう。
Linux 5.5から関数の呼び出しをフックする場合において、BPFではkprobeの代替としてBPF trampolineによるフックが利用できるようになっています。BPF trampolineに関しては前回の記事を参照してください。
uprobeおよびuretprobeはそれぞれkprobe、kretprobeのユーザープログラム版です。k(ret)probeと同様にブレークポイント命令を挿入することにより,u(ret)probeはユーザープログラムのトレーシングを実施します。カーネルにユーザープログラムのトレーシング機能があるのは意外かもしれませんが、これによって特定のpidにおけるアプリケーションの処理を動的にフックしたり、あるいは特定パスのバイナリを実行したときに自動でそのアプリケーションにフックを追加したりできます。uprobeはその仕組みから明らかなように、トレースをするためにユーザースペースからカーネルスペースへのコンテキストスイッチが発生します。
uprobeはkprobeと同様に動的トレーシングの一種であり、コールバック関数からはイベント発生時のレジスタ値にアクセスできます。uprobeにおいてtracepointsのような安定的なトレースを実現するために「USDT(User Statically Defined Tracing)」という仕組みがあります。USDTはもともと「Dtrace」「SystemTap」で利用されてきた方法で、Tracepointsのように明示的にソースコード内でイベントを定義します。
例えば、SystemTapのヘッダを利用して、以下のようにイベントを定義できます。
#include <stdio.h> #include <sys/sdt.h> int main(){ int a = 1; DTRACE_PROBE1(test, probe1, a); printf("%d\n", a); return 0; }
「DTRACE_PROBE1()」が定義したイベントです。この情報はバイナリの特定のELFセクションに保存されます。
% readelf -n ./a.out [...] Displaying notes found in: .note.stapsdt Owner Data size Description stapsdt 0x00000032 NT_STAPSDT (SystemTap probe descriptors) Provider: test Name: probe1 Location: 0x0000000000000659, Base: 0x0000000000000708, Semaphore: 0x0000000000000000 Arguments: -4@-4(%rbp)
ここで、「Location」がバイナリ内のイベントの位置を意味します。この箇所は、コンパイル時はnopとなっています。また「Arguments」から、RBPレジスタ(ベースレジスタ)のオフセット-4に型が-4(これは「signed int」を意味します)の値が入っていることが分かります(これは上記のプログラムではイベントの引数として渡した「a」の値です)。トレーシングプログラムは、この情報を参照してuprobeを設定することで、このイベントにアクセスします。このように、USDTは仕組みとしてはuprobeを利用しますが、イベントの情報をバイナリに含めることでTracepointsのような機能を実現します。なお、ここではSystemTapのヘッダを利用してUSDTを定義していますが、作成したアプリケーションは特別SystemTapにひも付いておらず、SystemTap以外のトレーシングツールからでも利用可能です。
perf eventはLinuxに含まれるトレーシングフレームワークの一つで、ユーザーからは「perf_event_open(2)」というシステムコールでこのperf eventの機能を利用できます。
perf eventにはさまざまな機能がありますが、代表的な機能の一つがCPUのパフォーマンスカウンタ値の計測です。CPUのパフォーマンスカウンタからはIPC(Instruction Per Cycle)やキャッシュミス率などの値が取得できます。値を取得するだけではイベントにはなりませんが、パフォーマンスカウンタには特定のカウンタが一定の値になった場合に割り込みを発生させる機能があり、これをイベントとして扱うことができます。
この割り込みを発生させる機能をCPUサイクル数などの線形に単調増加するカウンタに応用することで、一定時間間隔でイベントを発生させることができます。これはいわゆるサンプリング処理に利用できます。イベント発生時のコールバック関数からはその時点でのレジスタ値やスタックトレースを取ることができるため、これらを記録することで「どの関数がどれだけ実行されているのか」といった統計情報が得られます。
またperf eventには、この他、Tracepointsのようにカーネルのソース内に明示的に定義されたイベント「software event」や、特定のメモリアクセス(read/write含む)時にハードウェアブレークポイントを利用してイベントを発生させる機能「watchpoint」があり、これらもBPFプログラムとひも付けて利用可能です。
また、BPFプログラム内からは「bpf_perf_event_output()」というヘルパー関数を利用して追記専用のバッファーにデータを記録できますが、このバッファーは(名前から示されるように)perf eventの機能を利用する形で実装されています。さらに、実はBPFプログラムを各種イベントにひも付けるにも「perf_event_open(2)」が利用されています(これにはkprobeやTracepointsのイベントも含みます。perf eventはkprobeやTracepointsのイベントも扱えるフレームワークです)。
ここまでトレース時にBPFから利用可能なイベントについて見てきました。BPFプログラムタイプの観点で見ると、以下のようになります。
イベント | BPFプログラムタイプ | BPFプログラムの引数 |
---|---|---|
tracepoints | BPF_PROG_TYPE_TRACEPOINT | 各種tracepoitnsの引数 |
k(ret)probe | BPF_PROG_TYPE_KPROBE | struct pt_regs |
u(ret)probe | BPF_PROG_TYPE_KPROBE | struct pt_regs |
perf event | BPF_PROG_TYPE_PERF_EVENT | struct bpf_perf_event_data(struct pt_regs+α) |
BPFプログラムを特定のイベントにひも付け、トレースする流れは少々複雑です。おおむね以下のようになるでしょう。
具体的な処理はLinuxカーネルの「sample/bpf」にある「tracex*.c」が参考になります。
BPFでのトレースは前述の方法で実現できますが、そのままではあまりに大変なので、通常はライブラリを使用することになるでしょう。そのための重要なライブラリが、以前の連載で紹介した「bcc」です。また最近ではより簡単にトレースを実現するためのライブラリやアプリケーションの開発が幾つか進められており、その中でも広く利用されているものが「bpftrace」です。次回はbpftraceを使ってBPFによるトレーシングを詳しく見ていきます。
最後に、Linuxでトレーシングをするためのツールには他にもperfやtracecmd、SystemTap、DTrace(for Linux)、LTTngなど数多く存在します。それぞれ内部的にはTracepointsやkprobeなどを利用しています。これらのツールと比較して、BPFの利点は比較的容易かつ(BPFの検証器的な意味で)安全にオーバーヘッドの少ないプログラマブルなトレーシングが実現できる点です。
プログラマブルにトレースするという点では(e)BPFより先にSystemTapやDTrace(for Linux)がありますが、いずれも機能の全体はカーネルに取り込まれておらず、カーネルモジュールが必要となります。トレーシングツールは機能が重複するものも多く、どれが何によいかというのは一概にはいいにくい面もありますが、bccやbpftrace以外にも選択肢はあるので、実用上は適材適所でツールを使うことが重要になるでしょう。
perf eventやftraceに関しては以前筆者が書いた文章がありますので、合わせて参考にしてください。
東京大学 大学院 情報理工学系研究科 博士課程
オペレーティングシステムや仮想化技術の研究に従事。
Copyright © ITmedia, Inc. All Rights Reserved.