Upgrade to Pro — share decks privately, control downloads, hide ads and more …

OTel eBPF Profiler Deep Dive 〜eBPF でスタックトレースを集め...

Avatar for Mitsuhiro Tanda Mitsuhiro Tanda
November 06, 2026
220

OTel eBPF Profiler Deep Dive 〜eBPF でスタックトレースを集める仕組み〜

Avatar for Mitsuhiro Tanda

Mitsuhiro Tanda

November 06, 2026

Transcript

  1. 自己紹介 反田 光洋 (たんだ みつひろ) @mtanda Staff Developer Advocate Grafana

    Labs ▶ 約 10 年来の Grafana / Prometheus ユーザー ▶ Grafana 初期バージョンからのコントリビューター
  2. 1. CPU プロファイラの仕組み コールスタックを一定間隔で記録して数える プロファイラは、動いているプログラムのコールスタックを定期的に読み取ってス タックトレースとして記録し、同じスタックトレースの出現回数を数える。 ① 一定間隔で割り込む s1 s2

    s3 実行中のプログラム s4 ② スタックトレースを記録 s1 main;handle;parse s2 main;handle;parse s3 main;handle;send s4 main;auth ③ 同じスタックを数えて合算 幅 = サンプルに記録された回数 parse send handle main サンプルに多く記録された関数ほど、CPU 時間を多く使っている。 auth
  3. 1. CPU プロファイラの仕組み opentelemetry-ebpf-profiler の特徴 取れるもの CPU を使っている関数のスタックトレース(on-CPU)。off-CPU はオプション で、デフォルトは無効

    対象 ホスト全体のプロセス。コードの書き換え不要 ネイティブ C / C++ / Rust / Go など ネイティブ以外 Python / Ruby / PHP / Node.js(V8)/ JVM(HotSpot)/ .NET など
  4. 2. 既存の方式との比較 言語組み込みのプロファイラ (Go の pprof の場合) プロセス中のランタイムが、自分のスタックを自分で辿る。ランタイムは自分のス タックの構造を知っている。 Go

    のプロセス(アプリ自身のプロセス) シグナルハンドラ プロファイルバッファ 書き出しの goroutine pprof 形式 ランタイムがスタックを辿る 採取したスタックを貯める バッファを読む ファイル / HTTP SIGPROF(100Hz) ユーザー空間 カーネル CPU 時間タイマー スレッドごと(timer_create) スタックの構造を知っているのはランタイム自身
  5. 2. 既存の方式との比較 “言語組み込み”と“eBPF”の比較 言語組み込み eBPF 動く場所 対象プロセスの中 カーネル(プロセスの外) スタックの構造 ランタイム自身が把握している

    カーネルは知らない。見えるのはレジス タと生のメモリだけ 対象 プロファイラを組み込んだ、その プロセスだけ ホスト全体(言語もビルドもばらばら) 導入 アプリにプロファイラを組み込む アプリの変更も再起動も不要 eBPF は、コードを変えずにホスト全体を見られる。その代わり、スタックの構造を 外側から復元する必要がある。
  6. 2. 既存の方式との比較 プロセスの外側からコールスタックを復元する難しさ • ホスト上のプロセスは、言語もビルドもばらばら • C 言語などは、フレームポインタが省略されていなければ rbp レジスタを辿るだけでよい

    • 最適化が有効な場合は、フレームポインタは省略されてしまう • Python など、言語固有のコールスタックにも対応する必要がある • eBPF プログラムはカーネル内で動くため、複雑な処理は難しい
  7. 2. 既存の方式との比較 言語に合わせたスタックアンワインド ネイティブ ネイティブ以外 対象 C / C++ /

    Rust / Go など Python / Ruby / PHP / Node.js (V8)/ JVM など 辿る unwinder ネイティブ unwinder 言語別の unwinder 進み方の手がかり StackDelta ランタイムの構造体 • スタックアンワインドとは、レジスタとメモリの値からコールスタックを復元すること • サンプリングは、どちらも同じ • インタプリタのプロセスでも、ランタイム自体はネイティブのバイナリ。ネイティブの区 間はネイティブ unwinder、インタプリタのループに入ったら言語別 unwinder、と切り替 わる
  8. 2. 既存の方式との比較 eBPF によるプロファイリングの実現方法 perf_event のタイマー割り込みをトリガーに、対象プロセスのスタックをアンワイン ドして ring buffer に積み、ユーザー空間のホストエージェントが

    Pyroscope へ送 る。 ホストエージェント OTLP push 対象プロセス 読み出し OTLP 変換 on-CPU で実行中 ユーザー空間 割り込み時の PID/スタックを読む カーネル perf_event native_tracer_entry スタックアンワインド ring buffer タイマー割り込み 全サンプル共通の入口(perf_event) ネイティブ/言語別を切り替えて辿る trace_events Pyroscope
  9. 3. サンプリング on-CPU と off-CPU が計測する対象の違い スレッドの時間は、CPU で実行中の時間と、CPU から外れて待つ時間に分かれる。 実行中

    I/O 待ち 実行中 ロック待ち 実行中 on-CPU(実行中) off-CPU(待ち) • CPU を使っている時間の内訳 •「CPU を消費しているのはどの関数か」が分かる • CPU から外れている時間の内訳 ‣ I/O 待ち、ロック待ち、スケジューラの待ちな ど •「どこで待たされているか」が分かる どちらも同じ unwinder でスタックを採取する。今日は on-CPU の方だけ解説する。
  10. 3. サンプリング CPU で実行中に perf_event で定期的に割り込む • perf_event は、Linux カーネルの性能計測の仕組み

    • 一定の周期でイベントを発生させ、BPF プログラムを呼び出せる • デフォルトは 1 秒間に 20 回、周期は設定により変更可能 • 周期タイマーは CPU ごとにトリガーされる • トリガーされた時点で、get_pid_tgid()により「割り込まれたタスク」の PID/TID を読み、プロセスに紐付ける
  11. 4. ネイティブコードのアンワインド コールスタックを求めるのに必要な情報 サンプルで得る値 StackDelta を参照するキー 記録するバイナリファイル内の位置 BPF マップを(PID, PC)で参照する

    PID file ID 割り込まれたタスク バイナリの識別子 キー 次のフレームでは、PC は呼び出し元の PC、 SP は CFA になり、同じ手順を繰り返す ロードバイアス PC 読み込み先のずれ レジスタ StackDelta CFA 呼び出し元の PC CFA の求め方 SP/FP + オフセット CFA − 8 のメモリを読む キー SP / FP レジスタ ファイル内の位置 ファイル内の位置 PC − ロードバイアス 記録する値
  12. 4. ネイティブコードのアンワインド StackDelta とは • コールスタックを辿るには、各関数が「どこに戻るか」(戻りアドレス)を、スタックか ら読む必要がある • 戻りアドレスは、その関数が呼ばれた時点のスタックの位置(CFA)の、8 バイト下に置

    かれる • 一方、スタックの先頭(rsp)は、push や変数の確保のたびに動く。「rsp から何バイト先 か」は、PC によって変わる • この「バイナリファイル内の位置ごとの CFA の求め方」は、バイナリ自身が、Call Frame Information(CFI)として持っている • StackDelta とは、位置の範囲ごとに「rsp にいくつ足せば CFA になるか」を CFI から計算 した表
  13. 4. ネイティブコードのアンワインド StackDelta で戻りアドレスを求める スタックは低いアドレスへ伸びる 呼び出し元のフレーム rsp + 48 戻りアドレス(8

    バイト) rsp + 40 保存した rbp(8 バイト) rsp + 32 ① CFA = rsp + 48 ② 戻りアドレス = CFA − 8 のメモリの値 CFA − 8 = rsp + 40 の位置 StackDelta = 48 ローカル変数(32 バイト) rsp 例:push rbp と sub rsp, 32 を実行したあとの PC
  14. 4. ネイティブコードのアンワインド StackDelta は一度だけ計算して、BPF マップに載せる • ホストエージェントが、/proc/<pid>/maps からバイナリのマッピングを検出し、 file ID

    を計算する • バイナリ内の CFI から、ファイル内の位置の範囲ごとの StackDelta を一度だけ計 算する • file ID をキーにした BPF マップへロードする • サンプリング時、eBPF 側は StackDelta を参照するだけで、CFI の解釈はしない
  15. 4. ネイティブコードのアンワインド file ID はバイナリ内容のハッシュ file ID = SHA256( File[:4096]

    || File[-4096:] || len(File) ) の先頭16バイト • inode ではなく内容から決まる。同じバイナリなら、別ホスト・再起動後・別プロ セスでも同じ file ID • 計算するのはホストエージェント • libc.so.6 のような共有ライブラリも、1 つのバイナリとして別の file ID を持つ • StackDelta テーブルを、プロセスごとではなくバイナリ単位で使い回せる。シン ボルのキャッシュもこの file ID をキーにできる
  16. 4. ネイティブコードのアンワインド PC からファイル内の位置を求められるようにする c15456cf0000-c15456cf1000 r-xp 00000000 00:2a 1865102 ea5231d60000-ea5231efc000

    r-xp 00000000 00:2a 1861919 ea5231f0d000-ea5231f10000 r--p 0019d000 00:2a 1861919 ea5231f20000-ea523238f000 r-xp 00000000 00:2a 1865324 /usr/local/bin/python3.12 /usr/lib/aarch64-linux-gnu/libc.so.6 /usr/lib/aarch64-linux-gnu/libc.so.6 /usr/local/lib/libpython3.12.so.1.0 • ホストエージェントは、/proc/<pid>/maps の各行から、file ID とロードバイアスを求めて、BPF マップに登録する • サンプル時は、(PID, PC)で参照すると、PC を含む範囲の file ID とロードバイアスが得られる ロードバイアス = プロセス内アドレス範囲の開始(1 列目の左) − ファイル内オフセット(3 列目) ファイル内の位置 = PC − ロードバイアス 例:PC = ea5231d7a123(libc.so.6 の r-xp のアドレス範囲の中) ロードバイアス = ea5231d60000 − 0 = ea5231d60000 ファイル内の位置 = ea5231d7a123 − ea5231d60000 = 0x1a123
  17. 4. ネイティブコードのアンワインド スタックアンワインドの流れ バイナリ ホストエージェント BPF マップ .eh_frame / .gopclntab

    パースは一度だけ StackDelta(file ID ごと) ユーザー空間(一度だけ) カーネル(サンプルごと) サンプル時の PC file ID とファイル内の位置 StackDelta を参照する CFA → 戻りアドレス 割り込まれたタスク (PID, PC)で参照して変換 位置の範囲 → CFA の求め方 呼び出し元の PC になる 呼び出し元の PC で繰り返す(tail-call、カーネルの上限 32 回の手前の 29 回で打ち切り)
  18. 5. ネイティブ以外のアンワインド(Python) バイトコード実行中かどうかの判定 • ホストエージェントが、マッピングされたファイルのうち、名前が libpython3.X* や python3.X に合うものを見つけ、ELF を開いて実際のバージョンを確認する

    • バイトコードを実行するループ関数(_PyEval_EvalFrameDefault)のシンボル範囲 を、ファイルごとに一度だけ求める • interpreter_offsets マップに、(file ID, 範囲)を登録する • サンプリング時、ネイティブ unwinder は、PC がこの範囲に入っているかを見る だけ。入っていれば、Python 用の BPF プログラムへ tail-call する
  19. 5. ネイティブ以外のアンワインド(Python) Python のスタックを読むには • Python の関数呼び出しは、ネイティブスタックには現れない。CPython が、自分 のメモリ上の構造体(PyThreadState →

    フレーム → コードオブジェクト)として 持っている • eBPF からは、CPython の関数は呼べない。そのため、これらの構造体を、メモリ から直接読む(bpf_probe_read_user()) • 構造体のフィールドの位置は、ホストエージェントが BPF マップ(py_procs)で 渡す
  20. 5. ネイティブ以外のアンワインド(Python) Python のフレームの辿り方 PyThreadState TLS から取得 frame フレーム 1

    f_back フレーム 2 現在実行中 入口フレーム f_code, f_lasti f_code, f_lasti 関数と、実行中の バイトコードの位置 関数と、実行中の バイトコードの位置 入口 C のフレーム ネイティブのアンワインドへ C の先で再び Python に入れば戻る
  21. 6. シンボル化 eBPF が記録したアドレスを、関数名に変換する eBPF が記録するのは、ネイティブコードならファイル内の位置、インタプリタなら バイトコードオフセットまで。関数名に直す処理(シンボル化)は、コードの種類に 応じて、ホストエージェントまたはバックエンドが行う。 コード eBPF

    が記録 シンボル化する場所 取得元 C / C++ / Rust (file ID, 位置) バックエンド debuginfod、アップロードされ たシンボルなど Go (file ID, 位置) ホストエージェント .gopclntab Python など bytecode オフセッ ト / JIT メソッド情報 ホストエージェント 動いているプロセスのランタイム 構造体
  22. 7. バックエンドへの送信 ring buffer からバックエンドへ • 完了したスタックトレースは BPF_MAP_TYPE_RINGBUF でホストエージェントへ渡す •

    ホストエージェントは、スタックトレースの 128bit のハッシュをキーに、同じフ レーム列を 1 つにまとめて件数を数える。フレームの変換とシンボル化は 1 回だけ 行う • ホストエージェントがコンテナ/ホストのメタデータを付け、バックエンドへ push する • 送信待ちのデータは、上限つきの FIFO キューに溜める
  23. 8. まとめ eBPF とホストエージェントの役割分担 eBPF プログラム:サンプルごと ホストエージェント:事前と事後 • 一定間隔のサンプリングで、割り込 まれたタスクのスタックを辿る

    • 事前に用意された表(StackDelta など)を参照して、フレームを 1 つ ずつ読む • 結果は、アドレスのまま、ring buffer へ渡す • 事前:バイナリから StackDelta を 計算し、BPF マップに載せる • 事前:インタプリタを検出し、読み 方の情報(構造体のフィールドの位 置など)をマップに渡す • 後処理:同じスタックトレースの集 約、シンボル化、バックエンドへの 送信
  24. 付録 Q&A:off-CPU はどう取るのか Q. CPU から外れて待っている時間(I/O 待ち、ロック待ちなど)は、どうやって プロファイルしているのか? A. スケジューラのイベントで、CPU

    から外れた時刻と戻ってきた時刻を記録して、 待ち時間を測る。スタックは、on-CPU と同じ unwinder で取る。 • sched_switch(tracepoint)でスケジュールアウトの時刻を BPF マップに記録する • finish_task_switch(kprobe)で再開時に経過時間を計算し、スタックと待ち時間を報告する • デフォルトでは無効
  25. 付録 Q&A:syscall の先、カーネルの関数までたどれるか Q. ユーザープロセスの関数だけでなく、その先の open や read といった syscall

    の 中まで、関数呼び出しをたどれるか? A. サンプルしたときにカーネルの中にいれば、カーネルのスタックも一緒に取れ る。bpf_get_stack()でカーネルのフレームを取り、ユーザー空間のフレームの先 頭に付ける。 • on-CPU:syscall の処理中にサンプルされれば、read → ksys_read → vfs_read のようにカーネル 内の関数まで見える • off-CPU:read や futex で待たされている場所が、カーネルのスタックとして取れる