詳説 eBPF 概論編

執筆者:稲葉貴昭


1. はじめに

本記事は近年注目されているeBPFについて、概論編と実装編に分け解説を行います。
概論編となる今回では、以下のことを解説します。
(1)eBPFとはどんなものでどのような経緯で注目されるようになったのか
(2)LinuxにおいてeBPFがどのように実現されているか
(3)BCC、BCC-Toolsとはどんなものなのか
(4)eBPFのプログラムの移植性を高めるための仕組み(CO-RE)について

2. BPFとは

BPFとは、Berkeley Packet Filterの略で、当初はその名の通り 高速なパケットフィルタを実現することを目的に開発された技術でした。
Linuxにはじめに導入されたのは、1997年のようで意外と歴史ある技術ですが、 現在のように表立って注目されるようになったのは、2014年頃に行われた機能拡張後になります。
このような経緯があって、パケットフィルタを目的に作られた当初のBPFをcBPF(classic BPF) *1 と呼び、その後の機能拡張されたBPFをeBPF(extended BPF)と呼びます。
現在では、単にBPFと表記した場合はeBPFを指すのが一般的なため、以降本記事においてもBPFと表記します。

3. BPFで実現できること

BPFを使えば、カーネル内で発生する様々なイベントを契機にユーザが独自に作ったロジックを 動作させることができます。
例えば、アプリケーションが特定のシステムコールを実行したときにログを保存する、 また特定のパケットを受信したときにパケットに対してユーザ独自の変更を加えることが可能になります。

同様のことは、これまでもカーネルモジュールで実現可能でしたが、 自由度が高いため安全面でのリスクがありました。 BPFでは、BPF仮想マシンの上で動作することやVerifierによる違反コードの確認 を行うことで安全面でのリスクを軽減しています。

BPFの用途としては、Networking、Security、Observability、Tracingの4つがよく挙げられます。 BPFの仕組みを利用している代表的なOSSとしては、 bpftraceCiliumFalcoKatranTetragonなどがあります。

4. BPFの仕組み

BPFのプログラムを作成してから、実際に動作させるまでのざっくりとした流れは図1のようになります。
図1の左側のユーザ空間については、BCCを使う場合やbpfシステムコールを直接呼ぶ場合など 色々なバリエーションがあります。
図1は実装編にて解説を行うlibbpf-toolsのopensnoopの実装を参考に作成しています。

図1 BPFの動作概要

後述しますがBPFを使う場合、カーネル空間で発生するイベントに紐づけて駆動するプログラムおよび ユーザ空間で動作するアプリケーションの2つを作成する必要があります。
本記事では、前者をBPFプログラム、後者をBPFアプリケーションと呼称しますが 公式の用語定義はないようで、2つともBPFプログラムと呼称することもあるようです。

図1をもとにBPFの動きをざっくり説明すると、以下のようになります。

  1. BPFプログラムを開発しClang/LLVMでコンパイルしてバイトコードにする
  2. bpftoolでバイトコードからスケルトンヘッダを作成し、BPFアプリケーションで読み込む
  3. BPFプログラムの結果を保存するmapを作成
  4. BPFプログラムをカーネルにロード
  5. イベントが発生したらBPFプログラムが実行され結果をmapに保存

4.1 BPFプログラムを開発しClang/LLVMでコンパイルしてバイトコードにする

BPFプログラムは、最終的にはBPF仮想マシンにより実行されます。 この仮想マシンが理解できるニーモニックを直接書いてアセンブルしてバイトコードにする方法もあるようですが、 C言語で書いたコードをClang/LLVMでコンパイルする方法が主流なようです(図1-①)。

4.2 bpftoolでバイトコードからスケルトンヘッダを作成し、BPFアプリケーションで読み込む

BPFアプリケーションでは、バイトコードを読み込みlibbpfが提供するAPIを使ってmapの作成やカーネルへのロードを行います。 バイトコードの読み込みのため、bpftool(BPFの開発時に使うユーティリティ)でスケルトンヘッダを作成します(図1-②)。 BPFアプリケーションでは、スケルトンヘッダをインクルードすることで実現しています(図1-③)。

