平均値の裏を覗く — eBPFとは何か、Zabbixと比べて何が見えるのかを自作ダッシュボードで確かめた

平均値の裏を覗く — eBPFとは何か、Zabbixと比べて何が見えるのかを自作ダッシュボードで確かめた eBPF

eBPF は「Linux カーネルの中で、検証済みの小さなプログラムを動かす仕組み」である。言葉では何度も聞いていたが、実際に何が見えて何が見えないのかは、手を動かさないと分からない。そこで Zabbix 風の Linux ダッシュボードを eBPF で作り直す実験 eBPFLens を始め、5 本の PR で形にした。この記事は、そこで得た実測値とコードを材料に、eBPF の基本を整理したものである。

試した環境は次のとおり。

  • Ubuntu 26.04.1 LTS、カーネル 7.0.0-34-generic、8 コア、RTX 2070(自宅の検証サーバー)
  • Go 1.26 + cilium/ebpf v0.22.0、clang 21
  • フロントは React + TypeScript
eBPFLens のダッシュボード。Lens Summary と、資源ごとの使用率・飽和・エラーの升目
eBPFLens のダッシュボード。Lens Summary と、資源ごとの使用率・飽和・エラーの升目

eBPFとは

eBPF は、Linux カーネルに用意された カーネル内の実行環境 である。カーネルを再ビルドせず、カーネルモジュールも書かずに、カーネル内の決まった場所(フック)に自分の処理を差し込める。名前の由来は 1990 年代初めの Berkeley Packet Filter(tcpdump のパケットフィルタ)だが、今はパケットに限らず、スケジューラ、システムコール、ファイル、メモリ、ユーザー空間の関数呼び出しまで対象が広がっていて、名前は略語というより固有名詞になっている。

流れは次の図のとおりである。

eBPF プログラムがビルドされ、カーネル内で検査・実行され、結果がユーザー空間に渡る流れ
eBPF プログラムがビルドされ、カーネル内で検査・実行され、結果がユーザー空間に渡る流れ
  1. C で BPF プログラムを書き、clang で BPF バイトコードにする。 型情報(BTF)も一緒に埋め込まれる
  2. ローダーが bpf() システムコールでカーネルに渡す。 eBPFLens では Go の cilium/ebpf を使った
  3. verifier が安全性を検査する。 範囲外のメモリアクセス、終わらないループ、NULL チェック漏れがあると、ロードそのものを拒否する。カーネルを落とすプログラムは、そもそも入れられない
  4. JIT でネイティブコードにして、フックにアタッチする。 tracepoint(カーネルの計装点)、fentry/kprobe(カーネル関数の入口)、uprobe(ユーザー空間の関数)、XDP(NIC 直後のパケット処理)、LSM(セキュリティフック)などがある
  5. 結果は BPF マップや ring buffer に書き、ユーザー空間から読む

BCC と CO-RE

eBPF のツールを作る方式は大きく 2 つある。この違いは、最初の技術選定で一番効いた。

BCC(Python など) CO-RE(libbpf / cilium/ebpf / aya)
BPF をコンパイルする時 起動のたび(実行時に clang を呼ぶ) ビルド時
監視対象に要るもの BCC、clang/LLVM、カーネルヘッダ カーネルの BTF だけ
配布 ホストごとにパッケージを入れる 単一バイナリ
カーネル構造体のずれ ヘッダに合わせて毎回コンパイルし直す ロード時に BTF を見て再配置する

CO-RE(Compile Once – Run Everywhere)は、カーネルが自分の型情報(BTF、/sys/kernel/btf/vmlinux)を持っていることを利用する。ビルド時に「task_struct の tgid を読む」という意図を記録しておき、ロード時に実行中カーネルの BTF を見てオフセットを合わせる。だから一度ビルドしたバイナリが、構造体の並びが違う別のカーネルでも動く。eBPFLens は「エージェントを配って監視する」形を目指したので、CO-RE の Go(cilium/ebpf)を選んだ。

eBPFでできること

できることは、大きく 3 つに分けられる。eBPFLens で実際に書いたコードで見ていく。

1. カーネルの中で集計して、要約だけを持ち出す

最初に作ったのは runqlat(CPU 実行待ち時間)である。タスクが起床してから実際に CPU に載るまでの待ち時間を、スケジューラの tracepoint で測る。ポイントは、1 件ずつユーザー空間に送らず、カーネル内でヒストグラムに積むことだ。スケジューラのイベントは 1 秒に数千件あるが、ユーザー空間は 1 秒に 1 回、27 個のスロットを読むだけで済む。

