eBPF

Takami Torao Kernel 5.15 Ubuntu 22.04 libbpf 0.8 #BPF #eBPF
  • このエントリーをはてなブックマークに追加

概要

eBPF (extended Berkeley packet filter) はカーネルの再構築なしにカーネル空間でプログラムを実行することのできる仮想マシン技術。eBPF のバイトコードは実行時に JIT コンパイラによって検証とネイティブコード変換が行われ、カーネル内の保護された空間 (サンドボックス) で安全かつ効率的に実行することができる。

Table of Contents

  1. 概要
  2. 導入
    1. バイトコード
    2. フック
    3. マップ
    4. セキュリティ
  3. libbpf-bootstrap サンプル
    1. サンプル: bootstrap (C)
    2. サンプル: xdp の実行 (Rust)
    3. サンプル: minimal の分析
      1. BPF プログラム
      2. ユーザ空間プロセス
    4. 実行環境
  4. BPF 開発
    1. bpftool のインストール
  5. User × Kernel パフォーマンス比較
  6. 参照

導入

BPF は tcpdump(8) を高速化するためにカーネル内で動作する仮想マシンとして 1992 年に BSD Unix に導入された。これは事前定義された命令セットを実行するインタープリタとして機能するもので、アプリケーションの受信するパケットを負荷の低いカーネル空間でフィルタリングすることを目的としていた。BPF は継続的な開発によってカーネル空間で安全に実行できる JIT コンパイラをもつ汎用的な仮想マシン技術と進化し、Linux カーネル 3.18 での大幅な改定以降は eBPF と呼ばれている。現在では cBPF (classic BPF) とも呼ばる旧 BPF の機能はほとんど廃止されている。以下、この文書では BPF は eBPF を意味しているものとする。

Unix ライクな OS の処理はカーネルモードとユーザモードに分かれている。カーネルモードは主にデバイス操作や割り込みのような特権的な処理を行うことができる。ユーザモードで動作する処理はシステムコール (system call) と呼ばれる API を利用してカーネルモードの機能を使うことができる。

カーネル空間は I/O レイテンシーのようなシステムプロファイリングを行うのに最適な場所であるにも関わらず、バージョンごとに非常に神経質な改変が行われることから、あらゆるバージョンで動作するようなドメイン要件の計測からパッチを作成して導入することは難しい。パッチではなくカーネル標準として組み込むとしても、よしんば開発コミュニティを説得できたとしてもリリースまでに数年待たなければならない。BPF はそのような厳格なカーネルを修正することなく、将来のバージョンにわたって利用可能であることが保証されたプログラムをカーネルに追加できる機構といえる。

BPF Overview
Fig 1. BPF Overview

バイトコード

BPF バイトコードの実体は C 言語から LLVM などのバックエンドを介してコンパイルされた RISC 命令セットである (したがってユーザ空間でも動作することができる)。カーネルにロードされた BPF バイトコードはシステムを再起動することなくアトミックに更新され、Verifier によって実行安全性の検証を受けてから JIT コンパイラでネイティブコードに変換される。

バイトコードあたりのインストラクション数は 100 万 (カーネル 5.2 より前は 4096) に制限されている。Verifier は BPF バイトコードの実行が一定の時間で終了することを保証するために有界ループが最大インタラクション数を超えないかの検査を行っている (カーネル 5.3 より前はループ命令そのものが禁止されているため for 文はコンパイラレベルで展開やジャンプに置き換えられていた)。

BPF プログラムは末尾呼び出し (tail call) を使って別の BPF プログラムを実行することができる。ただし、これは関数呼び出しのように呼び出し元に制御が戻ることはない。呼び出し元の使用していたスタックフレームを再利用した longjump の動作を行う。

BPF CO-RE (compile once run everywhere) は BPF バイトコードをビルドしたカーネルとは異なるバージョンの環境で実行できるようにする仕組みである。例えばバージョンによって異なるカーネルソース内の構造体フィールド定義を実行時に認識してオフセットを書き換えてるようなことを行っている。ただしこの BPF CO-RE はカーネル 5.13 (Ubuntu 21.10 相当) で導入されたため、現時点で比較的新しいディストリビューションしか利用できない。

フック

BPF はシステムで何かが起きたときに呼び出されるイベント駆動型のプログラムである。BPF プログラムを設置できるポイントをフック (hook) と呼ぶ。システムで利用可能な規定のフック (トレースポイント) は /sys/kernel/debug/tracing/events から参照することができる。

