libbpf-rsを使ったRustとeBPFプログラミング

2021-12-27 / [linux] [ebpf]

この記事は、Qiitaの記事とのクロスポストです。

はじめに

この記事ではeBPFを活用してLinuxカーネルにフック用プログラムを注入することにより、ネットワークパケット処理を拡張する例を示します。 その実装にあたり、Rustとlibbpfの統合を行うlibbpf-rsを使った開発体験を記したいと思います。

TL;DR

libbpf-rsによってRustとeBPFを組み合わせたプログラムのコンパイルやロード処理の手間は省けるようになります。実際、システムコールの呼び出し部分はほとんど意識する必要がありませんでした。 一方でeBPFプログラミングで特に苦労したのは以下の点でした。

  • デバッグとテスト

つまりeBPFプログラミングにおいて周辺的な問題がツールによって解決されていき、よりプログラムの機能そのものの問題に時間を割くことができたと言えそうです。 とはいえスムーズに開発するためにはやはりテストフレームワークやテストハーネスが欲しいと感じるようになったので、そのあたりは更に調査が必要です。

eBPF

カーネル内部のイベントをトリガとして呼び出されるプログラムをユーザ空間から注入できる機能です。 専用のバイトコードをカーネル内の仮想マシンに解釈させることで命令を実行します。 ソース言語をこのeBPF専用バイトコードとしてコンパイルしてカーネルに組み込むことができる、という拡張性の高さからネットワーキングやセキュリティの分野で活用されています。

eBPFの拡張性を活用したCiliumがGKEの新たなコンテナネットワーキングの実装に選ばれた実績を見ると、それなりに注目度が高いことが伺えます。

コンテナのセキュリティと可視性が強化された GKE Dataplane V2 が登場

eBPFプログラミングの登場人物

eBPFプログラムはカーネル空間側で実行されますが、実用上はそのeBPFプログラムをカーネルにロードし、実行結果を取り出すためにユーザ空間側で動作するプログラムも必要となります。 カーネル空間、ユーザ空間2種類のプログラムをビルドして配布しつつ、それぞれのプログラムごとにビルドツールが分かれがちであるため、ツールのつなぎをMakefileでなんとかすることが多いです。

  • clang/llvm
    • カーネル空間のコードを(制限された)C言語で記述しeBPFバイトコードへとコンパイルするために必要
  • libbpfとその依存関係
    • カーネルが提供するeBPFシステムコールをラップするAPIやユーティリティを提供するライブラリ
  • eBPFプログラム
    • カーネル空間側で動作するバイトコード
  • アプリケーションプログラム (ユーザ側)
    • ユーザ空間側で動作し、eBPFプログラムをロードしたり実行結果を取り出したりするアプリケーション

今回はアプリケーションプログラム側としてRustを選択し、libbpf-rs crateが提供するワークフローによって少しでも開発の手間をなくせるか実際に試してみます。

eBPFプログラミングの流れ

eBPFプログラムを開発する場合、一般的には下記のようなステップが必要になります。

  1. カーネルで動作するeBPF用のC言語のプログラムを書く
  2. clang/llvmでコンパイルしeBPFのバイトコードを生成する(ELF形式)
  3. eBPFバイトコードをカーネルにロードするためのスケルトンを生成する
  4. スケルトンをincludeしユーザ空間側のプログラムを書く
  5. 4をコンパイルし2の結果と合わせて配布・実行する

Cで書く場合

たとえばCを基礎に置いたアプリケーション開発を行う場合は下記のようにclangbpftoolなどを組み合わせます。

  1. カーネルで動作するeBPF用のC言語のプログラムを書く
  2. clang/llvmでコンパイルしeBPFのバイトコードを生成する(ELF形式)
    • clang -target bpf -c something.bpf -o something.o
  3. eBPFバイトコードをカーネルにロードするためのスケルトンを生成する
    • bpftool gen skelton something.o > something.h
  4. スケルトンをincludeしユーザ空間側のプログラムを書く
    • スケルトンはlibbpfのヘッダに依存しているため依存関係として別途インストールが必要
  5. 4をコンパイルし2の結果と合わせて配布・実行する

このようにツールを組み合わせながら自前でMakefileなどを書いて、それぞれのステップで正しく成果物が作られるようにする必要があります。

Rustで書く場合

RustでeBPFのバインディングを利用する場合のcrateは、

