Perlで始めるeBPF: 自作Loaderの作り方

はじめに

2024年10月5日(土)に「YAPC::Hakodate 2024」が開催されました。そこで行われた発表の中から、BBSakura Networks株式会社の早坂彪流さんによる「Perlで始めるeBPF: 自作Loaderの作り方」をレポートします。

発表の構成としては、前半でeBPFの基礎を解説し、後半はPerlでeBPF Loaderを自作する過程の説明、そして最後にデモという内容でした。

eBPFの基礎

eBPFの概要

eBPF(extended Berkeley Packet Filter)は、Linuxカーネルに対する拡張を簡単に記述し、動的にロードする仕組みです。eBPFを使うことで、手軽にカーネルの拡張ができるだけでなく、カーネル内部で実行された関数の結果を取得できるとか、100Gbpsを超えるような高速なパケット処理も手軽にできるようになるなど、さまざまなことができるようになります。このようなことから、現在ではセキュリティ、オブザーバビリティ、ネットワークなどいろいろな分野でeBPFが活用されています。

さくらインターネットグループでも、さくらインターネットとBBSakura Networksの共同でパケット交換機(PGW-U)を開発しており、その中でeBPFを使用しています。こちらについては、2024年8月に開催されたeBPF Japan Meetup #1において早坂さんが「Exploring XDP: Fundamentals and Real-World Implementations in Mobile Network Data Plane」というタイトルで発表していますので、興味のある方は資料をご覧ください。

従来の仕組みとの違い

Linuxにはもともとカーネルモジュールと呼ばれる機能があり、これを使うことでカーネル空間で動く独自の拡張を実装することができます。eBPFで実装できる機能も基本的には同じですが、安全性と後方互換性において異なる点が見られます。

安全性に関しては、eBPFにはVerifierという事前検証の仕組みがあり、これがプログラマーのミスを事前に防ぎます。例えばメモリアクセス違反によるカーネルのハングを防止するとか、メモリリークの防止、無限ループ発生の防止などをチェックします。後方互換性に関しては、Linuxのカーネルモジュールが後方互換性を保証していないのに対して、eBPFはAPIインターフェース経由で動作するので後方互換性が保証されます。また、CPUアーキテクチャへの依存もないので移植性も高く、カーネル開発のアジリティも高くなります。

eBPFの歴史

eBPFの開発は1992年にまで遡ります。「The BSD Packet Filter: A New Architecture for User-level Packet Capture」という論文における、パケットキャプチャを効率化するアイデアが始まりです。ここで提唱されたパケットフィルタはclassic BPF(cBPF)と呼ばれ、現在のLinuxにおいてもLinux Socket Filter(LSF)として組み込まれています。我々が目にする機会がありそうなものとしてはtcpdumpコマンドにおけるフィルタ構文で利用されています。

それから約20年後に、Alexei StarovoitovさんがLinuxカーネルMLに「[PATCH net-next] extended BPF」というメールを投稿し、BPFの拡張を提案します。これがeBPFの原案です。これを起点にeBPFは数々の拡張がなされ、それとともに用途も拡大し、現在のような幅広い使われ方をするツールとなりました。

eBPFの仕組みとアーキテクチャ