torao@beryl:~$ sudo ls /sys/kernel/debug/tracing/events
alarmtimer    devlink       hda_controller  irq            mdio       page_isolation  rseq                     timer
asoc          dma_fence     hda_intel       irq_matrix     mei        pagemap         rtc                      tlb
avc           drm           header_event    irq_vectors    migrate    page_pool       sched                    tls
block         enable        header_page     iwlwifi        mmap       percpu          scsi                     udp
bpf_test_run  error_report  huge_memory     iwlwifi_data   mmap_lock  power           signal                   vmscan
bpf_trace     exceptions    hwmon           iwlwifi_io     mmc        printk          skb                      vsyscall
bridge        ext4          hyperv          iwlwifi_msg    module     pwm             smbus                    wbt
btrfs         fib           i2c             iwlwifi_ucode  mptcp      qdisc           sock                     workqueue
cfg80211      fib6          i915            jbd2           msr        random          spi                      writeback
cgroup        filelock      initcall        kmem           napi       ras             swiotlb                  x86_fpu
clk           filemap       intel_iommu     kvm            neigh      raw_syscalls    sync_trace               xdp
compaction    fs_dax        interconnect    kvmmmu         net        rcu             syscalls                 xen
cpuhp         ftrace        iocost          libata         netlink    regmap          task                     xhci-hcd
cros_ec       gpio          iomap           mac80211       nmi        regulator       tcp
dev           gvt           iommu           mac80211_msg   nvme       resctrl         thermal
devfreq       hda           io_uring        mce            oom        rpm             thermal_power_allocator
torao@beryl:~$ sudo ls /sys/kernel/debug/tracing/events/syscalls
enable                             sys_enter_msgrcv                  sys_enter_writev                  sys_exit_msgsnd
filter                             sys_enter_msgsnd                  sys_exit_accept                   sys_exit_msync
sys_enter_accept                   sys_enter_msync                   sys_exit_accept4                  sys_exit_munlock
sys_enter_accept4                  sys_enter_munlock                 sys_exit_access                   sys_exit_munlockall
sys_enter_access                   sys_enter_munlockall              sys_exit_acct                     sys_exit_munmap
sys_enter_acct                     sys_enter_munmap                  sys_exit_add_key                  sys_exit_name_to_handle_at
sys_enter_add_key                  sys_enter_name_to_handle_at       sys_exit_adjtimex                 sys_exit_nanosleep
sys_enter_adjtimex                 sys_enter_nanosleep               sys_exit_alarm                    sys_exit_newfstat
sys_enter_alarm                    sys_enter_newfstat                sys_exit_arch_prctl               sys_exit_newfstatat
sys_enter_arch_prctl               sys_enter_newfstatat              sys_exit_bind                     sys_exit_newlstat
sys_enter_bind                     sys_enter_newlstat                sys_exit_bpf                      sys_exit_newstat
sys_enter_bpf                      sys_enter_newstat                 sys_exit_brk                      sys_exit_newuname
...
sys_enter_mq_unlink                sys_enter_vmsplice                sys_exit_mremap                   sys_exit_wait4
sys_enter_mremap                   sys_enter_wait4                   sys_exit_msgctl                   sys_exit_waitid
sys_enter_msgctl                   sys_enter_waitid                  sys_exit_msgget                   sys_exit_write
sys_enter_msgget                   sys_enter_write                   sys_exit_msgrcv                   sys_exit_writev