SEC("tp_btf/sched_switch")
int BPF_PROG(handle_sched_switch, bool preempt, struct task_struct *prev,
         struct task_struct *next, unsigned int prev_state)
{
    ...
    // 横取り(preempt)されたタスクは runnable のまま待ち行列に戻る
    if (prev_state == TASK_RUNNING)
        trace_enqueue(prev->pid);

    pid = next->pid;
    tsp = bpf_map_lookup_elem(&start, &pid);
    if (!tsp)
        return 0;
    delta = (s64)(now - *tsp);
    bpf_map_delete_elem(&start, &pid);
    if (delta < 0)
        return 0;

    slot = log2_u64((u64)delta / 1000);
    if (slot >= MAX_SLOTS)
        slot = MAX_SLOTS - 1;
    cnt = bpf_map_lookup_elem(&hist, &slot);
    if (cnt)
        *cnt += 1;
    ...
}

sched_wakeup で起床時刻を記録し、sched_switch で CPU に載った瞬間との差を log2 のスロットに数える。if (slot >= MAX_SLOTS) の境界チェックは、verifier に「配列の範囲内だ」と証明するためにも要る。書き始める前に、実行中カーネルの BTF で引数の並びを確認した。カーネル 7.0 の sched_switch は (bool preempt, prev, next, unsigned int prev_state) で、古い解説にある形とは違う。

8 コアに stress-ng --cpu 32(4 倍の過負荷)をかけると、分布の山がはっきり動いた。

条件 分布の山
負荷なし 8〜15 µs
4 倍の過負荷 8〜16 ms
負荷を止めた後 8〜15 µs に戻る

4 倍の過負荷なら、自分の前に 3 つほどタスクが並ぶ。EEVDF スケジューラの基本スライスは約 3 ms なので、3 ms × 3 ≈ 9 ms 待つ計算になり、山の移動と合う。

CPU実行待ち時間の画面。左がヒートマップ、右が p50/p99 の推移としきい値の帯。下に原因と影響
CPU実行待ち時間の画面。左がヒートマップ、右が p50/p99 の推移としきい値の帯。下に原因と影響

2. イベントを 1 件ずつ、取りこぼさずに拾う

集計せずに 1 件ずつ欲しいものもある。プロセスの起動・終了・OOM kill がそうだ。こちらは ring buffer でユーザー空間に流す。

SEC("tp_btf/sched_process_exit")
int BPF_PROG(handle_exit, struct task_struct *p, bool group_dead)
{
    struct event *e;

    // スレッドの終了は無視し、プロセス全体が終わったときだけ拾う
    if (!group_dead)
        return 0;
    e = reserve(KIND_EXIT, p);
    if (!e)
        return 0;
    e->exit_code = BPF_CORE_READ(p, exit_code);
    e->lifetime_ns = e->ts - BPF_CORE_READ(p, start_time);
    bpf_ringbuf_submit(e, 0);
    return 0;
}

SEC("fentry/oom_kill_process")
int BPF_PROG(handle_oom, struct oom_control *oc, const char *message)
{
    struct task_struct *victim = BPF_CORE_READ(oc, chosen);
    struct task_struct *cur = (struct task_struct *)bpf_get_current_task_btf();
    ...
    e->trigger_pid = BPF_CORE_READ(cur, tgid);
    ...
    e->memcg = BPF_CORE_READ(oc, memcg) ? 1 : 0;
    bpf_ringbuf_submit(e, 0);
    return 0;
}

ここでもカーネル 7.0 の BTF を先に見た。sched_process_exit に group_dead という引数が増えていて、これを使うと「スレッドではなくプロセス全体が終わった」ときだけを拾える。OOM は oom_kill_process の入口に fentry で入り、強制終了されたプロセス(oc->chosen)に加えて、メモリを要求して OOM を引き起こしたプロセス(その時点の current)と、cgroup の上限によるものかどうかまで取る。

実際に拾えたもの:

テスト 記録された内容
/bin/true 寿命 880 µs
sh -c 'exit 3' 終了コード 3
kill -SEGV $$ SIGSEGV、コアダンプあり
systemd-run -p MemoryMax=64M の中で 300MB 確保 OOM kill、cgroup の上限(64MB)、直後に SIGKILL で終了