eBPFのアーキテクチャ (出典:https://ebpf.io/what-is-ebpf/)

上図がeBPF Foundationの公式サイトに掲載されているeBPFのアーキテクチャです。全体の流れとしては、eBPFのプログラムをコンパイルし、生成されたバイナリをLoaderがロードしてアタッチして、Verifierによる検証やJITコンパイルを行い、プロセスとの間で通信を行います。

eBPFプログラムの開発

eBPFプログラムの開発を行うのが上図左上のDevelopmentエリアです。具体的にはC言語などでプログラムを作成し、clangでコンパイルしてバイトコード(ELFファイル)を生成します。

 1  #include <linux/bpf.h>
 2  #include <bpf/bpf_helpers.h>
 3  #include <linux/if_ether.h>
 4  #include <arpa/inet.h>
 5  SEC("xdp_drop")
 6  int xdp_drop_prog(struct xdp_md *ctx)
 7  {
 8     void *data_end = (void *)(long)ctx->data_end;
 9     void *data = (void *)(long)ctx->data;
10     struct ethhdr *eth = data;
11     __u16 h_proto;
12
13     if (data + sizeof(struct ethhdr) > data_end)
14         return XDP_DROP;
15
16     h_proto = eth->h_proto;
17
18     if (h_proto == htons(ETH_P_IPV6))
19         return XDP_DROP;
20
21     return XDP_PASS;
22  }
23  char _license[] SEC("license") = "GPL";

上記はプログラム例で、IPv6パケットをドロップするプログラムです。このプログラムのポイントは2つあります。1つは5-6行目で、SECというマクロを関数に適用することでエントリーポイントを決定します。Cのmainに相当するものを自分で指定するようなイメージです。もう1つのポイントは13-14行目のif文です。ここでは安全性を保つための境界値チェックをしています。こういったチェックをプログラムの中に書かないと、後でVerifierを通したときに警告されてプログラムが動作しないようになっています。

eBPFプログラムのロードと検証

生成したバイトコードをロードするのがLoaderです。今回はこれを自作したわけですが、詳細は後述します。

また、ロードされたプログラムを実行する前に検証するのがVerifierです。検証は二段階で行われます。最初の段階ではすべての分岐をトレースし、無限ループの回避、最大命令長(100万命令)の超過チェック、不正なジャンプのチェックなどを行います。二段階目ではBPFの制約の上でレジスタの演算ができるかどうか、バッファオーバーフローしないかなど、より細かいチェックを行っていきます。

eBPFプログラムの実行

Verifierを通過したバイトコードは、実行する前にJITコンパイラを通してマシンコードに変換されます。このとき、実行するアーキテクチャに応じたコードに変換されるので、eBPFはアーキテクチャ非依存でプログラムを開発することができます。

マシンコードに変換したらカーネルにロードし、実行したい場所にアタッチします。通常は何かのイベントをフックにしてeBPFプログラムを実行します。例えばsocketに対するIOをフックするとか、NICに対するIOをフックするなどです。

それから、eBPFプログラムを実行する場合、その多くはカーネルからデータを取得したり、逆にデータを渡すような目的を持っています。このような目的を達成するための仕組みがeBPF Mapです。これはカーネルからもユーザー空間からも読み書きができるキーバリューストア(KVストア)です。例えば、IPアドレスのブラックリストをeBPF Mapで持っておき、それを参照することで合致するIPアドレスからのパケットをドロップする、ブラックリストはユーザ空間から随時更新可能、というような使い方をします。

PerlでeBPF Loaderを自作する

ここからは本発表の本題である、eBPF LoaderをPerlで実装する話です。

実装するべき項目をざっと挙げると以下のようになります。

  • ELFバイナリをパースして、実行に必要な情報を取り出す
  • リロケーションでeBPF MapのFD(File Descriptor)を埋め込む
  • bpfシステムコールを使ってeBPFプログラムをロードする
  • bpfシステムコールを使ってeBPF Mapを読み書きする
 1  #include <linux/bpf.h>
 2  #include <linux/ptrace.h>
 3  #include <bpf/bpf_helpers.h>
 4  struct bpf_map_def SEC("maps") kprobe_map = {
 5     .type = BPF_MAP_TYPE_HASH,
 6     .key_size = sizeof(__u32),
 7     .value_size = sizeof(__u64),
 8     .max_entries = 1,
 9  };
10  SEC("kprobe/sys_open")
11  int kprobe_sysopen()
12  {
13     __u32 key = 1;
14     __u64 initval = 1, *valp;
15     valp = bpf_map_lookup_elem(&kprobe_map, &key);
16     if (!valp){
17         bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
18         return 0;
19     }
20     __sync_fetch_and_add(valp, 1);
21     return 0;
22  }
23  char LICENSE[] SEC("license") = "GPL";

上記のプログラムは説明用のサンプルプログラムです。何かのファイルが開かれたらカウンターが増えるという動作をします。

eBPF Loaderは、上記のプログラムをコンパイルして得られたELFバイナリを解析します。ELFバイナリは一般的には下図のような構造になっています。先頭にELFヘッダがあり、続いてプログラムヘッダとその参照、セクションヘッダならびに各種セッションが含まれています。BPFの場合は基本的にすべてセクションです。

PerlでELFファイルをパースする際は、まずELFヘッダをパースします。下記に実装例を提示します。ELFファイルを読み込み、substrで文字列のように切り出して、unpackでPerlのデータ構造に変換しています。

 1  my $byte_offset = 0;
 2  my $byte_range  = 16;# ELFヘッダは16バイト
 3  # e_identをパース
 4   my ( $magic, $class, $endian, $version, $abi, $abi_version )
 5     = unpack( 'A4C3A5C2',
 6       substr( $data, $byte_offset, $byte_offset + $byte_range ) );
 7  
 8  $byte_offset += $byte_range;
 9  $byte_range = 32;
10  # 細かいのは中略...
11  # ELFファイルのサイズなどを取得
12  my ($e_type, $e_machine, $e_version, $e_entry,$e_phoff, $e_shoff, $e_flags, $e_ehsize, $e_phentsize, $e_phnum, $e_shentsize, $e_shnum, $e_shstrndx)= unpack( 'S S L Q Q Q L S S S S S S',
13         substr( $data, $byte_offset, $byte_offset + $byte_range ) );

続いてセクション部分のパースです。ELFではデータの文字列部分をシンボルテーブルに分けているため、今回のデータの実体があるセクションテーブルとシンボルテーブルを紐づけながら解析する必要があります。これも実装例を示します。

 1  # section tableのセクション名を取得するために文字列テーブルセクションを取得
 2  my $strtab_section_offset
 3     = $elf->{e_shoff} + $elf->{e_shstrndx} * $elf->{e_shentsize};
 4  my $strtab_offset
 5     = unpack( 'Q', substr( $data, $strtab_section_offset + 24, 8 ) );
 6  
 7  # セクションヘッダとシンボルテーブルをパースするための追加処理
 8  $elf->{sections}
 9     = parse_sections( $data, $elf->{e_shoff}, $elf->{e_shnum},
10     $elf->{e_shentsize}, $strtab_offset );
11  $elf->{symbols}
12     = parse_symbols( $data, $elf->{sections}, $elf->{e_shstrndx} );
13  $elf->{relocations} = parse_relocations( $data, $elf->{sections} );
14  return $elf;

処理の順番としては次はリロケーションですが、先にbpfシステムコールを用いたプログラムのロードやマップの生成方法を説明します。これらの処理はbpfシステムコールのサブコマンドで実現できます。マップの生成はBPF_MAP_CREATE、プログラムのロードはBPF_PROG_LOADを実行します。Perlにおいてシステムコールを実行するには、Perlの組み込みコマンドh2phと、Perlの組み込み関数syscallを利用します。

しかし、ここで苦労する点があります。BPFのデータ構造を見ると、プログラムのポインタを渡していると思われる箇所があります。Perlにはポインタはないので、何らかの工夫が必要があります。これを処理するPerlの実装例を以下に示します。insnsがプログラムのポインタです。これをpack("P")でバイナリ文字列に変換し、それをunpack("Q")でuint64に変換、さらに再度pack("P")してバイナリにしています。最後にsyscallでシステムコールを実行します。

 1  my $attr = pack(
 2     "L L Q Q L L Q L L",
 3     $attrs->{prog_type},
 4     $attrs->{insn_cnt},
 5     unpack( "Q", pack( "P", $attrs->{insns} ) ),
 6     unpack( "Q", pack( "P", $attrs->{license} ) ),
 7     $attrs->{log_level},
 8     $attrs->{log_size},
 9     unpack( "Q", pack( "P", $attrs->{log_buf} ) ),
10     $attrs->{kern_version},
11     $attrs->{prog_flags}
12  );
13   my $fd = syscall( Sys::Ebpf::Syscall::SYS_bpf(),
14       BPF_PROG_LOAD, $attr, length($attr) );

これでシステムコールは実行できるようになりましたが、これだけでは不十分で、Verifierを通過できません。

Log buffer content:
0: (b7) r1 = 0
1: (63) *(u32 *)(r10 -4) = r1
last_idx 1 first_idx 0
regs=2 stack=0 before 0: (b7) r1 = 0
2: (b7) r6 = 1
3: (7b) *(u64 *)(r10 -16) = r6
4: (bf) r2 = r10
5: (07) r2 += -4
6: (18) r1 = 0x0
8: (85) call bpf_map_lookup_elem#1
R1 type=inv expected=map_ptr

上記がそのログですが、r1 = 0x0という行が問題の箇所です。ここにはeBPF Mapが入るのですが、先ほど説明を後回しにしたリロケーション(プログラムロード時にシンボルやメモリアドレスを決定するプロセス)を行うことでeBPF MapのFDを取得することができます。そして、これをr1に埋め込むことでVerifierも通るようになり、プログラムのロードができるようになります。

実装の詳細はかなり複雑なので、知りたい方は早坂さんが公開している資料(後述)をご覧ください。

eBPF Mapを通じたデータの読み書き

これでプログラムのロードまではできるようになりましたが、実際に使えるようにするには、Perlで開発したアプリとの間でデータのやり取りができるようにする必要があります。eBPF Mapの読み書きをするAPIとしてはlookup, update, deleteがあり、それらをPerlのアプリから使えるように実装します。こちらもbpfシステムコールを利用することで実装できます。実装例を以下に示します。

 1  my $attr = pack(
 2     "L L Q Q Q",
 3     $map_fd,
 4     0,
 5     unpack( "Q", pack( "P", $key ) ),
 6     defined($value)
 7     ? unpack( "Q", pack( "P", $value ) )
 8     : 0, 
 9     $flags,
10  );
11  my $result
12     = syscall(
13      Sys::Ebpf::Syscall::SYS_bpf(),
14      $cmd, $attr,
15     length($attr) );

しかしこのようなシンプルな実装にすると、アプリ側から利用するときに簡単に利用できず、難しいプログラミングを強いられてしまいます。これを解決するにはデータのシリアライズ・デシリアライズをきれいに行えるようにすることが必要と感じたので、eBPF Mapのkeyとvalueのスキーマを定義できるようにしました。こちらも実装の詳細は早坂さんの資料(後述)をご覧ください。

eBPF Loaderの完成

こうしてeBPF Loaderを実装することができました。実装したプログラムはSys::Ebpfというライブラリとして公開しています。CPANにも登録されています。

下記は「PerlでeBPF Loaderを自作する」の章で紹介した「何かのファイルが開かれたらカウンターが増加する」というサンプルプログラムを処理するPerlのプログラムです。

 1  my $kprobe_info= Sys::Ebpf::Link::Perf::Kprobe::attach_kprobe( $prog_fd, $kprobe_fn );
 2  print "Map FD: " . $map_kprobe_map->{map_fd} . "\n";
 3  print "Program FD: $prog_fd\n";
 4  sleep(1);
 5  print "Counting file opens. Press Ctrl+C to stop.\n";
 6  while (1) {
 7     my $key   = { kprobe_map_key => 1 };
 8     my $value = $map_kprobe_map->lookup($key);
 9     if ( defined $value ) {
10         printf "Files opened: %d\n", $value->{kprobe_map_value};
11     }
12     sleep(1);
13  }

デモ

会場では早坂さんのPCを使ったデモも行われました。1つは先ほど紹介した「何かのファイルが開かれたらカウンターが増加する」というサンプルプログラムを処理するPerlのプログラムです。もう1つは、XDPという高速パケット処理用の仕組みを利用した、8080番ポートへの着信パケット数を数え上げるプログラムです。

実際のデモの様子は文章で紹介することが難しいので、後日YAPCの公式サイトで公開される予定の動画でご確認ください。

8080番ポートへの着信パケット数を数え上げるプログラムのデモ

今後の展開

今回の実装では対応できなかったことがいくつかあります。1つはBTF(BPF Type Format)に対応することです。これによって今回実装したLoaderをより多くの場面で使えるようになります。また、より多くのフックポイントに対応することで、多様なバケット処理やオブザーバビリティに使えるようにしたいという話もありました。

おわりに

早坂さんからは、今回のeBPF Loaderの実装によって、カーネルのメトリック取得やパケット処理をPerlで書けることを学べたのがよかったという話がありました。本記事の冒頭でも紹介したように、さくらインターネットとBBSakura Networksの共同でパケット交換機(PGW-U)を開発しており、その中でeBPFを使用しています。実務で得た知見を社外のカンファレンスで発表できたのは筆者から見てもとても良いことだと感じました。もっと多くのエンジニアに社外のカンファレンスで発表してもらい、それをさくらのナレッジで紹介できればうれしいです。

最後に、早坂さんが公開している今回の発表資料を掲載します。また、YAPC公式サイトで動画が公開されましたら、そちらも掲載する予定です。

eBPF Japan Meetupのご紹介

日本国内におけるeBPFの技術コミュニティとして、eBPF Japanがあります。eBPFの内部実装や周辺技術、ユースケースなどについて、中立な立場で情報交換と議論を行っています。

2024年12月6日(金)には2回目の会合となるeBPF Japan Meetup #2をさくらインターネット東京支社にて開催します。さくらインターネットやBBSakura Networksを含め、各社のエンジニアがeBPFに関する発表と議論を行います。この記事を見てeBPFに興味を持たれた方はぜひご参加ください。