syscalls/* を見るとかなり多くの (すべての?) システムコール前後をトレースポイントとして使用できることが分かる。例えば syscalls/sys_enter_write というトレースポイントは、システムコール write() が呼び出されたときに起動するフックを意味している (write() の汎用性を考えれば出力に関するかなりの部分がこのトレースポイントでカバーできることが分かるだろう)。

事前定義されていないポイントに対しては、kprobe, uprobe というカーネル処理やユーザ空間のアプリケーションに処理を差し込むための仕組みを使って BPF プログラムを設置することもできる。

ネットワーク向けのフックでは、NIC デバイス上で BPF プログラムを実行できるようなオフロードインターフェースが用意されている。また BPF を使ってカーネルで TLS をサポートする拡張が提案されているように、透過的にデータを加工する用途に使用することもできる。

マップ

BPF プログラムはファイル入出力を含むシステムコールを利用できない代わりにマップ (map) と呼ばれるカーネル空間の key-value ストアを利用することができる。マップは複数の BPF プログラムおよびユーザ空間のプログラムで共有されているため、それらの間でデータ交換のために利用することができる。1 つの BPF プログラムで利用できるマップの上限は現在 64 である。

セキュリティ

BPF をカーネルにロードするには特権モード (root 権限) が必要。これはオプトアウト可能だが、特権モードでない場合は利用できる機能に制限がある。

BPF はカーネルのクラッシュや悪意的な攻撃を防ぐために硬化 (hardening) 呼ばれるメモリ保護機能を持つ。これはカーネルにロードされた BPF プログラムのメモリ領域を読み取り専用としてロックする。また JIT splaying attack を防ぐために (完全な防止策でないものの) JIT コンパイラが有効になっているときはすべての JIT 定数領域がブラインド化される。

libbpf-bootstrap サンプル

libbpf/libbpf-bootstrap は libbpf を使ったサンプル集である。これに含まれているいくつかの BPF プログラムを実際に動かしてみよう。これらは Ubuntu 22.02 環境で以下のようにビルドすることができる。

torao@lazurite:~/git$ sudo apt install -y build-essential clang libelf-dev zlib1g
torao@lazurite:~/git$ git clone https://github.com/libbpf/libbpf-bootstrap.git --recurse-submodules
torao@lazurite:~/git$ cd libbpf-bootstrap/examples/c
torao@lazurite:~/git/libbpf-bootstrap/examples/c$ make

make 時に llvm-strip が見つからないエラーが発生する場合は /usr/bin/llvm-strip-XX のシンボリックリンクを作成する。

torao@lazurite:/usr/bin$ sudo ln -s /usr/bin/llvm-strip-14 /usr/bin/llvm-strip

サンプル: bootstrap (C)

libbfp-bootstrap に含まれている bootstrap コマンドはシステム上で起動するすべてのプロセスの開始から終了までの時間をトレースする BPF プログラムである。

上記の例は bootstrap を起動した状態で bash, python3, および libbpf-boostrap 自体の make を実行している。bootstrap 起動画面にそれらの実行で起動したプロセスのプロセス ID や終了コード、実行時間が表示されていることがわかる。

サンプル: xdp の実行 (Rust)

xdp は XDP (express data path)libbpf-rs (Rust) を使用したサンプルである。cargo が利用可能な環境で以下のようにビルド/実行することができる。

torao@beryl:~/git/libbpf-bootstrap$ cargo install libbpf-cargo
torao@beryl:~/git/libbpf-bootstrap$ cd examples/rust$
torao@beryl:~/git/libbpf-bootstrap/examples/rust$ cargo build --release
torao@beryl:~/git/libbpf-bootstrap/examples/rust$ sudo ./target/release/xdp 1

このサンプルはシステムで発生するネットワークパケットのプロファイリングを行っている。xdp を起動した状態で /sys/kernel/debug/tracing/trace_pipe ファイルを cat すると、各ネットワークデバイスの入力に接続してパケットサイズをログ出力する。

サンプル: minimal の分析

libbpf-bootstrap に含まれている minimal は BPF を動作できる (ほぼ) 最小セットの実装サンプルである。これは BPF CO-RE の代わりにシステムのカーネルヘッダを直接参照するため古いカーネルでも動作する。これはバイトコードがビルド環境のカーネルに強く依存する汎用性のない方法だが、ローカルで実行するだけであればこれでも十分だろう。

minimal コマンドは 1 秒に 1 回 /sys/kernel/debug/tracing/trace_pipe にメッセージを出力する。その動作原理は 1) ユーザ空間側のプロセス \(P_u\) が 1 秒に 1 回 . を出力し、2) カーネル側の BPF プログラム \(P_k\) は \(P_u\) で write() システムコールが呼び出されるたびに起動され trace_pipe にメッセージを出力する。

torao@beryl:~/git/libbpf-bootstrap/examples/c$ sudo ./minimal
[sudo] password for torao:
libbpf: loading object 'minimal_bpf' from buffer
libbpf: elf: section(3) tp/syscalls/sys_enter_write, size 104, link 0, flags 6, type=1
...
libbpf: prog 'handle_tp': found data map 1 (minimal_.rodata, sec 7, off 0) for insn 6
libbpf: map 'minimal_.bss': created successfully, fd=4
libbpf: map 'minimal_.rodata': created successfully, fd=5
Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` to see output of the BPF programs.
...................

xdp と同様に、minimal コマンドを実行しながら /sys/kernel/debug/tracing/trace_pipe を経由してメッセージを参照することができる (tail ではなく cat であることに注意)。

torao@beryl:~$ sudo cat /sys/kernel/debug/tracing/trace_pipe
 minimal-13986   [003] d...1 13587.843103: bpf_trace_printk: BPF triggered from PID 13986.
 minimal-13986   [003] d...1 13588.843427: bpf_trace_printk: BPF triggered from PID 13986.
 minimal-13986   [003] d...1 13589.843740: bpf_trace_printk: BPF triggered from PID 13986.
...

BPF バイトコードの本体は minimal の実行バイナリに定数としてハードコードされている。そして main() 内で load, attach することでカーネルに BPF プログラムがロードされ write() システムコールにアタッチされる。これは minimal プロセスが終了するとアンロードされる。

Fig 2. minimal サンプルの構造。

syscalls/sys_enter_write はすべてのプロセスの write() システムコールに反応してしまうため、実際の BPF プログラムでは PID を比較して自身をロードした minimal プロセスからの write() 呼び出しでのみ処理を行う設計になっている。

以下、minimal のコードを読み解きながら libbpf を使ったプログラム構造について説明する。

BPF プログラム

以下は minimal.bpf.c の内容。

// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2020 Facebook */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