4.3 BPFプログラムの結果を保存するmapを作成

多くの場合、BPFプログラムの処理結果はBPFアプリケーションと共有する必要があります。 mapはBPFプログラムの処理結果を保存するストレージのようなものです。
mapはカーネル空間に存在するため、bpfシステムコールを使って作成します(図1-④、⑤、⑥)。

4.4 BPFプログラムをカーネルにロード

BPFプログラムを動作させるには、bpfシステムコールを呼び出しカーネルにロードする必要があります。 bpfシステムコールを呼び出しロード処理に入ると、カーネルはVerifierでBPFプログラムに問題がないかの検証を行います(図1-⑦、⑧、⑨)。 この検証に失敗した場合、BPFプログラムの修正が必要になります。
Verifierは以下のような項目に違反していないかを検証していますが、カーネルのバージョンによって細かな違いがあるようです。

  1. 到達不可能な命令がないか
  2. ループの上限数に達していないか(v5.3以前だとループ自体が不可)
  3. 命令数の上限に達していないか(カーネルのバージョンにより上限数は異なる。v5.4だと1M程度)
  4. 未初期化のレジスタを利用していないか
  5. 境界を超えたメモリアクセスをしていないか

Verifierの検証に成功したら、次はJITコンパイラによってネイティブコードに変換されカーネルへのロードが完了します(図1-⑩、⑪) 。
図1では省略していますが、ロード完了後にlibbpfのAPIを使ってイベントにアタッチする処理が必要になります。

4.5 イベントが発生したらBPFプログラムが実行され結果をmapに保存

イベントへのアタッチが完了したら、指定したイベントが発生したときにBPFプログラムが実行されます(図1-⑫)。
BPFプログラムでは、必要な処理結果をmapに保存し、BPFアプリケーション側から参照できるようにします(図1-⑬)。 処理が完了したら、return値をカーネルに返して処理を終了します。(図1-⑭)。

(補足)libbpfとは
libbpfとは、文字通りBPFのプログラムを開発する際によくお世話になるライブラリです。
git.kernel.orgで開発が進められており、libbpfリポジトリはミラーになっています。

5. BCCとは

BCCは、BPF Compiler Collectionの略でBPFのツールキットを提供しています。 Compiler Collectionとなっているものの、BCC自体がコンパイラを提供している わけではなく内部ではClang/LLVMを使用しています。
BPFを扱う上でBCCにお世話になるのは、高機能なツールを使用する時とBPFアプリケーションを 開発するときになります。

5.1 BCC-Tools

BPFの力の恩恵を受ける最も簡単な方法は、BCC-Toolsを使うことです。 BCC-Toolsを使うとvmstat、iostat、sarなどの伝統的なUNIXコマンドでは測定できない様々な測定が可能となります。
Ubuntuの場合、aptあるいはsnapを使うことで簡単にBCC-Toolsをインストールできます。 snapでインストールするほうが新しいバージョンが入れられるようです。

【apt版】
sudo apt install bpfcc-tools

【snap版】
sudo snap install bcc --devmode

少々ややこしいのが、apt版をインストールすると「コマンド名-bpfcc」という名前のコマンドがインストールされるのですが、 snap版だと「bcc.コマンド名」という名前でインストールされることです。 snap版のほうが新しい機能が使えるメリットもあるため、特に理由がなければsnapでインストールすることをお勧めします。

図2は、BCC-Toolsで提供されているコマンドがOSのどのレイヤに対応しているかを表したものです。 *2

図2 BCC-Tools

特定のイベントの発生を知りたいときは、execsnoop、opensnoopなどの「~snoop」という 名前のコマンドを使い、topコマンドのようにリアルタイムで何かを監視したい場合はfiletop、tcptopなどの「~top」 という名前のコマンドを使います。 BCC-Toolsの各コマンドの使い方などは、すでに巷に情報がたくさんあるため、本記事での解説は割愛します。

5.2 BCCを使った開発

