1章 eBPF とは何か?なぜ重要なのか?
eBPF の歴史
- [1993年] 「BSD Packet Filter(BPF)」が論文に登場
- [1997年] Linux にカーネルバージョン2.1.75 で初めて導入
- tcpdump の中でトレースされるべきパケットをキャプチャするためのより効率的な方法として使われはじめた。
- [2012年] seccomp-bpf がカーネル v3.5 で導入
- この機能を使うと、ユーザー空間のアプリケーションがシステムコールを呼び出すことについて、BPF プログラムを使って許可するか禁止するかを決定できる。この機能はパケットフィルタの範囲を超えていて、この辺りから「packet filter」という名前はあまり意味を持たなくなった。
- [2014年] 拡張された(extended)BPF(eBPF) がカーネルバージョン 3.18 でリリース
- eBPF の基盤部分が追加され、この後の大きな進化につながる。
- [2015年] eBPF プログラムを kprobe にアタッチする機能が追加
- [2020年] LSM(Linux Security Module)BPF が登場
- これは eBPF プログラムに LSM カーネルインターフェースへのアタッチを可能にするもので、これによって eBPF はネットワークと可観測性に加えて、セキュリティツールとしての用途が可能になった。
なぜ eBPF?
eBPF によって、開発者はカーネルの振る舞いを変更できるカスタムコードを、カーネルの内部にロードし、実行することができるようになった。
今までこれを行うには、カーネルに新機能を追加するか、カーネルモジュールを書く必要があった。問題は前者はリリースまでに時間がかかるし、またどちらもカーネルプログラミングの技術が必要になる。また、安全に実行できるカーネルのコードを書くのが難しい問題もある。
その点、eBPF プログラムは動的ロードをサポートしているので、カーネルのアップグレードもマシンの再起動も必要ない利点がある。また、eBPF 検証器が eBPF プログラムが安全に実行できると判定されたときのみロードされることを保証する仕組みになっている。
2章 eBPF の 「Hello World」
BCC の Hello World
MacOS を使っている場合は https://github.com/lizrice/learning-ebpf の手順に沿って、VM 上で実行環境を用意するといい。
# クローン $ git clone --recurse-submodules https://github.com/lizrice/learning-ebpf $ cd learning-ebpf # lima のインストール $ brew install lima # VM 起動 limactl start learning-ebpf.yaml # VM ログイン limactl shell learning-ebpf
VM ログイン後、次のように hello.py を実行すると、使用中のマシンで execve() システムコールが呼び出されるたびに "Hello World" の文字列を含むトレースが出力される。execve() システムコールは、新しいプログラムを実行しようとするたびに呼び出される。
yoheimuta@lima-learning-ebpf:/Users/yoheimuta/lizrice/learning-ebpf$ sudo -s root@lima-learning-ebpf:/Users/yoheimuta/lizrice/learning-ebpf# cd chapter2/ root@lima-learning-ebpf:/Users/yoheimuta/lizrice/learning-ebpf/chapter2# python3 hello.py b' <...>-5873 [003] d...1 787.501458: bpf_trace_printk: Hello World!' b' bash-5873 [003] d...1 787.507280: bpf_trace_printk: Hello World!' b' <...>-5874 [001] d...1 787.509545: bpf_trace_printk: Hello World!' b' <...>-5875 [002] d...1 787.511014: bpf_trace_printk: Hello World!' b' <...>-5876 [000] d...1 787.512891: bpf_trace_printk: Hello World!' b' <...>-5879 [001] d...1 787.524347: bpf_trace_printk: Hello World!' b' <...>-5880 [000] d...1 787.525203: bpf_trace_printk: Hello World!' b' <...>-5882 [001] d...1 787.526269: bpf_trace_printk: Hello World!' b' <...>-5883 [000] d...1 787.527529: bpf_trace_printk: Hello World!' b' <...>-5885 [001] d...1 908.367247: bpf_trace_printk: Hello World!'
hello.py は次のようなコードで、BCC Python フレームワークを使って記述されている。このコードは二つの部分から構成されていて、最初の program 変数に C 言語で書かれた eBPF プログラムが定義されている。後半は、その eBPF プログラムをカーネルにロード、イベントにアタッチ、トレース結果を読み出すためのユーザー空間のコードになる。
#!/usr/bin/python3 from bcc import BPF program = r""" int hello(void *ctx) { bpf_trace_printk("Hello World!"); return 0; } """ b = BPF(text=program) syscall = b.get_syscall_fnname("execve") b.attach_kprobe(event=syscall, fn_name="hello") b.trace_print()
BPF Map
BPF Map は eBPF プログラムとユーザー空間の両方からアクセスできるデータ構造で、典型的なユースケースは次の通り:
- ユーザー空間で設定情報を書き込み、eBPF プログラムからその情報を取得する
- eBPF プログラム間で状態を共有する
- eBPF プログラムから実行結果やメトリクスを書き込み、ユーザー空間のアプリケーションから取得して結果を表示する
様々な種類の Map が定義されており、例えばハッシュテーブル、Perf リングバッファ、配列として利用できる。
次のサンプルはハッシュテーブル Map を作成し、その Key に Linux のユーザーID、Value にそのユーザーが動かすプロセスが execve() を呼び出した回数のカウンタを記録する。 別ターミナルを立ち上げてみることで、このプログラムが execve() を記録できていることが確認できる。
# ターミナル1 # python3 hello-map.py ID 501: 1 ID 501: 2 ID 501: 2 ID 501: 2 ID 501: 11 ID 501: 11
# ターミナル2 12:40:09 ~/lizrice/learning-ebpf $ limactl shell learning-ebpf $ ls LICENSE README.md chapter10 chapter2 chapter3 chapter4 chapter5 chapter6 chapter7 chapter8 learning-ebpf-cover.png learning-ebpf.yaml libbpf $ echo $UID 501
前半が C 言語風で書かれた eBPF プログラムで、counter_table ハッシュテーブルにカウンタを書き込んでいる。 後半が python で書かれたユーザー空間のアプリケーションで、b["counter_table"] ハッシュテーブルからカウンタを呼び出している。
#!/usr/bin/python3 from bcc import BPF from time import sleep program = r""" BPF_HASH(counter_table); int hello(void *ctx) { u64 uid; u64 counter = 0; u64 *p; uid = bpf_get_current_uid_gid() & 0xFFFFFFFF; p = counter_table.lookup(&uid); if (p != 0) { counter = *p; } counter++; counter_table.update(&uid, &counter); return 0; } """ b = BPF(text=program) syscall = b.get_syscall_fnname("execve") b.attach_kprobe(event=syscall, fn_name="hello") while True: sleep(2) s = "" for k,v in b["counter_table"].items(): s += f"ID {k.value}: {v.value}\t" print(s)
3章 eBPF プログラムの仕組み
この章からしばらく内部構造の説明が続く。eBPF の利用者に詳細の把握は必要ないが、これ以降の章を理解するための最低限は整理しておく。
eBPF 仮想マシン
eBPF プログラムのソースコードが実行されるまでには次の変換を辿る:
- eBPF コードを開発者が C または Rust 言語で書く
- Clang コンパイラなどで eBPF バイトコードにコンパイルする
- カーネル内にある eBPF 仮想マシンが eBPF バイトコードを動かす
- 実行時にバイトコードが JIT コンパイルされて機械語命令(ネイティブマシン命令)に逐次翻訳される
カーネルへの eBPF プログラムの操作
ここでは bpftool というコマンドを使って、プログラムのロード・情報の確認・イベントへのアタッチなどを行う。 bpftool を使うと、BCC で記述して実行していた操作を、コマンドライン経由で行える。
man でも説明されている通り、ここでもデバッグ用途で使っていく。
BPFTOOL - tool for inspection and simple manipulation of eBPF programs and maps https://man.archlinux.org/man/bpftool.8.en
# ロード $ bpftool prog load hello.bpf.o /sys/fs/bpf/hello # イベントにアタッチ $ bpftool net attach xdp id 540 dev eth0
4章 bpf() システムコール
ユーザー空間のアプリケーションがカーネルに eBPF プログラムをロードするには、bpf() システムコールを呼ぶ必要がある。
eBPF プログラムを作るときには、eBPF を高レベルで抽象化したライブラリを使うことが多いので、bpf() システムコールを直接呼ぶことはない。 ただ、ライブラリ関数は bpf() システムコールから呼び出す様々なコマンドとおおよそ 1対1 で対応しているので、bpf() システムコールの概要を知っておくのは必要である。
bpf() のシグネチャは次の通り:
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
- cmd はどのコマンドを実行するかを指定する。eBPF プログラムのロード、Map の作成、プログラムのイベントへのアタッチ、Map の Key-Value ペアへのアクセスと更新などに対応するコマンドがある
- attr はコマンドのパラメーターを指定する。
以降は BCC プログラムを strace して、出力された bpf() のコマンドを詳説している。何か作るときにまた読むと良さそう。
5章 CO-RE、BTF、libbpf
多くの eBPF プログラムはカーネルのデータ構造にアクセスするため、あるマシンでコンパイルされた eBPF プログラムが別の Linux カーネルのバージョンで動く保証はない。 このカーネル間の移植性の問題には二つのアプローチが存在する:
BCC のアプローチ
BCC はマシン上で実際に実行するときに eBPF コードをコンパイルすることで移植性の問題を解決している。 ただし、このアプローチには次のような問題がある:
このような問題があるため、BCC は実運用で使う eBPF プログラムの開発には向いていない。 一方で、Python のユーザー空間コードはコンパクトで読みやすいので、凝ったことをせずに即座に動かしたい場合は、BCC は第一候補になる。
CO-RE のアプローチ
CO-RE は compile once, run everywhere の略で、BCC よりもはるかに上手にカーネル間移植性に対処しているため、実運用ではこちらを検討することになる。
このアプローチでは、まずコンパイルされたデータ構造のレイアウト情報を eBPF プログラムに含める必要がある。この情報は BTF(BPF Type Format)と呼ばれる。Linux カーネルv5.4以降サポートされている。 そして、そのレイアウトが実行しようとしているマシンとで異なる場合に、フィールドのアクセス方法を調整することで移植が可能になる。
ライブラリによって、この調整を行う。この調整が可能なライブラリはいくつか存在する:
- libbpf(Cライブラリ)。bpftool の裏側は libbpf
- Cilium eBPF(Goライブラリ)
- Aya(Rustライブラリ)
以降は libbpf を使用したコードを書く方法を紹介している。何か作るときにまた読むと良さそう。
6章 eBPF 検証器
eBPF プログラムをカーネルにロードするときは、検証プロセスによってプログラムが安全であることを保証している。 「検証(verification)」とは、プログラムを通して考えうるすべての実行経路をチェックし、どの命令も安全であると保証することを指す。
この章では検証器がどのようなことをしているかについて解説する。eBPF コードを書くときに発生する検証器のエラーを理解したくなったら、また読むと良さそう。
7章 eBPF のプログラムとアタッチメント
eBPF プログラムを書くときにはプログラムタイプを指定する必要がある。これによってそのプログラムがどのイベントにアタッチできるかを決定する。 プログラムタイプは大きく二つのカテゴリに分類される。トレーシングとネットワーク関連である。
トレーシング
Perf 関連のプログラムとも呼ばれる。このカテゴリに分類されるプログラムタイプには次のようなものがある:
- kprobe: カーネル関数の入り口へのアタッチに使う。関数の引数にアクセスできる。
- kretprobe: カーネル関数の終了時へのアタッチに使う。関数の戻り値にアクセスできる。
- fentry: kprobe のより新しく効率的な代替。
- fexit: kretprobe のより新しく効率的な代替。さらに戻り値だけでなく引数にもアクセスできる。
- Tracepoint: カーネルコード内でマークされた場所。5.15 のカーネルでは 1400 以上の Tracepoint が定義されている。以前から SystemTap のようなツールでトレース出力に使われてきた。
ユーザー空間へのアタッチもできる。例えば、OpenSSL の SSL_write 関数の開始地点にアタッチできる。
- uprobe/uretprobe: ユーザー空間の関数の開始と終了の地点へのアタッチに使う。
- USDT(User Statically Defined Tracepoint): ユーザー空間のコードの特定の Tracepoint にアタッチできる。
ネットワーク
ネットワーク関連のプログラムタイプでは、トレーシング関連のプログラムタイプと違って、ネットワークの振る舞いをカスタマイズするために使われる。 例えば、ネットワークパケットをドロップしたりリダイレクトしたり、ソケットの設定パラメーターの変更を行う。
- ソケット操作: TCP 接続に割り込んだり、TCP タイムアウトを設定したりできる
- トラフィックコントール: 帯域制限の設定を変更したりできる
- XDP: XDP プログラムは eth0 インターフェースなどにアタッチして、ネットワークデバイス上でパケットを処理できる。Linux のネットワークスタックに通る前に処理するので高速に動く
- cgroup: 特定の cgroup において、ソケットの操作やデータ転送の実行を禁止できる
8章 ネットワーク用 eBPF
eBPF をベースにしたネットワークツールは広く使われている:
- CNCF の Cilium プロジェクトによる Kubernetes ネットワークのカスタマイズ
- Meta の Load Balancer
- Cloudflare の DDos 防衛機能
これらはパケットのドロップや転送を XDP プログラムで実現することで、既存のものに比べて高いパフォーマンスを発揮している。 一部のネットワークカードは XDP オフローディング機能をサポートしている。これを使えば、全ての処理はネットワークカード上で完結するため、ホストマシンの CPU サイクルは 1 クロックも使わなくていい。
eBPF と Kubernetes ネットワーク
Kubernetes は CNI (Container Network Interface) を通して、どのネットワーク実装を利用するかをユーザーが選べる。 多くの CNI プルグインは Kubenetes における L3/L4 のネットワークポリシーを実装するために iptables を利用している。
Kubernetes では Pod とその IP アドレスが動的に更新されるため、iptables の更新に数時間かかる場合がある。 また、iptables のルール検索はルールの数に比例して時間がかかる問題もある。
Cillium は eBPF ハッシュテーブルで kube-proxy の iptables を置き換えることで、高いスケーラビリティを実現している。 Cillium は AWS, GCP, Azure で使える。
9章 セキュリティ用 eBPF
eBPF は不正な活動を検出・禁止するセキュリティツールを作成するためにも使われている。
Linux において、セキュリティイベントを検知するために使われるイベントは次の二つ:
- システムコール
- Docker や Kubernetes では BPF を使ってシステムコールを制限する seccomp という機能が使える
- システムコールの追跡をベースにしたセキュリティツールで有名なものは、CNCF プロジェクトの Falco がある。Falco はセキュリティアラート機能を提供している。
- LSM インターフェース
10章 プログラミング eBPF
eBPF プログラムを書くときの最もシンプルな方法は bpftrace を使うこと。bpftrace を使えば、カーネル空間とユーザー空間の分離を意識せずにコードを書ける。 https://github.com/bpftrace/bpftrace/tree/master?tab=readme-ov-file#one-liners には便利なワンライナーのサンプルがたくさん載っている。
例えば、2章で BCC を使って書いた execve システムコールが呼ばれた回数を uid 別に集計するコードは bpftrace だと次のように書ける。
# 動作中のマシン上で利用できる kprobe の一覧を表示 $ sudo bpftrace -l "*execve*" ... kprobe:__arm64_compat_sys_execve kprobe:__arm64_compat_sys_execveat kprobe:__arm64_sys_execve kprobe:__arm64_sys_execveat ... $ sudo bpftrace -e 'kprobe:__arm64_sys_execve { @[uid] = count(); }' Attaching 1 probe... ^C @[501]: 2 @[0]: 2
📝 https://github.com/bpftrace/bpftrace/blob/master/docs/tutorial_one_liners.md にあるチュートリアルはわかりやすい。