int my_pid = 0;

SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
	int pid = bpf_get_current_pid_tgid() >> 32;

	if (pid != my_pid)
		return 0;

	bpf_printk("BPF triggered from PID %d.\n", pid);

	return 0;
}

6 行目: BPF プログラムのライセンス表明は必須であり、この例ではセクション指定 SEC("license") を使って LICENSE 変数にライセンスを表示している。ライセンスの明示は GPL 非互換の BPF プログラムで一部の機能を使用できないようにするために参照される。

8 行目: libbpf を使った BPF プログラムのグローバル変数は、ユーザ空間のプロセスとカーネルで動作している BPF プログラムで共有することができる。この例の my_pid は後述するようにユーザ空間側のコードから skel->bss->my_pid で参照できる (ビルド時にそのような構造体が自動生成される)。ユーザ空間側のプロセスとの状態共有手段として見るとグローバル変数を使う方法はマップよりも効率が良い。

10-21 行目: SEC("tp/syscalls/sys_enter_write") に続く関数はカーネルにロードされる BPF プログラムの本体である。

SEC() で指定するセクション名は libbpf が作成すべき BPF プログラムの種類と、それをどこのフックにアタッチするか示している。tp/[event] または tracepoint/[event] はトラップポイント (規定のフック) を使用することを示している。syscalls/sys_enter_write は前述の通り /sys/kernel/debug/tracing/events/syscall/sys_enter_write で定義されている名前に相当する。トラップポイント以外では、例えば kprobe を使用する場合は kprove/[function] のようになる。

この例では、任意のユーザ空間プロセスから write() システムコールが呼び出されるたびに起動され、そして write() システムコールを行ったのがユーザ空間側で動作する minimal プロセスであった場合にのみ (つまり PID が一致した場合にのみ) bpf_printk() 経由でメッセージを出力している。

bpf_printk()/sys/kernel/debug/tracing/trace_pipe にメッセージを出力するためのヘルパー機能である。その他に使用できるヘルパー関数は bpf-helpers(7) — Linux manual page 参照。

ユーザ空間プロセス

以下は minimal.c の内容。

// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
/* Copyright (c) 2020 Facebook */
#include <stdio.h>
#include <unistd.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "minimal.skel.h"

static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
	return vfprintf(stderr, format, args);
}

int main(int argc, char **argv)
{
	struct minimal_bpf *skel;
	int err;

	libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
	/* Set up libbpf errors and debug info callback */
	libbpf_set_print(libbpf_print_fn);

	/* Open BPF application */
	skel = minimal_bpf__open();
	if (!skel) {
		fprintf(stderr, "Failed to open BPF skeleton\n");
		return 1;
	}

	/* ensure BPF program only handles write() syscalls from our process */
	skel->bss->my_pid = getpid();

	/* Load & verify BPF programs */
	err = minimal_bpf__load(skel);
	if (err) {
		fprintf(stderr, "Failed to load and verify BPF skeleton\n");
		goto cleanup;
	}

	/* Attach tracepoint handler */
	err = minimal_bpf__attach(skel);
	if (err) {
		fprintf(stderr, "Failed to attach BPF skeleton\n");
		goto cleanup;
	}

	printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
	       "to see output of the BPF programs.\n");

	for (;;) {
		/* trigger our BPF program */
		fprintf(stderr, ".");
		sleep(1);
	}

cleanup:
	minimal_bpf__destroy(skel);
	return -err;
}

minimal.skel.h ヘッダファイルは bpftool によって自動生成され、minimal_bpf 構造体と minimal_bpf__xxx() 関数の定義、それにハードコードされた ELF バイナリが含まれている。詳細は make 後に残されている .output/minimal.skel.h を参照。

