[Rust] AyaでeBPFプログラムをつくってみる
Introduction
eBPFは、Linuxカーネル対して動的に機能を拡張する技術で、
カーネルで独自のプログラムを安全に実行することができます。
これらのプログラムは任意のポイントに設定可能で、さまざまなタイミングで
実行できます。
eBPFは、カーネル再構築なしでカーネルの動作をカスタマイズすることができるため、
近年いろいろな用途に使われています。
今回はBCC/bpftoolで少しeBPFを使ってみた後、
Aya(eBPF用Rustフレームワーク)を使ってXDPのサンプルをつくってみます。
eBPF?
eBPFはExtended Berkeley Packet Filterの略で、
もともとはLinuxでデータパケットを効率的にフィルタリングするために
開発された技術ですが、現在はその機能が拡張されて、
カーネルのコード変更やモジュール追加なしで
独自のプログラムをカーネルで実行させることができる技術となっています。
プログラムをカーネルにロードしてさまざまなタイプのイベントにアタッチできます。
その結果、イベントが発生するたびにeBPFのプログラムが実行されることになります。
例えばプローブ(kprobe)という機能を使うと、Linuxカーネルの特定の関数の実行時に
ユーザーが定義したeBPFプログラムを実行するための
ポイントを提供してくれます。
これによりカーネル関数の呼び出し時や終了時をフックし、
処理を実行できます。
なお、対象はシステムコールだけでなくその他のカーネル関数でも可能です。
後述しますが、プローブ以外にもXDP(NICにパケットが到達したタイミング)など
いくつものフックポイントが使用可能です。
eBPFのユースケース
使い道はいろいろありますが、ソフトウェアルータやLoadbalancer、
DDoS Mitigationやトレース機能など幅広く使い道があります。
eBPFベースのL4ロードバランサーであるFacebook Katran、
Cloudflare社のGatebotなどがすでに実績をあげてます。
最近だとDocker/k8sなどのコンテナ環境でもよく使用されてます。
CiliumなどのeBPFを用いたプロダクトがあるように、
コンテナ同士のネットワーク管理やセキュリティの管理
モニタリング、リソース管理などに有用です。
アタッチポイント
eBPFは、プログラムをどこにアタッチするか選択することができます。
カーネルの任意の関数が実行されたタイミングやXDP,Traffic Controlなど
いろいろなポイントにセット可能です。
いくつか代表的なアタッチポイントを紹介します。
1. kprobes / kretprobes
- kprobes: カーネル関数が呼び出されるたびにeBPFを実行
- kretprobes: カーネル関数が終了する際にeBPFプログラムを実行
kprobeのプログラムはカーネルの(ほぼ)どこでもアタッチ可能。
kprobeを関数のエントリポイントから特定のオフセットに位置する
命令に対してアタッチとかもできる。
カーネルコンパイル時に関数がインライン化されたら、
kprobeがなくなるので注意。
2. uprobes / uretprobes
- uprobes: ユーザースペースの関数が呼び出されるたびにeBPFプログラムを実行
- uretprobes: ユーザースペースの関数が終了する際にeBPFプログラムを実行
3. tracepoints
- カーネルが提供するトレースポイントにアタッチし、特定のイベント発生時にeBPFプログラムを実行
kprobeとは違い、Tracepointは異なるカーネルバージョンでも安定している。
下記コマンドで見れる。
$ sudo cat /sys/kernel/tracing/available_events initcall:initcall_finish initcall:initcall_start initcall:initcall_level raw_syscalls:sys_exit raw_syscalls:sys_enter syscalls:sys_exit_rt_sigreturn syscalls:sys_enter_rt_sigreturn ・・・
4. XDP (eXpress Data Path):
ネットワークインターフェースにアタッチする。
パケットがカーネルスタックに行く前にeBPFプログラムを実行。
NICによってはXDPオフローディング機能をサポートしている。
この場合、受信したパケットを処理するeBPFはNIC上のプロセッサで動く。
そのため、NICから先に送られなかったパケットはホストのカーネルに認識されない。
すべての処理がNICまでで終わるので、ホストのCPUはまったく消費されない。
5. tc (Traffic Control):
ネットワークトラフィックの制御を行うために、
Linuxのトラフィックコントロール(tc)レイヤーにアタッチする。
XDPはingressのみだが、tcはegressも可能。
XDPとの違いはこのあたり。
ほかにもいろいろあるので、このへんとか確認してください。
Environments
- MacBook Pro (13-inch, M1, 2020)
- OS : MacOS 14.3.1
- Orcstack : 1.4.3(Ubuntu amd64)
BCCを使ってみる
BPF Compiler Collection(BCC)
ではeBPFを少し体験してみましょう。
BCCはeBPFプログラムを作成するためのフレームワークです。
PythonなどでeBPFを記述するためのライブラリや
サンプルプログラムが含まれています。
簡単に動作確認できるので、とりあえず動かしたい場合はとても楽です。
使い方
Orbstack上のUbuntuにセットアップしてみます。
% sudo apt update % sudo apt-get install bpfcc-tools % sudo apt-get install linux-headers-generic
下記コマンドを実行すれば、
execsnoop(全てのプロセスをトレースするツール)が起動します。
% sudo /usr/share/bcc/tools/execsnoop
BCCで自作eBPFプログラムを使う場合、Python APIを使用できます。
↓のプログラムは、cloneシステムコールが実行されるタイミングで
Hello, World!を出力するeBPFをアタッチします。
//test-ebpf.py from bcc import BPF # eBPFプログラムのソースコード bpf_source = """ int hello_world(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; } """ # BPFオブジェクト作成 b = BPF(text=bpf_source) # eBPFをアタッチ b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello_world") try: b.trace_print() except KeyboardInterrupt: pass
このプログラムを起動し、
cloneシステムコールが呼ばれるようなコマンド(unshare --fork、dockerなど)を実行すると、
hello_world関数が呼ばれます。
Use bpftool
bpftoolもBPFを管理するためのツールです。
これを使用するとCLIでBPFのロード/アンロード/デバッグなどを実行でき、
BPF map(BPF用KVS)も管理可能になります。
下記手順にてbpftoolをビルドして使ってみましょう。
% sudo apt install linux-tools-common linux-tools-generic % sudo apt-get install clang git libelf-dev make % sudo apt-get install pkg-config % sudo apt-get install llvm % git clone https://github.com/libbpf/bpftool.git % cd bpftool/ % git submodule init % git submodule update % cd src/ % make % sudo mv bpftool /usr/local/bin/
% bpftool --version bpftool v7.4.0 using libbpf v1.4 features: llvm, skeletons
CでサンプルのBPFモジュール(hello.bpf.c)を記述します。
パケットをうけとるとメッセージを表示するだけのシンプルなものです。
#include <linux/bpf.h> #include <bpf/bpf_helpers.h> SEC("xdp") int xdp_pass(struct xdp_md *ctx) { bpf_trace_printk("Packet received\n",0); return XDP_PASS; } char _license[] SEC("license") = "GPL";
コンパイルしてカーネルにロードしたら、
デバイス(eth0)にBPFモジュールをアタッチします。
% clang -O2 -target bpf -c hello.bpf.c -o hello.bpf.o % sudo bpftool prog load hello.bpf.o /sys/fs/bpf/hello % sudo bpftool net attach xdp id 132 dev eth0
モジュールが登録されてます。
$ sudo bpftool net list xdp: eth0(3) driver id 132 tc: flow_dissector: netfilter:
ipコマンドでeth0を確認しても、
BPFモジュールが設定されているのが確認できます。
% ip addr ・・・ 3: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp/id:132 qdisc noqueue state UP group default qlen 1000 link/ether da:7b:3e:eb:12:6e brd ff:ff:ff:ff:ff:ff link-netnsid 0 ・・・
確認がおわったのでデタッチします。
listでみても登録が消えてます。
% sudo bpftool net detach xdp dev eth0 % sudo bpftool net list xdp: tc: flow_dissector: netfilter:
が、カーネルにはloadされてるのでアンロードしておきます。
% sudo bpftool prog show name hello 132: xdp name hello tag xxxxxxxxx gpl loaded_at 2024-02-05T15:23:07+0900 uid 0 xlated 96B jited 160B memlock 4096B map_ids 32,33 btf_id 132 % sudo ls /sys/fs/bpf/ hello % sudo rm /sys/fs/bpf/hello % sudo bpftool prog show name hello
bfptoolはほかにもマップの操作やプログラムの詳細表示もできるので、
詳しくはドキュメントを確認してください。
eBPF programming with Aya
BPFの基本的な動作確認ができたので、AyaをつかってeBPFプログラムを書いてみます。
AyaはeBPFフレームワークで、
すべてのプログラムをRustで記述できます。
libbpfやbccに依存しません(システムコール実行用にlibcを使用)。
クロスコンパイル可能で、単一バイナリをいろいろな
ディストリ/カーネルにデプロイできます。
Ayaではカーネルとユーザスペース両方のeBPFプログラムにRustを使えます。
初心者にも簡単にコードを書き始められるようになっており、
Aya bookというドキュメントも公開されており、
(量は少ないですが)基本的な部分やサンプルコードが解説されているので助けになります。
Setup
ではAyaでeBPFプログラムを作成するためのセットアップをします。
必要なツール・ライブラリをインストールしましょう。
Rustをインストールしてcargoでbpf-linkerとcargo-generateをインストール。
% curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh % rustup toolchain install nightly --component rust-src % cargo install bpf-linker % cargo install cargo-generate
プロジェクト作成は新しいcargo-generateで実行可能なテンプレートを作成できます。
下記のようにtypeを指定すればそのまま雛形が生成されます。
(対話式で生成もできる)
% cargo generate --name my-xdp-sample -d program_type=xdp https://github.com/aya-rs/aya-template
XDPタイプのeBPFプログラムを生成しました。
下記3つのRustプロジェクトが生成されます。
my-xdp-sample/my-xdp-sample-ebpf
eBPF用プロジェクト。no_std。
my-xdp-sample/my-xdp-sample
ユーザスペース用プロジェクト。
eBPFのロード/アタッチ/デタッチなどを実行。
my-xdp-sample/my-xdp-sample-common
eBPF Mapデータを定義できる。
ここのデータはeBPF、ユーザースペース両方からアクセスできる。
ビルドは下記のようにxtaskで行います。
% cargo xtask build-ebpf
どのデバイスにアタッチするか確認(eth0)して、実行しましょう。
% ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 3: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether da:7b:3e:eb:12:6e brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 198.19.249.36/24 metric 100 brd 198.19.249.255 scope global dynamic eth0 ・・・
runを実行すると、ビルド、カーネルへのロード、デバイスへのアタッチが実行されます。
さきほどと同じく、パケットを受け取るとメッセージを表示します。
#実行 % RUST_LOG=info cargo xtask run -- --iface eth0
Creating a Simple Http parser
ではここでちょっとしたパケットパーサ的なものをつくってみます。
(TPCペイロードの中身を少し見るだけ。かなり雑)
もう少し具体的にいうと、XDPにアタッチして
受信パケットがIPv4&TCPかチェックしてTCPペイロードに
HTTPヘッダ("Host:")が存在するか確認してみます。
(普通の人はXDPでTCPペイロード内のHTTPヘッダ確認とか
たぶんやらない)
まず、必要となるcrateをaddします。
% cd ./my-xdp-sample-ebpf % cargo add network-types
network-typesは、ネットワークパケットのパースに必要なプロトコルの型を
定義しています。
これを使用することでXdpContextから取得した
IPヘッダやTCPヘッダをRustの構造体で使えます。
eBPFプログラム(my-xdp-sample-ebpf/src/main.rs)をざっと見てみましょう。
#![no_std] #![no_main] use core::mem; use network_types::{ eth::{EthHdr, EtherType}, ip::{Ipv4Hdr, IpProto}, tcp::TcpHdr, udp::UdpHdr, }; use aya_bpf::{bindings::xdp_action, macros::xdp, programs::XdpContext}; use aya_log_ebpf::{info, warn}; use aya_bpf::helpers::bpf_csum_diff; #[xdp] pub fn my_xdp_sample(ctx: XdpContext) -> u32 { info!(&ctx, "======== my_xdp_sample start ========"); match try_my_xdp_sample(ctx) { Ok(ret) => ret, Err(_) => xdp_action::XDP_PASS, } } fn try_my_xdp_sample(ctx: XdpContext) -> Result<u32, ()> { // イーサネットヘッダ取得&IPv4を使用しているかどうかチェック let ethhdr: *const EthHdr = unsafe { ptr_at(&ctx, 0)? }; match unsafe { *ethhdr }.ether_type { EtherType::Ipv4 => {} _ => return Ok(xdp_action::XDP_PASS), } // パケット全体の長さヘッダ長を取得 let ipv4hdr: *const Ipv4Hdr = unsafe { ptr_at(&ctx, EthHdr::LEN)? }; let tot_len = u16::from_be(unsafe { *ipv4hdr }.tot_len); let headers_total = EthHdr::LEN as u16 + Ipv4Hdr::LEN as u16 + TcpHdr::LEN as u16; if tot_len < headers_total { info!(&ctx, "tot_len is less than headers_total"); return Ok(xdp_action::XDP_PASS); } //TCPペイロードのサイズ計算 let payload_size = (tot_len - headers_total) as usize; //TCPプロトコルの場合のみ処理継続 match unsafe { *ipv4hdr }.proto { IpProto::Tcp => { // TCPヘッダ取得 let tcphdr: *const TcpHdr = unsafe { ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN) }?; let payload_offset = headers_total as usize; // パケットの全長が64バイトより大きい場合、HTTPヘッダの存在をチェックします。 if tot_len > 64 { // "Host:" HTTPヘッダがペイロードに存在するかどうか let has_host_header = unsafe { check_http_header(&ctx, headers_total, payload_size, "Host:") }?; if has_host_header { info!(&ctx, "HTTP 'Host:' header found"); } else { info!(&ctx, "HTTP 'Host:' header not found"); } } } // TCP以外のプロトコルの場合エラー _ => { return Err(()); }, } // パケットをパス(処理続行) Ok(xdp_action::XDP_PASS) } #[panic_handler] fn panic(_info: &core::panic::PanicInfo) -> ! { unsafe { core::hint::unreachable_unchecked() } }
no_std属性は、標準ライブラリをリンクせずにコンパイルします。
なので、Stringやprintln!マクロも使えません。
※eBPFプログラムがカーネルスペースで動作するため
また、my_xdp_sample関数は、XDPプログラムのエントリポイントとなります。
そして、#[xdp]マクロによって、この関数がXDPプログラムとして登録されます。
次にptr_at関数を見てみます。
#[inline(always)] unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> { let start = ctx.data(); let end = ctx.data_end(); let len = mem::size_of::<T>(); // 指定された型がパケットのデータ領域を超えていないか確認 if start + offset + len > end { return Err(()); } Ok((start + offset) as *const T) }
ここではrawポインタを使ってポインタ演算をしています。
なので、unsafe関数です。
メモリアドレスにオフセットを足して、任意の型のデータ(T)を指し示す
新しいポインタを取得しています。
ネットワークパケットでは連続したメモリブロック内で決まった構造しているので、
下記のようにオフセット(イーサヘッダ長+IPv4ヘッダ長)を指定して
rawポインタを取得することでTcpHdr(TCPヘッダ)にキャストすることができます。
let tcphdr: *const TcpHdr = unsafe { ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN) }?;
そして、check_http_header関数は指定したHTTPヘッダが
TCPペイロードに存在するかチェックします。
#[inline(always)] unsafe fn check_http_header(ctx: &XdpContext, offset: usize, payload_size: usize, header: &str) -> Result<bool, ()> { let start = ctx.data(); let end = ctx.data_end(); let header_len = header.len(); let header_bytes = header.as_bytes(); // チェックするペイロードの長さを64バイトに制限 let len = 64; // ペイロードがパケットのデータ領域を超えていないか確認 if start + len + offset > end { return Err(()); } for i in 0..len { let ptr = (start + offset + i) as *const u8; if (0..header_len).all(|j| { if ptr.add(j) >= end as *const u8 { return false; } *ptr.add(j) == header_bytes[j] }) { return Ok(true); } } Ok(false) }
ビルドして実行してみましょう。
# build % cd /path/your/my-xdp-sample % cargo xtask build-ebpf # run % RUST_LOG=info cargo xtask run
上記eBPFをアタッチした状態で
nodeとかpythonとか使って適当に↓みたいなhttpサーバを起動します。
const http = require('http'); const server = http.createServer((req, res) => { if (req.url === '/foo') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } }); const port = 3000; server.listen(port, () => { console.log(`Server running at http://localhost:${port}/`); });
その後curlとかでHTTPサーバにリクエストを送ります。
XDPパケットにHostヘッダがあるときだけ、
「HTTP 'Host:' header found」
というメッセージがeBPFのコンソールログに表示されます。
Summary
今回はeBPF概要の説明からはじまり、
BCC、bpftoolsを使ってeBPFの動作を見てみました。
そして最後にAyaを使ったXDPプログラムを作成してみました。
eBPFは今後さらなる発展が予想されているプラットフォームなので、
注目していきたいところです。
References
- Aya
- Aya Book
- eBPF enhanced HTTP observability - L7 metrics and tracing
- L7 Tracing with eBPF: HTTP and Beyond via Socket Filters and Syscall Tracepoints
- eBPF の tc を使ってパケットを触ってみる開発入門資料
- eBPF でパケットを触ってみよう
- Cilium
- cilium : progtypes
- Demystifying eBPF Tracing: A Beginner's Guide to Performance Optimization
- libbpf-rsを使ったRustとeBPFプログラミング
- eBPF本を読んだのでXDPとRust (Aya) に入門してPingに応答してみた