前述の通り、BPFのプログラムを開発する場合、BPFプログラムとBPFアプリケーションの2つを 開発する必要があるわけですが、この方法だけではカーネルバージョン間での移植性を確保することができません。
これは、BPFプログラムがカーネル内部の値を参照する関係で、コンパイルを行うカーネルと 実際にプログラムを動かすカーネルのバージョンが異なるとBPFプログラムが読み取った領域に異なる値が入るためです。
BCCを利用した開発では、ユーザはBPFアプリケーションをPythonで開発し(他にもいくつかの言語をサポートしていますが、Pythonを使うのが一般的なようです)、 BPFプログラムをPythonの文字列リテラルで書きます。
一例としては、BCC-Toolsのexecsnoop.pyのような感じです。
BCCを使って開発する場合、BCCのライブラリ内でプログラム実行時にBPFプログラムのコンパイルとカーネルへのロードが実行されます。
そのため、開発したプログラムのソースコードレベルでの移植性を確保することが可能になります。 ただし、プログラムを実行する環境にBPFプログラムをコンパイルするためのClang/LLVMや Python実行環境、Linuxカーネルヘッダなどをインストールする必要があります。
これに加えて、実行時にコンパイルというそれなりに重い処理を行う関係で、開発したプログラムが重くなるというデメリットもあります。

このようなデメリットの部分が大きいため、BCC+Pythonで開発されているBCC-Toolsは既に 廃止予定(deprecated)になっており、後述するlibbpf-toolsに移行することが決定しています。

6. CO-RE

CO-REとは、Compile Once - Run Everywhereの略で、名前通り1回コンパイルしたらどこでも実行できるようにするための仕組みです。
CO-REを実現するために、Clangやlibbpf、カーネルなどで対応が行われており、さらにBPFプログラム側もCO-REを意識したコードにする必要があります。

6.1 BPFプログラムをCO-RE対応する場合

開発するBPFプログラムをCO-RE対応したい場合、この記事が参考になるかと思います。 また、libbpf-toolsにあるプログラムのコードを参考にするのもよいかと思います。
例えば、exitsnoop.bpf.cは、CO-RE対応しているBPFプログラムになりますが、BPF_CORE_READという関数マクロを使って、 task_struct構造体(カーネルにおいてプロセスと対応している構造体)を参照しています。

6.2 CO-REの仕組み

CO-REを実現するためにBTF(BPF Type Format)というものが使われています。
BTFとは、BPFプログラムに関するデバッグ情報などのメタデータを格納するファイルフォーマットです。
CO-REを実現するには、BPFプログラム内でカーネルが持つ変数へのアクセスに使うアドレスを実行時に決定する必要があります。 なぜ実行時かというとコンパイル環境と実行環境でカーネルバージョンが異なれば、変数のアドレスが変わる可能性があるためです。 Linuxカーネルは現在でも活発に開発が進められており、内部で持つ変数は割と頻繁に変更されるため、 実行時に実行環境のカーネルバージョンにおいてのアドレスに調整(再配置)する必要があるわけです。
再配置は、BPFアプリケーションがlibbpfを使ってBPFプログラムをカーネルにロードするときに行われます。 libbpfは、BPFアプリケーションが実行されたカーネルのBTFを「/sys/kernel/btf/vmlinux」から読み出し再配置を行います。

ちなみに前述のBPF_CORE_READは内部で__builtin_preserve_access_indexというClang提供する機能を使って再配置可能なセクションにコードを配置しています。
最終的には、実行時に実行環境のカーネルのtask_struct構造体のメンバのアドレスを参照できるようにlibbpfが調整してくれるわけです。

7. おわりに

少々駆け足となりましたが、概論編は以上となります。 次回の実装編では、BCC-Toolsの後継となるlibbpf-toolsのうちopensnoopというコマンドを軸に具体的にどのような実装になっているのか ソースコードをベースに解説します。

*1:カーネルドキュメントによれば現在ではcBPFで書かれたプログラムは、eBPFの命令に変換されて実行されるようです。

*2:引用元:https://github.com/iovisor/bcc/blob/master/images/bcc_tracing_tools_2019.png