h2ologにみるBPF toolsの構造と設計

最近はお仕事で h2olog を開発しています。これはC++で書いたBPF toolsで、H2Oに組み込まれたUSDTが出力するイベントをトレースするためのツールです。

具体的にはこんな感じで使ってH2OプロセスをトレースしてJSON-Linesにして出力します:

# QUICを有効にした H2O serverが起動しているとして
$ sudo h2olog quic -p "$(pgrep -o h2o)"
{"type":"accept","seq":1,"conn":2,"time":1594471121930,"dcid":"9546e6940ebeec5f"}
{"type":"crypto-decrypt","seq":2,"conn":2,"time":1594471121930,"pn":0,"decrypted-len":1236}
{"type":"quictrace-recv","seq":3,"conn":2,"time":1594471121930,"pn":0}
{"type":"stream-receive","seq":4,"conn":2,"time":1594471121930,"stream-id":-1,"off":0,"len":212}
...

さて、BPFのためにUSDTを定義するプログラムは、次のようにD言語でUSDTを定義します。

https://github.com/h2o/h2o/blob/master/h2o-probes.d

provider h2o {
    probe h1_accept(uint64_t conn_id, struct st_h2o_socket_t *sock, struct st_h2o_conn_t *conn);
    probe h1_close(uint64_t conn_id);
    // ...
}

これらは関数に見えますが、実際にはむしろ probe イベント名(このイベントの構造) という意味のデータスキーマです。そして struct st_h2o_socket_t * などのデータ構造は、USDTを提供するプログラム自身が定義するデータ構造です。つまり、BPF toolsはトレース対象のプログラムのデータ構造を知っていなければいけないのです。

(ところで、USDTをうまく加工するためのBPF toolsを作るときは、USDT定義ファイルからBPFとBPF toolsの一部を生成するコードジェネレータを用意することになります。ひとつひとつのUSDTに対応するPBF program用のイベントハンドラとそれを(たとえば)JSONに加工するBPF tools側のロジックを手動でメンテナンスするのは、あまりにもコストが掛かりすぎるからです。)

初期の h2olog (toru/h2olog) は、データ構造については妥協してH2Oのソースコードから必要な定義だけを抜きだしたヘッダファイルを用意していました(quic.h)。しかし、この方法はMVPを開発する際には十分でしたが、長期的な開発を見据えるといくつかデメリットがあります。つまり、同期を忘れるとリグレッションが容易に起こりうるし、もともとの構造体が複雑だと(実際複雑なのです!)、「必要な定義だけ抜き出す」というだけでも精査するコストがかかります。

BPF toolsがトレース対象のCヘッダファイルにアクセスするのは、BPF tools自体をC++で書くならば簡単です。そこでBPF toolsが使うBPF programが、トレース対象のデータ構造を参照できればこの問題は解決です。そしてsのための方法をいくつか検討しました。

まず、C structのパーサーを用意してトレース対象のヘッダファイルから情報を抜き出すという方法があります。h2ologでいえば、最初はこの方法でコードジェネレータが quic.h をパースしていました。しかし、実際にH2Oのヘッダファイルをパースするとなると、様々なtypedefやネストした構造体をパースするために、ほとんど完全なCの構造体のパーサを再発明することになります。これは現実的ではありません。

次に、H2Oのヘッダファイル全体をBPF programに埋め込む、または BPF program でH2Oのヘッダファイルをincludeする、という方法です。BPF programの文法はCなので、これは一見うまくいきそうでしたが、結局コンパイルできませんでした。BPF programは構文がCというだけで、まったく異なるコンパイルターゲットです。たとえば、BPF programからはCの標準ライブラリやPOSIX/Linuxの標準ライブラリも使えません。つまりH2Oの構造体がCの標準ライブラリにある構造体を含む場合、BPF programにincludeしてもコンパイルできないのでした。

さらに、pahole(1)でバイナリのデバッグ情報 (DWARF) からCの構造体を再構築する方法 (h2o/h2o#2365-651525032) も検討されました。paholeはDWARFを読んでCのソースコードを出力するため、うまくいけばコードジェネレータとその生成コードは一番シンプルになると思われたのですが、pahole自体は「特定の構造体とその依存関係だけをCコンパイラでコンパイル可能なソースコードとして出力する」という機能はありません。結局paholeの出力したCの構造体をパースする必要があり、今回の目的に使うのは不可能ではないにせよかなり困難でした。

最後に、必要な構造体から情報を抜き出すCプログラムを生成して情報を吐き出させる、という方法です (h2o/h2o#2365-651448880)。これはBPF programを生成するコードジェネレータのためにさらに別のコードジェネレータでCプログラムを生成する必要があるため、コードジェネレータは複雑になります。しかしこのコードジェネレータの二重構造以外はコードジェネレータをシンプルに保てるうえ、長期的にも安定した動作が見込めるため、最終的にはこの方法を採用しました。

pull-req: https://github.com/h2o/h2o/pull/2372

最終的なh2ologの設計はこんな感じになりました

  • gen-quic-bpf.py は、USDT定義ファイル(*.d)を入力にして C の BPF program と C++ の BPF tools を生成する、Python製の code generator
    • gen-quic-bpf.pygenerated-quic.cc を生成する、これが BPF tools の一部で、中に BPF program を文字列リテラルで持つ
    • generated-quic.cc はrepoにコミットする。そのほうが gen-quic-bpf.py の変更の影響を把握しやすいので
  • gen-quic-bpf.py はさらに、「BPF programの一部を生成するコードジェネレータ(C++製のcode generator)」を生成する
    • これはh2ologの実行中にコードを生成する。生成コードは h2olog(1) に -dd を与えることで見られる

BPF toolsプログラムにだけ注目すると、トレース対象プログラム(ここではh2o)と同じコンパイラでコンパイルしたBPF tools(ここではh2olog)が、トレース対象プログラムのデータ構造へのアクセス方法を実行時に構築してBPF programに渡すということです。

h2ologのソースコードはこちらです。

ここではh2ologについてのみ触れましたが、特定のプログラムに特化したBPF toolsを開発するときはいずれにせよこういう設計になると思います。