ユーザ空間で動作する minimal プロセスは、minimal BPF プログラムをオープンし、minimal 以外の write() に反応しないように前述のグローバル変数に PID を設定し、バイトコードをカーネルにロードし、規定のフックにアタッチしたあと、1 秒おきに . を出力して (write() システムコールを呼び出して) BPF プログラムを起動している。

実行環境

この章でのサンプル実行環境は以下の通り。なお Windows 11 の WSL2 + Ubuntu 22.04 や Docker コンテナ上ではビルドは成功するもののいくつかのコマンドはうまく起動ができなかったため、最初は仮想化されていない最新カーネルの Linux 環境で試すことをおすすめする。

torao@beryl:~$ uname -a
Linux beryl 5.15.0-30-generic #31-Ubuntu SMP Thu May 5 10:00:34 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
torao@beryl:~$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04 LTS"

BPF 開発

bpftool のインストール

bpftool は BPF のためのユーティリティである。bpftool を使うことでマップを参照したり更新することができる。

torao@beryl:~$ sudo apt install -y linux-tools-common linux-tools-generic linux-cloud-tools-generic

または Linux カーネルのリポジトリを取得してソースビルドを行う。

TBC..

User × Kernel パフォーマンス比較

一般にユーザ空間よりカーネル空間で動作するほうが速いと言われるが、実際に BPF での処理はどうなるだろうか。BPF の複雑性検査が失敗しない範囲で右のような簡素な素数判定機を作成した。

以下のコードの左が BPF のトレースポイントをトリガーに起動される関数、右が main() から呼び出される関数である。どちらも 0 から 100 の範囲に含まれる素数の個数を数えている。

typedef unsigned long long u64;

bool is_prime(u64 x) {
  if (x < 2) {
    return false;
  }
  for (u64 i = 2; i * i <= x; ++i) {
    if (x % i == 0) {
      return false;
    }
  }
  return true;
}
// clang -g -O2 -target bpf -D__TARGET_ARCH_x86 ... -c cnt_prime.bpf.c -o .output/cnt_prime.bpf.o
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

int my_pid = 0;

SEC("tp/syscalls/sys_enter_write")
int cnt_prime(void *ctx) {
  int pid = bpf_get_current_pid_tgid() >> 32;
  if (pid != my_pid) {
    return 0;
  }

  int count = 0;
  u64 t0 = bpf_ktime_get_ns();
  for (int i = 0; i <= 100; ++i) {
    if (is_prime(i)) {
      ++count;
    }
  }
  u64 t1 = bpf_ktime_get_ns();
  bpf_printk("[%d] %d, %d[nsec]\n", pid, count, t1 - t0);

  return 0;
}
// clang -g -O2 -o cnt_prime cnt_prime.c
void cnt_prime() {
  struct timespec t0;
  struct timespec t1;

  int count = 0;
  clock_gettime(CLOCK_MONOTONIC, &t0);
  for (int i = 0; i <= 100; ++i) {
    if (is_prime(i)) {
      ++count;
    }
  }
  clock_gettime(CLOCK_MONOTONIC, &t1);
  u64 elapsed = 1000 * 1000 * 1000 * (t1.tv_sec - t0.tv_sec);
  elapsed += t1.tv_nsec - t0.tv_nsec;
  printf("%d, %lld[nsec]\n", count, elapsed);
}

Table 1 は上記の cnt_prime() をそれぞれ 15 回呼び出し、最初の 5 回を破棄して 10 回分の計測の平均と標準偏差を出したものである。ユーザ空間での実行よりもカーネルで動作する BPF ほうが 15 倍以上遅いという結果となった。

JIT を有効化している状態でここまで差が出る理由は分からないが、少なくともカーネル空間だから BPF のほうが速いという盲目的な先入観は疑う余地がある。なお -g オプションは (多分グローバル変数名を取得するような目的で) bpftool がシンボルを参照するために必要らしい。

BPF ユーザ空間
平均 14,879.1 984.0
標準偏差 1,190.0 46.2
Table 1. 実行時間の比較 (単位は nsec)。

Intel Core i3-10110U CPU @ 2.10GHz + Ubuntu 22.04 (Kernel 5.15.0)
$ sudo cat /proc/sys/net/core/bpf_jit_enable => 1

参照

  1. Brendan Gregg (2020) Systems Performance, Pearson
  2. eBPF - Introduction, Tutorials & Community Resources
  3. BPF and SDP Reference Guide ─ Cilium 1.11.5 documentation