などがあります。 今回はlinuxのソースツリーでもメンテされているlibbpfを使ったバインディングである前者のlibbpf-rsを使ってみます。

libbpf-rsを使ってRustベースの開発を行う場合は以下のようになります。

  1. カーネルで動作するeBPF用のC言語のプログラムを書く
  2. eBPFバイトコードをカーネルにロードするユーザ空間側のプログラムを Rust で書く
  3. cargo buildで1, 2の成果物がバンドルされるのでそのまま配布・実行する
    • Rustのcrateがスケルトンを生成してくれるためbpftoolは不要になる

libbpf-rsというライブラリが提供する道具立てのおかげで 少し開発ステップが短縮できる というのがポイントです。 また、ビルド全体のライフサイクルがcargoに包括されるため他のRustのcrateとほぼ同じ道具立てで開発を進めることができます。

(参考) なお、GoのeBPFプログラミングでも概ねCと同様のツールチェインになっており、コマンド実行でスケルトンを生成するようです。 ciliumのeBPFプログラムコンパイル

libbpf-rs

libbpf-rs

RustのプログラムからeBPFのシステムコールおよびeBPF関連オブジェクトを取り扱いやすくしてくれるライブラリです。 実体としてはlibbpfのRust用ラッパーですが、ただ関数シグニチャをRust側にポーティングしているだけではなく、Rustプログラミングと親和性が高くなるような仕掛けが追加されています。

eBPFのプログラムの周辺操作を行うスケルトンを自動生成する

libbpf-rsとそれに付随するツールlibbpf-cargoが提供するAPIを使うことで、ユーザ空間側のRustプログラムからeBPFプログラムを扱いやすくなります。 libbpf-cargoはclang/llvmで出力したeBPFのバイトコードをRust上から操作するための構造体やメソッドを定義したRustのソースコードをスケルトンとして生成します。

// THIS FILE IS AUTOGENERATED BY CARGO-LIBBPF-GEN!
<snip>
pub struct OpenUdpRedirectProgs<'a> {
    inner: &'a libbpf_rs::OpenObject,
}

さらにこのスケルトンからeBPFのシステムコールに直接eBPFのバイトコードを入力できるように、バイトコードそのものをスケルトンコードの定数として埋め込んでいます。(これもGo版eBPFのスケルトン実装と同様です)