1ms 未満で終わるプロセスまで、1 件ずつ残る。

プロセスの起動と終了の画面。1秒未満で終わったプロセス、クラッシュ、OOM kill
プロセスの起動と終了の画面。1秒未満で終わったプロセス、クラッシュ、OOM kill

3. 「誰が」まで分解する

カーネルの中では、イベントがどのプロセスのものかが分かる。runqlat の sched_switch に数行足すだけで、「CPU を使っていたのは誰か(原因)」と「待たされたのは誰か(影響)」をプロセス別に取れた。

    // 原因: 前回の切り替えから今までこの CPU を使っていたのは prev
    last = bpf_map_lookup_elem(&cpu_last, &zero);
    if (last) {
        if (*last && prev->pid) {
            v = proc_of(prev);
            if (v)
                __sync_fetch_and_add(&v->oncpu_ns, now - *last);
        }
        *last = now;
    }
    ...
    // 影響: next がどれだけ待たされたか
    v = proc_of(next);
    if (v) {
        __sync_fetch_and_add(&v->wait_count, 1);
        __sync_fetch_and_add(&v->wait_ns, (u64)delta);
        ...
    }

過負荷の最中に、1ms ごとに起きるだけの小さなプロセス victim-app(python3 を別名でコピーしたもの。プロセス名で見分けるため)を同居させた結果がこれである。

プロセス 平常時の p99 過負荷時の p99 CPU 使用
stress-ng-cpu ×32(原因) – 16 ms 99.5%
victim-app(巻き込まれた側) 16 µs 1.0 ms 0.1%

原因が一目で分かるうえに、巻き込まれた側の待ちは占有している側より 1 桁以上小さい、ということまで見えた。すぐ寝るタスクを EEVDF が起床時に優遇するためだ。

eBPFのユースケース

eBPF はすでに多くのプロダクトの土台になっている。

  • 可観測性・監視: Pixie、Grafana Beyla(アプリを改修せずに HTTP などを計装する)、Parca(常時プロファイリング)。今回の eBPFLens もここに入る
  • ネットワーク: Cilium(Kubernetes の CNI)、Meta の Katran(XDP を使った L4 ロードバランサー)、XDP による DDoS 対策
  • セキュリティ: Falco、Tetragon。プロセスの起動やファイルアクセスを監視し、ポリシー違反を検知・遮断する
  • 性能分析: BCC のツール群や bpftrace。runqlat や execsnoop は、もともと BCC のツールとして広まったもの
  • スケジューラ: sched_ext(Linux 6.12 で取り込まれた)で、スケジューラ自体を BPF で書ける

eBPFLens の次の狙いは GPU 推論サーバーである。libcudart / libcuda に uprobe を掛け、推論プロセスごとに「カーネル起動・転送・同期待ち」を測れば、「GPU が遊んでいるのは CPU 待ちのせいか、転送待ちのせいか」を言えるはずだ。ちなみに、音源分離の Demucs(PyTorch の GPU 推論)を動かしながら runqlat を測ると、CPU の取り合いはほとんど起きていなかった(1 秒ごとの p99 の中央値は平常時 29 µs に対して 27 µs)。計算の大半は GPU で進み、CPU 側のスレッドは完了を待って寝ているからだ。

eBPFのメリット・デメリット

メリット

  • 再起動も再ビルドも要らない。 動いている本番カーネルに、後から計測を足して外せる
  • 安全。 verifier を通らないプログラムは入れられない。カーネルモジュールと違い、書き損じてもカーネルは落ちない
  • カーネルの中で集計できる。 大量のイベントを 1 件ずつ外に出さず、要約だけを読める
  • 取得間隔に縛られない。 ポーリングの隙間で起きて消えたもの(寿命 880 µs のプロセス)も拾える
  • アプリを改修しなくていい。 uprobe ならライブラリの関数呼び出しも外から測れる
  • CO-RE なら配りやすい。 監視対象に要るのはカーネルの BTF だけ。エージェントは単一バイナリになった

オーバーヘッドの小ささもよく挙げられるが、今回は計測していない。エージェントの常駐メモリが約 18 MB だった、というところまでである。

