新しいトレーシング用ツール
bpftrace
は、Alastair Robertsonさんたちによって開発されているBPFのフロントエンドとして動作するトレーシング・ツールです。同じようなものとしては、BPF Compiler Collection (BCC)という高機能なツール群が既にあるのですが、bpftrace
では、付属する独自の言語を使って、その場に応じたトレーシングが簡単にできるように工夫されています。
こうしたツールは運用の現場で起きる突発的なパーフォーマンスの劣化やリソースの大量消費等のトラブルシューティングに使われることが多いため、「簡単に使える」ことは大切です。現場の担当者がプレッシャーに圧し潰されそうになりながら原因調査を行っている状況を想像してみてください。簡単に使えて、詳細なデータを採取できるツールがあれば、助かりますよね。
bpftrace
はそんなツールです。本稿では、その有用性を理解してもらうために、仕組みよりも「使い方」に重点を置いて説明します。もし興味を持ったら、Brendan D. Greggさんのホームページで公開されている詳細な資料を見てください。
トレーシング・ツールとは
システムのパーフォーマンスやリソースに関連するコマンドは実にたくさんあります。まず、〜statというような名前のものがあります。すぐに思いつくものだけでも、mpstat
, netstat
, vmstat
, iostat
, dstat
がありますね。他にもps
, top
, sar
は良く知られています。
こうしたコマンドは、そのときの状態(State)や一定間隔毎の統計情報(Statistics)を表示することを目的としています。軽いので日常的な監視には最適です。しかし、問題が発見されても、これらのツールだけで、根本原因の特定に至るのは難しいと思います。
そこで必要となるのがトレーシング・ツールです。特定の観察対象を決めて、それに関わるデータを継続的に収集することを目的としたツールです。システムへの負荷が高くなる傾向があるため、日常の監視用には不向きですが、トラブルシューティングには必要不可欠なものです。
トレーシング・ツールとして一番有名なものは、tcpdump
コマンドでしょう。このコマンドはネットワークを流れるパケットを観察対象とすることができるため、ネットワーク関連のトラブルシューティングには大変役に立ちます。しかし、当たり前ですが、tcpdump
コマンド(より一般的には、パケット・フィルタリング・ツール)では、観察対象がパケットに限られています。それに対して、これから説明するbpftrace
コマンドでは、様々なものを観察対象とすることができる点に特徴があります。
BPFとは
bpftrace
コマンドは、その名前("bpf" + "trace")のとおり、BPFの仕組みを使ったトレーシング・ツールです。"trace"については既に説明したので、BPFについても説明しなければ片手落ちですが、ここでは詳細な説明はしません。bpftrace
コマンドはツールなので、その仕組みが分からなくても使うことはできます。(もちろん、理解して使えば、より上手に使えるはずです。)
ここでは、差し当たって以下の2つを覚えておけば十分です。
-
bpftrace
コマンドはカーネル側から提供されれるBPFという仕組みを使っている。 - BPFは、主要な機能の実装は終わっているが、現在も改良が継続して行われている。
そのため、カーネルのバージョンが新しいほど、多くの機能が使えるようになっています。以下の例では、最近リリースされた**Fedora 29 (Kernel 4.19)**を使用しています。
スタティック/ダイナミック トレーシング
トレースする対象や箇所が予めバイナリに埋め込まれているトレーシングはスタティック・トレーシングと呼ばれます。先に挙げたtcpdump
コマンドはスタティック・トレーシングの一例です。より簡単なものでは、デバッグ用のprintf
文もスタティック・トレーシングの例と言えるでしょう。
それに対して、対象や箇所を自分で決めて行うトレーシングをダイナミック・トレーシングと呼びます。サービスを開始した後にエラーが発生し、ログをベースに調査しているとき、「この関数が返すエラーコードがエラーログに出力されていればどれだけ助かっただろう!」と思ったことはありませんか。ダイナミック・トレーシングを使えば、こうしたケースにも対応可能になります。
bpftrace
コマンドでは、トレースする箇所を指定するために以下の4つのキーワードが用意されています。
-
kprobe
,kretprobe
: カーネル空間でのダイナミック・トレース用 -
uprobe
,uretprobe
: ユーザ空間でのダイナミック・トレース用
ちなみにスタティック・トレース用に使われるキーワードはtracepoint
です。
bpftraceコマンドのインストール方法
使用しているOSがFedoraの場合、インストールは非常に単純です。この手順に従ってください。ディフォルトの設定では、bpftrace
コマンドは/usr/local/bin
に、bpftrace
コマンドをベースにしたツール群は/usr/local/share/bptrace/tools
にインストールされます。
練習用のサンプルコード
後半では、キーワードuprobe
とuretprobe
を使ってユーザ・プログラムを対象としたダイナミック・トレーシングの練習をします。以下がその練習に使うサンプルコードtracesample.c
です。
#include <stdio.h>
#include <string.h>
size_t getBufSize(const char *buf) {
return strlen(buf);
}
int main(void)
{
char buffer[256];
size_t length;
for (;;) {
printf("Input: ");
if (fgets(buffer, 256, stdin) == NULL || buffer[0] == '\n') {
break;
}
length = getBufSize(buffer);
printf("Length: %zu\n", length - 1);
}
return 0;
}
かなりわざとらしいのですが、後でuprobe
とuretprobe
の使い方を説明をするために、標準入力から入力された文字列の長さを測る処理を別立てのgetBufSize()
という関数にしています。コンパイラは、gcc
でもclang
でも構いません。DWARFデバッグ情報を付加する必要もありません。シンボルテーブルは必要なので、ストリップしないでください。
# gcc -o tracesample tracesample.c
# ./tracesample
# Input: aaaa
Length: 4
これで準備完了です。
uprobeを使った引数の確認
それでは、uprobe
を使ってgetBufSize()
関数に渡される引数の値を見てみましょう。引数の値は、arg0
, arg1
,...といったビルトイン変数に設定されているので、それをプリントするだけです。コンパイルを行ったディレクトリで、以下のようにbpftrace
コマンドを起動してみてください。
$ sudo bpftrace -e 'uprobe:./tracesample:getBufSize { printf("geBufSize arg0: %s\n", str(arg0)); }'
ここで再度、tracesample
に適当な文字列を入力してください。それが上記にも出力されるようになっていることが分かります。
$ sudo bpftrace -e 'uprobe:./tracesample:getBufSize { printf("geBufSize arg0: %s\n", str(arg0));}'
Attaching 1 probe...
geBufSize arg0: aaaa
uretprobeを使った戻り値の確認
次は、uretprobe
を使って、getBufSize()
の戻り値を確認します。戻り値はビルトイン変数retval
に設定されているため、今度もその値をプリントするだけです。
$ sudo bpftrace -e 'uretprobe:./tracesample:getBufSize { printf("getBufSize returned: %d\n", retval); }'
Attaching 1 probe...
getBufSize returned: 5
簡単ですね!
ダイナミック・リンク・ライブラリのトレース
今度は、サンプル・プログラムがダイナミック・リンクしている関数をトレースしてみましょう。まず、どんな関数をダイナミック・リンクしているかを調べます。
$ objdump -T ./tracesample
./tracesample: ファイル形式 elf64-x86-64
DYNAMIC SYMBOL TABLE:
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 strlen
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 printf
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 __libc_start_main
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 fgets
0000000000000000 w D *UND* 0000000000000000 __gmon_start__
0000000000404040 g DO .bss 0000000000000008 GLIBC_2.2.5 stdin
GLIBCで定義されているfgets()
が良さそうです。念のためldd
コマンドを使ってライブラリの場所を確認しておきましょう。
$ ldd ./tracesample
linux-vdso.so.1 (0x00007ffc3718b000)
libc.so.6 => /lib64/libc.so.6 (0x00007fad00377000)
/lib64/ld-linux-x86-64.so.2 (0x00007fad0055a000)
それでは、このダイナミック・リンク・ライブラリで定義されているfgets()
関数をトレースしてみます。
$ sudo bpftrace -e 'uretprobe:/lib64/libc.so.6:fgets { printf("fgets: %s\n", str(retval));}'
何か、色々出て来ましたね。fgets()
を使っているプログラムは他にもたくさんあるためです。そういうときは、comm
というビルトイン変数を使って対象を絞り込みます。この変数には、プロセス名が設定されています。
$ sudo bpftrace -e 'uretprobe:/lib64/libc.so.6:fgets / comm == "tracesample" / { printf("fgets: %s\n", str(retval));}'
これでtracesample
に対する入力のみが表示されるようになります。
uprobeとuretprobeを一緒に使う
uprobe
とuretprobe
を組み合わせて使えば、関数の処理にかかった時間を計ることができます。
$ sudo bpftrace -e 'uprobe:./tracesample:getBufSize { @start = nsecs; } uretprobe:./tracesample:getBufSize { printf("Latency: %d\n", nsecs - @start );}'
nsecs
というのが現時刻のタイムスタンプ(単位:ナノ秒)です。getBufSize()
は、実際には、strlen()
しかしていません^^;。すぐに終わってしまうため、良い例ではありませんが、使い方は分かっていただけると思います。
これを使えば、サーバのどの関数で時間がかかっているかを調査することができます。実際のサーバは、マルチプロセス/マルチスレッドで動作しているため、pid
(プロセスID)やtid
(スレッドID)といったビルトイン変数を使い、開始時刻と終了時刻を対応付けることが必要で、もう少し複雑になります。どうすれば良いかは、後ほど紹介するone-linerにあるRead時間の計測の例を参考にしてください。
興味を持ったなら
どうでしたでしょうか。bpftrace
コマンドの可能性を実感していただけたでしょうか。もし興味を持たれたのであれば、このbpftrace
を使ったone-linerを見てみることをお勧めします。ここまでで説明したことは、機能のごく一部にすぎませんが、ほとんどのone-linerはおおよそ理解できるようになっているはずです。
* このコマンドは、まだ開発中ということもあり、不安定な部分もあります。運用環境で使うのは、もうしばらくしてからの方が良いかもしれません。
* 本稿は、弊社内で開催したBPFとトレーシング・ツールに関する勉強会の内容を元に、そのとき行ったデモを中心に再構成したものです。