const DATA: &[u8] = &[
    127, 69, 76, 70, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 247, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 248, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 64,
<snip>

libbpfは静的リンクする

これはlibbpf-rsそのものの効用というよりは依存するlibbpf-sysのおかげですが、Buildingで説明されている通り、libbpfのライブラリがcargo buildの成果物に静的にリンクされるため実行環境でlibbpfをインストールしておく必要がありません。

libbpf-rsを使ってみる

実際にlibbpf-rsを使ってみます。 コード全体は xdp-sandbox-libbpf-rs にホストしてあります。

課題設定

サーバ上のUDP 8000ポートに対する通信をUDP 8001ポートへとリダイレクトします。 これをeBPFを使ったパケット処理の拡張機能であるXDP(eXpress Data Path)で実装します。

とても現実にありえる正気のユースケースとは思えませんが、こうしたパケットのヘッダ書き換えが発展するともっといい感じのデータパスがLinuxカーネルで作れます。たぶん。

eBPFプログラミング環境のセットアップ

CをベースにeBPFプログラムを開発するためのセットアップと手順については下記が詳しいです。 Building BPF applications with libbpf-bootstrap

今回はこれを参考にしつつlibbpf-rsを使ったRustのプロジェクトでのセットアップを確認します。

Cargo.tomlの追記

依存関係としてlibbpf-rsを追加しておきます。cargo buildでeBPFプログラムのコンパイルも起動するためには、ビルドスクリプトの依存関係としてlibbpf-cargoも追加しておきます。

[dependencies]
libbpf-rs = "0.14.0"
libc = "0.2"

[build-dependencies]
libbpf-cargo = "0.9"

eBPFのコード置き場を作る

eBPFプログラムとしてカーネル空間で動作するコードは多くの場合Cで記述します。(正確には制限のついたC)

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp")
int xdp_pass(struct xdp_md *ctx)
{
	return XDP_PASS;
}

char __license[] SEC("license") = "GPL";

このようなコードをcargo経由で呼び出すclangがコンパイルします。 eBPFプログラムのコードの置き場となるディレクトリを作っておきます。

mkdir -p src/bpf/c/

libbpf-rs公式だとsrc/bpf/にeBPF用のCのコードを配置することが例として紹介されますが、個人的にはRustのプロジェクトの中にCのコードが混ざることを明示したいので下記のようなディレクトリを用意しています。

それを元に作られるRustのスケルトンはsrc/bpf/に出力されるように、後でビルドスクリプトを調整します。

生成されるスケルトンコードはgit管理しない

.gitignore に下記を追記しておきます。

src/bpf/*.rs

eBPF用のCのコードからスケルトンコードは導出できるため、特にgit管理する必要はありません。 スケルトンコードを後から手動で修正することもありません。

libbpf-cargoを使ったビルドスクリプトを用意する

libbpf-cargoはeBPFプログラムのコンパイルやRustへのインテグレーションをおこなうワークフローを自動化するためのcargo用ライブラリです。

Subcommands build

上記にあるように

cargo libbpf make

などでcargoコマンド経由でclangを呼び出すことができます。 しかし普通にRustプログラムを書いていてcargo libbpf makeも忘れずに実行するのは普通にめんどくさいです。

libbpf-cargoには幸いにしてビルドスクリプトから呼び出すことができるAPIがあるのでこれを使ってcargo build時に合わせてeBPFプログラムもビルドできるようにします。

crateのdocにも下記の説明があるので、むしろそちらが正攻法のようです。

The build script interface is recommended over the cargo subcommand interface

ビルドスクリプトを用意する

build.rs

なんならここが今回一番Rustのコードを書いたかもしれません(それでいいのか)。

やっていることはシンプルです。

  1. scan_input: src/bpf/c/ 配下を走査して .bpf.c の拡張子を持つファイルをeBPFのソースコードとしてピックアップする
  2. SkeletonBuilder: libbpf-cargoのAPIにeBPFソースコードを渡してスケルトンを生成する
  3. gen_mods: スケルトンコードをモジュールとしてcrate内に公開するためのmod.rsを自動生成する

一度書いてしまうと後はeBPFのソースコードが増えたところで変わらないので、割と作りっぱなしになります。

カーネル空間: eBPFプログラムを書く

udp_redirect.bpf.c

今回はUDPヘッダの宛先ポートを書き換える処理が必要なのですが、実行が簡単などの理由からXDPというeBPFプログラムの種別で実装します。 XDPではeBPFプログラムをカーネルへロードして、特定のネットワークデバイスにアタッチした場合、ネットワークデバイスがバケットを受信したタイミングでeBPFプログラムを呼び出します。 eBPFプログラムはパケットの解析や編集などを行った後に、次の処理を指示する返却コードを返します。

最も単純なXDPのeBPFプログラムだと下記のようなコードになります。

int xdp_pass(struct xdp_md *ctx)
{
  // XDP_PASSは受信したパケットをカーネルのネットワークスタックでそのまま処理するように依頼する
	return XDP_PASS;
}

上記ではxdp_mdとしてデバイスが受信したパケットの内容を含むメタデータを受け取っています。 このeBPFコードを土台にしてxdp_mdの内容を参照・更新することで、パケットの解析・編集処理を肉付けしていきます。

eBPFプログラムでパケット処理を行うXDPと呼ばれる技術のチュートリアルはxdp-tutorialが非常に詳しく、またidiomaticな作法が多数掲載されています。今回もこれを参考にしつつ実装してみました。 コードのほとんどがヘッダの解析とデータのアクセス範囲チェックです。

今回の課題で実現したいUDPの宛先ポートの書き換えを行っているのは下記です。

  // 宛先ポートの書き換え
  if (udp->dest == bpf_htons(TARGET_PORT))
  {
    udp->dest = bpf_htons(REDIRECT_PORT);
  }

カーネル内で確保されているデータの書き換えをカーネルモジュールなしで実現することができるというeBPFの恩恵を端的に示しています。

ユーザ空間: eBPFプログラムをロードする

main.rs

    let skel_builder = UdpRedirectSkelBuilder::default();
    let open_skel = skel_builder.open()?;
    let mut skel = open_skel.load()?;
    let link = skel.progs_mut().xdp_main().attach_xdp(1)?;
    skel.links = UdpRedirectLinks {
        xdp_main: Some(link),
    };

正直、スケルトンコードをmain関数から呼び出すだけなので、ほぼ定型文で面白みのあるコードにはなりませんでした。

しかしユースケースが複雑になるにつれ、ユーザ空間側のプログラムもより複雑になります。 今回はあまりに簡単な例だったのでこれだけで終わっていますが、ダイナミックにXDPの処理内容を変更するためにeBPFプログラムとデータをやりとりするようになると更に多くのコードがユーザ空間側にも必要になってくるでしょう。

動作確認: XDPアタッチ前

サーバ上のUDP 8000ポートに対する通信をUDP 8001ポートへとリダイレクトします。

この課題がeBPFプログラムの実装で解決できたかを確認していきます。

確認環境

$ cat /etc/redhat-release 
CentOS Linux release 8.4.2105
$ uname -a
Linux imamura-test 4.18.0-305.25.1.el8_4.x86_64 #1 SMP Wed Nov 3 10:29:07 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

まずはXDPのフィルタを設定する前の動作を確認します。

tcpdumpでループバックデバイスをキャプチャ

$ sudo tcpdump -i lo -nn
dropped privs to tcpdump
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes

UDPサーバを8000ポートで起動

$ nc -4 -l -u 8000

UDPクライアントからデータ送信

1 と改行タイプします。

$ nc -4u 127.0.0.1 8000 
1

パケットキャプチャの出力を確認する

宛先8000番でUDPのデータグラムが確認できます。

14:27:16.457557 IP 127.0.0.1.44229 > 127.0.0.1.8000: UDP, length 2

動作確認: XDPアタッチ後

XDPアタッチ

Rustで書いたユーザ空間側のプログラムを実行することでeBPFプログラムをロードしてXDPとしてループバックデバイスにアタッチします。

$ sudo ./xdp-redirect

これでUDPの宛先ポートが8000から8001に書き換わるはずです。

UDPクライアントからデータ送信

$ nc -4u 127.0.0.1 8000 
2

パケットキャプチャの出力を確認する

予想通り宛先ポートが8001になりました。

14:47:06.481427 IP 127.0.0.1.37685 > 127.0.0.1.8001: UDP, length 2
14:47:06.481454 IP 127.0.0.1 > 127.0.0.1: ICMP 127.0.0.1 udp port 8001 unreachable, length 38

8001ポートではlistenしているポートもないためunreachable扱いになっています。

やってみて解った課題

テスト

eBPFプログラムをロードする際に、危険な操作がないかどうかカーネル側でもチェックをしてくれるverifierという機構があります。 このverifierの検査が通りロードに成功すれば、ひとまず安全なコードであるとは言えそうなのですが、業務として正しく動作するかは相変わらずテストする必要があります。 特にeBPFでカバーする領域が増えれば増えるほどテストは必要になるし、カーネルで動作させたい程度には重要なプログラムではあるはずなので、十分にテストしておきたくなるのが人情です。 が、現状では確立されたテストフレームワークは無さそうなので自前で道具を作っていくことになるようです。

Ciliumではユニットテスト環境をコンテナで作ってモックをはさみながら テストしているようです

そうは言っても普通にセットアップが面倒くさい

libbpfをビルドするために必要なlibzlibelfは開発環境に入れておく必要はあるし、cargoのビルドスクリプトも最初だけとはいえやっぱり手書きしているわけです。 Cargo.tomlにdependenciesを追加したらすぐに開発を始められるというものではないのが難しいところです。

しかしpure RustをうたったeBPFライブラリ Aya というプロジェクトがあるので、もしかしたらそちらではいくらかセットアップが楽になっているのかもしれない、という期待もあります。 ちょうどタイムリーに記事が出た RustでeBPFを操れるayaを触ってみた を拝見するとプロジェクトテンプレートを使って足場を作るようです。 これがセットアップの手間を軽減しているかというとなんとも言えませんが。

新しめのカーネルじゃないと厳しい

eBPFの機能はLinuxカーネルのバージョンアップとともに徐々に追加されているため、使っているディストリビューションの最新バージョンのカーネルじゃないと使いたい機能が入っていなかったりします。

BPF Features by Linux Kernel Version を見るだけではなく、各ディストリビューションのドキュメントも探してみる必要があります。読んでも自明じゃないものもあったりするので難しいです。

今後やってみたいこと

今回はパケットのヘッダをいじってそのまま下流に丸投げする無責任なXDPプログラムでしたが、今後はもう少しユーザ空間との連携をeBPF Mapを活用した実装を試してみたいところです。