デメリット

  • 強い権限が要る。 root か CAP_BPF などのケーパビリティが必要で、カーネル内のあらゆる情報に触れられる。取る情報の選び方には責任が伴う。eBPFLens では、プロセスのコマンドライン引数をあえて取らないことにした。mysql -pPASSWORD のように、引数に秘密が入ることがあるからだ
  • カーネルのバージョンに依存する。 CO-RE は構造体のずれを吸収してくれるが、フック先の関数やtracepointの引数そのものが変わると追従が要る。今回も sched_switch と sched_process_exit の引数は、書く前にカーネル 7.0 の BTF で確かめた
  • 書き方に制約が多い。 verifier に安全だと証明できる形でしか書けない。ループの上限、配列添字の範囲チェック、NULL チェックを常に意識する
  • ツールチェーンの癖に当たる。 bpf2go が looking up type event: not found で止まったことがある。ring buffer でしか使っていない構造体は BTF に載らないことがあり、型を明示的に参照させて回避した
// bpf2go が Go の型を生成できるよう、struct event を BTF に確実に載せる
const struct event *unused_event __attribute__((unused));
  • Linux 専用。 ネットワーク機器や Windows は対象外である
  • 見えすぎる。 これが一番大きい。最初に作った画面は、ヒートマップと p50/p99 のグラフを並べただけで、「で、今は問題があるのか」が読み取れなかった。eBPF が出すのは生データであり、意味に変換するのは別の仕事である。eBPFLens では画面の一番上に、判定と文章の要約(「CPU待ち時間は低い状態です。99%のタスクが 29 µs 以内に CPU を獲得しています」)を置き、グラフはその根拠として後ろに回した

Zabbixとの比較

Zabbix と eBPF は、同じ「監視」でも立っている場所が違う。

観点 Zabbix eBPF(eBPFLens)
取り方 エージェントやサーバーが一定間隔で値を取りに行く(ポーリング) カーネルのイベントにフックし、起きたそのときに処理する
粒度 取得時点の値や、区間の平均 分布(ヒストグラム)、イベント 1 件ずつ
間隔より短い現象 取りこぼす(寿命 1 秒未満のプロセスは基本的に見えない) 拾える
「なぜ」 「CPU 使用率が高い」「ロードアベレージが高い」まで 「stress-ng が CPU の 99% を使い、victim-app が 1ms 待たされた」まで
対象 Linux、Windows、ネットワーク機器(SNMP)、アプリ、ログ Linux カーネルと、その上のプロセス
成熟度 テンプレート、トリガー、通知、履歴の保存、権限管理がそろっている プローブは作れるが、保存・通知・画面は自分で用意する
導入の重さ エージェントを入れてテンプレートを当てれば始められる カーネルの知識と、権限の設計が要る

比べてみると、置き換えではなく役割分担だと分かる。Zabbix は「広く・長く・漏れなく」見張って、何かおかしいことに気づくのが得意だ。eBPF は「その瞬間にカーネルの中で何が起きていたか」を掘って、なぜかを説明するのが得意だ。Zabbix のアラートで気づき、eBPF で原因を特定する。あるいは、eBPF で取った分布やイベントを Zabbix に流し込む。そういう組み合わせが現実的だろう。

eBPFLens では、この「気づく」側の画面も作ってみた。Brendan Gregg の USE メソッドに倣い、資源ごとに使用率(Utilization)・飽和(Saturation)・エラー(Errors)を升目に並べた。プローブが増えても升目が埋まっていくだけで、画面は縦に伸びない。

まとめ

  • eBPF は、カーネルを変えずに、安全に、カーネルの中へ計測を差し込む仕組みである。verifier が安全を保証し、CO-RE が配布を楽にする
  • できることは、カーネル内での集計、イベントの 1 件ずつの取得、プロセス単位への分解。ポーリングでは平均に埋もれる分布や、取得間隔の隙間で消えるプロセスが見える
  • 代わりに、強い権限とカーネルへの依存を引き受けることになる。何を取らないかを決めるのも設計のうちだ
  • Zabbix とは競合ではなく補完の関係にある。「気づく」のが Zabbix、「なぜを掘る」のが eBPF
  • そして、生データを並べただけでは監視にならない。意味に変換して見せるところまでやって、はじめて道具になる

eBPFLens は GitHub で公開した(Apache-2.0): yoshiharu-ishii/ebpf-lens。この記事のあと、メモリ回収による停止(memstall)、SQLite への保存、systemd での常駐(エージェントは root をやめて CAP_BPF と CAP_PERFMON だけ)、英語化を足した。次は VM 監視(ホストから見た vCPU の待ちと、VM が落ちた理由)と GPU に進む。

コメント

タイトルとURLをコピーしました