- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- eBPFがカーネルを変えた方法
- アーキテクチャ全体を一望する
- プログラムタイプの地図
- マップ: カーネルとユーザー空間の橋
- Verifier: カーネルの門番
- 初めてのプログラム実習: libbpf + CO-RE
- bpftool: eBPFのスイスアーミーナイフ
- 言語とフレームワークの選択
- カーネルバージョンと機能マトリクス
- デバッグのヒント
- プロダクションでの考慮事項
- 学習ロードマップ
- 落とし穴とアンチパターン
- おわりに
- 参考資料
はじめに
運用中のサーバーで「特定のプロセスがどのファイルを開いているかをリアルタイムで見たい」という要件が発生したとしましょう。従来の方法は二つでした。カーネルモジュールを自作してロードするか、straceのようなptraceベースのツールでプロセスを捕まえて観察するかです。前者はカーネルパニックのリスクを受け入れる必要があり、後者は対象プロセスを数十倍遅くしてしまいます。本番環境ではどちらも簡単には選べません。
eBPF(extended Berkeley Packet Filter)はこのジレンマを根本から解決しました。カーネルの再コンパイルもモジュールのロードもせずに、検証済みの安全なプログラムをカーネル内部のほぼあらゆるポイントにアタッチして実行できます。今日では、CiliumのようなCNI、Katranのようなロードバランサー、FalcoやTetragonのようなセキュリティツール、そして数多くの可観測性ツールがすべてeBPFの上で動いています。
この記事では、eBPFの中核要素であるプログラム、マップ、Verifierを順に見ていき、libbpfとCO-REを使った初めてのプログラムを最初から最後まで書いてみます。可観測性編とセキュリティ編へと続くシリーズの第1回です。
eBPFがカーネルを変えた方法
カーネルモジュールの時代とその限界
Linuxカーネルの動作を拡張する伝統的な方法はカーネルモジュール(LKM)でした。モジュールは強力ですが、致命的な欠点があります。
| 項目 | カーネルモジュール (LKM) | eBPFプログラム |
|---|---|---|
| 安全性 | バグ一つでカーネルパニックの恐れ | Verifierがロード前に安全性を検証 |
| 分離 | カーネル全メモリにアクセス可能 | ヘルパー関数経由の制限付きアクセスのみ |
| 互換性 | カーネルバージョンごとに再コンパイル | CO-REで単一バイナリが複数カーネルで動作 |
| デプロイ | モジュール署名やポリシーの問題 | システムコール一つでロード、権限で統制 |
| 終了保証 | 無限ループが可能 | プログラムの終了が静的に証明されないとロード不可 |
| 学習コスト | カーネル内部API全般の理解が必要 | 制限されたヘルパーAPIとCのサブセット |
eBPFの核心アイデアは「カーネル内で動かすコードを、カーネルが事前に検証する」ことです。JavaScriptがブラウザというサンドボックスの中でWebをプログラマブルにしたように、eBPFはカーネルをプログラマブルにしました。eBPFが「カーネルのJavaScript」と例えられるのには理由があります。
cBPFからeBPFへ
元々のBPFは、1992年にtcpdumpのパケットフィルタリングのために設計された小さな仮想マシン(classic BPF、cBPF)でした。2014年のカーネル3.18で拡張版のeBPFが導入され、次の点が変わりました。
- レジスタが2個から11個(R0〜R10)に増え、64ビットに拡張
- マップという永続的データ構造の導入により、カーネルとユーザー空間のデータ共有が可能に
- ヘルパー関数呼び出しのサポートにより、カーネル機能へ安全にアクセス
- JITコンパイラによりネイティブコード並みの性能を確保
- ネットワーキングを超えて、トレーシングやセキュリティなど汎用実行エンジンへ拡張
アーキテクチャ全体を一望する
eBPFプログラムが書かれてからカーネル内で実行されるまでの道のりを図にすると次のようになります。
ユーザー空間(User space)
+--------------------------------------------------------------+
| Cソース (.bpf.c) |
| | clang -target bpf -g (BTF付き) |
| v |
| ELFオブジェクト (.bpf.o) |
| | |
| v |
| ローダー (libbpf / cilium-ebpf / aya / bpftool) |
| | CO-RE再配置を適用、bpf()システムコール |
+-----|--------------------------------------------------------+
v
カーネル空間(Kernel space)
+--------------------------------------------------------------+
| Verifier ──> 安全性検証 (ポインタ、境界、終了保証) |
| | 通過 |
| v |
| JITコンパイラ ──> ネイティブ機械語へ変換 |
| | |
| v |
| フック(hook)へアタッチ |
| ├── kprobe / kretprobe (カーネル関数の入口/出口) |
| ├── tracepoint (安定したカーネルイベント) |
| ├── XDP (NICドライバ受信直後) |
| ├── tc (トラフィック制御 ingress/egress) |
| ├── LSM (セキュリティフック) |
| └── cgroup (ソケット/システムコール制御) |
| |
| マップ(Maps) <────> ユーザー空間とデータ共有 |
+--------------------------------------------------------------+
流れを要約すると次のとおりです。制限付きCで書いたコードをclangがBPFバイトコードへコンパイルし、ローダーがbpf()システムコールでカーネルに渡すと、Verifierが安全性を検証し、JITがネイティブコードへ変換して指定のフックにアタッチします。プログラムとユーザー空間はマップを通じてデータをやり取りします。
プログラムタイプの地図
eBPFプログラムはアタッチされる場所(フック)によってタイプが分かれ、タイプごとに受け取れるコンテキストと使えるヘルパーが異なります。実務でよく出会うタイプを整理します。
| プログラムタイプ | アタッチポイント | 主な用途 | 特徴 |
|---|---|---|---|
| kprobe / kretprobe | 任意のカーネル関数の入口/出口 | トレーシング、デバッグ | 柔軟だがカーネルバージョンで関数が変わり得る |
| tracepoint | 事前定義された静的カーネルイベント | 安定したトレーシング | ABIが比較的安定、推奨の出発点 |
| fentry / fexit | カーネル関数の入口/出口 (BTFベース) | 高性能トレーシング | kprobeよりオーバーヘッドが低い、カーネル5.5以降 |
| XDP | NICドライバ受信経路の最前段 | DDoS防御、ロードバランシング | sk_buff割り当て前のため非常に高速 |
| tc (clsact) | トラフィック制御 ingress/egress | パケット操作、ポリシー | 送信方向も処理可能、sk_buffへアクセス |
| LSM (BPF LSM) | Linuxセキュリティモジュールフック | ランタイムセキュリティポリシー | 動作のブロックが可能、カーネル5.7以降 |
| cgroup系 | cgroup単位のソケット/システムコール | コンテナごとのネットワークポリシー | コンテナ環境と相性が良い |
| uprobe / USDT | ユーザー空間関数/静的トレースポイント | アプリケーショントレーシング | ライブラリ関数の追跡など |
| perf_event | タイマー/PMUイベント | CPUプロファイリング | サンプリングベースの分析 |
選択基準を簡単にまとめます。安定性が重要ならtracepoint、カーネル内部の細かいポイントが必要ならkprobeやfentry、パケットを最速で処理すべきならXDP、ポリシーによるブロックが目的ならLSMをまず検討します。
マップ: カーネルとユーザー空間の橋
マップはeBPFプログラムが状態を保存し、ユーザー空間とデータを交換するキー・バリューのデータ構造です。どのマップを使うかがプログラムの性能と構造を左右します。
| マップタイプ | 構造 | 代表的な用途 |
|---|---|---|
| BPF_MAP_TYPE_HASH | ハッシュテーブル | PID別統計、コネクション追跡など任意キーの検索 |
| BPF_MAP_TYPE_ARRAY | 固定サイズ配列 | 設定値、インデックスベースのカウンタ |
| BPF_MAP_TYPE_PERCPU_HASH | CPU別ハッシュ | ロック競合なしの高頻度カウント |
| BPF_MAP_TYPE_PERCPU_ARRAY | CPU別配列 | ヒストグラムのバケット、ホットパス統計 |
| BPF_MAP_TYPE_LRU_HASH | LRUハッシュ | 満杯時に古い項目を自動削除 |
| BPF_MAP_TYPE_RINGBUF | リングバッファ (MPSC) | カーネル→ユーザーのイベントストリーミング (5.8以降、推奨) |
| BPF_MAP_TYPE_PERF_EVENT_ARRAY | CPU別perfバッファ | 旧カーネルでのイベント伝達 |
| BPF_MAP_TYPE_LPM_TRIE | 最長プレフィックス一致 | IP CIDRマッチング |
| BPF_MAP_TYPE_PROG_ARRAY | プログラム配列 | tail callによるプログラムチェーン |
| BPF_MAP_TYPE_SK_STORAGE | ソケットローカルストレージ | ソケットごとのメタデータ |
実務上のヒントを二つ強調しておきます。
- イベント伝達にはringbufを優先します。perfバッファと違いCPU間のイベント順序が保証され、メモリ効率が良く、ユーザー空間APIもシンプルです。
- ホットパスのカウンタはpercpuマップで作ります。通常のハッシュマップを複数CPUが同時に更新するとアトミック演算のコストが大きくなりますが、percpuマップはCPUごとに独立したスロットを使うため競合がありません。集計はユーザー空間で読む時に行います。
マップの定義は、最新のlibbpfスタイルではBTFベースのセクションで宣言します。
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32); /* PID */
__type(value, __u64); /* 呼び出し回数 */
} call_count SEC(".maps");
Verifier: カーネルの門番
VerifierはeBPFの安全性を担う中核コンポーネントです。プログラムのロード時にすべての実行パスを静的に解析し、次を検証します。
- プログラムが必ず終了するか (無限ループの禁止)
- すべてのメモリアクセスが検証済みの範囲内か
- 未初期化のレジスタを読んでいないか
- そのプログラムタイプで許可されたヘルパーのみを呼んでいるか
- ポインタ演算が安全な範囲を逸脱していないか
境界チェック: Verifierが見る世界
Verifierは各レジスタの取り得る値の範囲を追跡します。パケットデータを読むXDPプログラムの典型的なパターンは次のとおりです。
SEC("xdp")
int xdp_prog(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
/* この境界チェックがないとVerifierがロードを拒否する */
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
/* チェックを通過した後にのみ eth->h_proto へのアクセスが許される */
if (eth->h_proto == bpf_htons(ETH_P_IP))
return XDP_DROP;
return XDP_PASS;
}
if文で境界をチェックした瞬間、Verifierはその分岐内でポインタの範囲が安全であることを「知る」ことになります。コンパイラがこのチェックを最適化で除去したり順序を入れ替えたりすると検証が失敗し得るため、境界チェックのコードはシンプルかつ直接的に書くのが良いです。
ループの制約
初期のeBPFはループを一切許可しませんでした。現在は段階的に緩和されています。
| 方法 | カーネルバージョン | 説明 |
|---|---|---|
| pragma unroll | 全バージョン | コンパイル時にループを展開、回数の固定が必要 |
| bounded loop | 5.3以降 | Verifierが終了を証明できる有界ループを許可 |
| bpf_loopヘルパー | 5.17以降 | コールバックベースのループ、大きな反復回数に有利 |
| open-coded iterator | 6.4以降 | bpf_forマクロなどイテレータベースの反復 |
よくある検証失敗と解決法
| エラーメッセージ (要旨) | 原因 | 解決 |
|---|---|---|
| invalid mem access | 境界チェックなしのポインタ参照 | アクセス前に明示的な境界チェックを追加 |
| unbounded loop detected | 終了を証明できないループ | 反復回数に上限を付与、bpf_loopを使用 |
| BPF program is too large | 命令数の上限超過 | ロジック分割、tail call、5.2以降は上限が百万に緩和 |
| stack limit exceeded | スタック512バイト超過 | 大きな構造体はpercpu arrayをスクラッチ領域に |
| R1 type=ctx expected=fp | コンテキストポインタの誤用 | コンテキストフィールドは決められた方法でのみアクセス |
| helper call is not allowed | プログラムタイプで禁止されたヘルパー | そのタイプで許可されるヘルパーを確認 |
Verifierのログは長く難解なことで有名ですが、失敗地点直前のレジスタ状態ダンプを追えば、ほとんどの場合原因を見つけられます。libbpfローダーでverboseログを有効にする方法はデバッグの節で扱います。
初めてのプログラム実習: libbpf + CO-RE
実際に動くプログラムを作ってみましょう。目標は「システム全体で実行されるすべてのプロセス(execve)を追跡し、PID、親PID、コマンド名を出力する」ミニexecsnoopです。
CO-REとvmlinux.h、BTFとは
従来のbcc方式は、対象マシンにclangとカーネルヘッダをインストールしてランタイムにコンパイルしていました。CO-RE(Compile Once, Run Everywhere)はこの問題を解決します。
- BTF(BPF Type Format): カーネルが自分自身の型情報をコンパクトに内蔵したフォーマットです。/sys/kernel/btf/vmlinux ファイルがあればBTFが有効なカーネルです。
- vmlinux.h: カーネルBTFから生成された、すべてのカーネル型定義を含む単一ヘッダです。カーネルヘッダのパッケージなしでカーネル構造体を参照できるようにしてくれます。
- CO-RE再配置: コンパイル時に構造体フィールドへのアクセスを「再配置情報」として記録しておき、ロード時にlibbpfが実行中カーネルのBTFと照合してフィールドオフセットを補正します。おかげで一度ビルドしたバイナリが、フィールド配置の異なる複数のカーネルでそのまま動きます。
vmlinux.hは次のコマンドで生成します。
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
カーネルサイドのコード (execsnoop.bpf.c)
// SPDX-License-Identifier: GPL-2.0
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#define TASK_COMM_LEN 16
struct event {
__u32 pid;
__u32 ppid;
char comm[TASK_COMM_LEN];
};
/* カーネル -> ユーザー空間のイベント伝達用リングバッファ */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
SEC("tracepoint/syscalls/sys_enter_execve")
int handle_execve(struct trace_event_raw_sys_enter *ctx)
{
struct event *e;
struct task_struct *task;
/* リングバッファにイベント領域を予約 */
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
/* CO-REマクロでtask_structから親PIDを読む */
task = (struct task_struct *)bpf_get_current_task();
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
ポイントを押さえておきましょう。
- SECマクロはELFセクション名でプログラムタイプとアタッチポイントを宣言します。
- BPF_CORE_READはCO-RE再配置が適用される安全なメンバーアクセスマクロです。ネストしたポインタチェーン(task → real_parent → tgid)を一度にたどれます。
- ライセンス宣言は必須です。GPL互換ライセンスでないと一部のヘルパーが使えません。
ユーザーサイドのローダー (execsnoop.c)
ビルド過程でbpftoolが.bpf.oからスケルトンヘッダ(execsnoop.skel.h)を生成してくれます。スケルトンはロード/アタッチ/解放を型安全な関数でラップします。
// SPDX-License-Identifier: GPL-2.0
#include <stdio.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "execsnoop.skel.h"
struct event {
__u32 pid;
__u32 ppid;
char comm[16];
};
static volatile sig_atomic_t exiting = 0;
static void sig_handler(int sig) { exiting = 1; }
static int handle_event(void *ctx, void *data, size_t len)
{
const struct event *e = data;
printf("%-8u %-8u %-16s\n", e->pid, e->ppid, e->comm);
return 0;
}
int main(void)
{
struct execsnoop_bpf *skel;
struct ring_buffer *rb;
int err;
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
skel = execsnoop_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "BPFスケルトンのロードに失敗\n");
return 1;
}
err = execsnoop_bpf__attach(skel);
if (err) {
fprintf(stderr, "BPFプログラムのアタッチに失敗: %d\n", err);
goto cleanup;
}
rb = ring_buffer__new(bpf_map__fd(skel->maps.events),
handle_event, NULL, NULL);
if (!rb) {
err = -1;
goto cleanup;
}
printf("%-8s %-8s %-16s\n", "PID", "PPID", "COMM");
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* ms */);
if (err == -EINTR) { err = 0; break; }
if (err < 0) break;
}
cleanup:
ring_buffer__free(rb);
execsnoop_bpf__destroy(skel);
return err < 0 ? 1 : 0;
}
ビルドと実行
# 1. vmlinux.h を生成
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
# 2. カーネルサイドをコンパイル (-g 必須: BTF/CO-RE情報を生成)
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
-c execsnoop.bpf.c -o execsnoop.bpf.o
# 3. スケルトンヘッダを生成
bpftool gen skeleton execsnoop.bpf.o > execsnoop.skel.h
# 4. ユーザーサイドをコンパイルしてリンク
clang -O2 -o execsnoop execsnoop.c -lbpf -lelf -lz
# 5. 実行 (rootまたは適切なcapabilityが必要)
sudo ./execsnoop
別のターミナルでlsやdateを実行すると、PID、PPID、コマンド名が即座に出力されるのを確認できます。この小さなプログラムが、ほぼゼロに近いオーバーヘッドでシステム全体のexecveを観察しているという事実こそが、eBPFの力です。
bpftool: eBPFのスイスアーミーナイフ
bpftoolはカーネルツリーで管理される公式CLIで、ロード済みのプログラムとマップを調べるのに欠かせません。
# ロードされた全プログラムを一覧
sudo bpftool prog show
# 特定プログラムのJIT結果を逆アセンブル
sudo bpftool prog dump jited id 42
# 変換後(Verifier通過後)のバイトコードを確認
sudo bpftool prog dump xlated id 42
# マップの一覧と内容のダンプ
sudo bpftool map show
sudo bpftool map dump id 17
# システムのeBPF機能サポート状況を調査
sudo bpftool feature probe
# ネットワークにアタッチされたプログラムを確認 (XDP, tc)
sudo bpftool net show
# プログラムをbpffsにピン(pin)してローダー終了後も維持
sudo bpftool prog pin id 42 /sys/fs/bpf/myprog
特にfeature probeは「このカーネルでどのプログラムタイプ、マップタイプ、ヘルパーがサポートされるか」を一度に見せてくれるため、新しいサーバーで作業を始める際に最初に実行する価値があります。
言語とフレームワークの選択
eBPF自体はカーネル技術であり、どの言語で開発するかは別の選択です。
| フレームワーク | 言語 | 長所 | 短所 | 向いているケース |
|---|---|---|---|---|
| libbpf + C | C | カーネルツリー公式、機能が最新、CO-RE標準 | Cの生産性、メモリ管理の負担 | システムツール、最高性能と最新機能 |
| cilium/ebpf | Go | Goエコシステム統合、純Goローダー | カーネルサイドは依然C | k8sツール、Goベースのエージェント |
| aya | Rust | カーネル/ユーザー両方Rust、メモリ安全 | エコシステムが比較的若い | Rustチーム、安全性重視のプロジェクト |
| bcc | Python + C | 豊富な例、素早いプロトタイピング | ランタイムコンパイル、重い依存関係 | 学習、単発の分析 |
| bpftrace | 専用DSL | ワンライナーで即座に分析 | 複雑なロジックには限界 | 運用中の即席トラブルシューティング |
推奨ルートは明確です。運用分析はbpftraceで始め、製品化するツールはlibbpf(C)やcilium/ebpf(Go)、aya(Rust)で書きます。bccのランタイムコンパイル方式は、CO-REが普及した今では新規プロジェクトには推奨されません。
カーネルバージョンと機能マトリクス
デプロイ対象のカーネルが何をサポートするかは、設計段階で最初に確認すべき事項です。主要なマイルストーンを整理します(私が確実に知っている範囲であり、正確なサポート状況はbpftool feature probeで確認するのが安全です)。
| カーネルバージョン | 主な機能 |
|---|---|
| 3.18 | eBPFシステムコールの導入 |
| 4.1〜4.7 | kprobe、tc、tracepointアタッチのサポート拡大 |
| 4.8 | XDPの導入 |
| 4.18 | BTFの導入開始 |
| 5.2 | 命令数上限が百万に緩和、グローバル変数のサポート |
| 5.3 | 有界ループ(bounded loop)の許可 |
| 5.5 | fentry / fexit (BTFベースのトランポリン) |
| 5.7 | BPF LSM、struct_ops |
| 5.8 | ring bufferマップ、CAP_BPF権限の分離 |
| 5.10 | sleepable BPFプログラム |
| 5.17 | bpf_loopヘルパー |
| 6.x | open-coded iterator、kfuncの拡大、arenaなど継続的な拡張 |
実務基準では、RHEL 9、Ubuntu 22.04 LTS以降であればringbuf、CO-RE、fentryまで問題なく使えます。2026年現在の主流ディストリビューションのカーネル(5.14以降)では、この記事の例はすべて動作します。
デバッグのヒント
- Verifierログを詳しく見ます。libbpfの環境変数またはコードでverboseレベルを上げられます。
/* ロード前にデバッグ出力を有効化 */
libbpf_set_print(libbpf_print_fn); /* LIBBPF_DEBUGレベルまで出力 */
- bpf_printkでカーネルサイドのprintfデバッグを行います。出力はtrace_pipeから読みます。
bpf_printk("pid=%d comm=%s", pid, comm);
sudo cat /sys/kernel/debug/tracing/trace_pipe
- コンパイラ最適化が境界チェックを除去する問題は、volatileの使用やbarrier_varマクロで回避します。
- xlatedダンプとソースを照合します。bpftool prog dump xlatedの出力でVerifierが見た実際の命令を確認すると、「自分が書いたコード」と「Verifierが検証したコード」の違いを発見できます。
- 小さく始めて段階的に育てます。一度に大きなプログラムを通そうとせず、最小バージョンを先にロードしてからロジックを追加します。
プロダクションでの考慮事項
オーバーヘッド
eBPFは軽量ですが、タダではありません。コストの大部分はフック自体の呼び出し頻度に比例します。
- 毎秒数百万回呼ばれる関数(例: スケジューラのホットパス)にkprobeを仕掛けると、累積オーバーヘッドが目に見えることがあります。fentryはkprobeより低コストなので、可能であればfentryを使います。
- マップ更新コストを下げるには、percpuマップと「集計してから渡す」パターン(カーネル側で集計し、ユーザー空間は定期的に読むだけ)を使います。
- イベントが殺到し得るポイントでは、カーネルサイドでサンプリングやフィルタリングを行い、ringbufへ送る量自体を減らします。
権限: CAP_BPFとその仲間たち
カーネル5.8から、eBPFの権限がCAP_SYS_ADMINから分離されました。
| 権限 | 許可範囲 |
|---|---|
| CAP_BPF | bpf()システムコールの基本使用 (マップ作成、一部のプログラムロード) |
| CAP_PERFMON | トレーシング系プログラムのアタッチ、カーネルメモリの読み取り |
| CAP_NET_ADMIN | XDP、tcなどネットワークプログラムのアタッチ |
| CAP_SYS_ADMIN | 上記すべてを含む従来方式 (避けるべき) |
可観測性エージェントをデプロイする際は、CAP_BPF + CAP_PERFMONの組み合わせで最小権限を構成し、ネットワークプログラムが必要な場合にのみCAP_NET_ADMINを追加するのがベストプラクティスです。また、unprivileged BPFはセキュリティ上ほとんどのディストリビューションで無効化されており(sysctl kernel.unprivileged_bpf_disabled)、そのままにしておくことを推奨します。
ライフサイクル管理
- プログラムとマップは、ファイルディスクリプタの参照がなくなると解放されます。ローダープロセスから独立して維持するにはbpffsにピンする必要があります。
- カーネルアップグレード時、CO-REのおかげでほとんど再ビルドは不要ですが、kprobe対象の関数が消えたり名前が変わったりすることがあるため、アタッチ失敗へのフォールバックロジックをエージェントに入れておくのが安全です。
学習ロードマップ
- ステージ1 — ユーザーとして始める: bpftraceワンライナーとbccツール(execsnoop、opensnoop、biolatency)を運用マシンの分析に活用し、感覚をつかみます。
- ステージ2 — 読む: ebpf.ioのWhat is eBPFドキュメントとカーネルBPFドキュメントを読み、プログラムタイプとマップの全体像を把握します。
- ステージ3 — 初めてのプログラム: この記事のexecsnoopのように、tracepoint + ringbufの組み合わせでlibbpfプログラムを書きます。libbpf-bootstrapリポジトリのテンプレートが素晴らしい出発点です。
- ステージ4 — 領域拡張: 関心分野に応じてXDP(ネットワーキング)、uprobe/USDT(アプリケーション)、LSM(セキュリティ)へ拡張します。
- ステージ5 — プロダクション化: CO-RE互換性マトリクス、権限設計、オーバーヘッド測定、CIでのマルチカーネルテストまで整えます。
落とし穴とアンチパターン
- アンチパターン1: 不安定なカーネル内部関数へのkprobe依存。カーネルのマイナーアップデートで関数がインライン化されたり名前が変わったりすると、ツールが静かに壊れます。tracepointやfentry + BTF存在チェックで防御します。
- アンチパターン2: マップサイズ不足の放置。ハッシュマップが満杯になると更新が失敗しデータが失われます。LRUマップを使うか、失敗カウンタを別途設けます。
- アンチパターン3: ringbufの消費遅延。ユーザー空間のコンシューマが遅いとreserveが失敗しイベントが消えます。ドロップカウンタを作って監視すべきです。
- アンチパターン4: 検証通過だけを目的としたコードのねじ曲げ。Verifierを「だます」パターンはカーネル/コンパイラのバージョンが変わると壊れます。明示的な境界チェックとシンプルな制御フローが正解です。
- アンチパターン5: ライセンス宣言の欠落。GPL宣言がないと多数のヘルパーがブロックされます。意図的に非GPLを選ぶ場合は、使用可能なヘルパー一覧を事前に確認します。
おわりに
eBPFは「カーネルを直さずにカーネルの動作を変える」という、十年前なら不可能と思われたことを日常にしました。核心は三つでした。フックにアタッチされるプログラム、データをつなぐマップ、そして安全を保証するVerifierです。この三つの関係を理解すれば、どんなeBPFツールに出会っても内部動作を推論できます。
次の記事では、この基礎の上でbpftraceとBCCツールを使って実際のシステムのブラックボックスを開ける可観測性の実践を扱い、その次の記事ではTetragonとFalco、BPF LSMでランタイムセキュリティを構築する方法を見ていきます。
参考資料
- ebpf.io — What is eBPF?
- Linuxカーネル BPF公式ドキュメント
- BPF Verifierドキュメント (kernel.org)
- BTF (BPF Type Format) ドキュメント (kernel.org)
- libbpfリポジトリ (GitHub)
- libbpf-bootstrap — libbpfサンプルテンプレート
- BPF CO-REリファレンスガイド (Andrii Nakryiko)
- bpftoolドキュメント (kernel.org)
- cilium/ebpf — Go向けeBPFライブラリ
- aya — Rust向けeBPFライブラリ
- BCC (BPF Compiler Collection)
- Brendan Gregg — eBPFトレーシング資料集