eBPF本で例として扱われていたPingへ応答するeBPFプログラムを、より低いレイヤ (XDP) でRust (Aya) で開発してみました。
はじめに
eBPF本の和訳 (入門 eBPF) が年末に発売されたので早速読んでみました。筆者はeBPF/XDPはおろかネットワークプログラミングやカーネル周りにもあまり精通していませんが、興味のあったネットワークパケットの高速処理の仕組みが1章まるごと割かれて説明されており、入門としてとても満足な内容でした。
原著はIsovalentのサイトで (個人情報と引き換えに) 無料で手に入ります。
eBPFは、カーネルのソースコードに変更を加えたりカーネルモジュールをロードすることなく、自作のプログラムでカーネルの機能を拡張できる技術です。
eBPF本では、eBPFの利用シーンとしてネットワーク処理、オブザーバビリティ、セキュリティが主に挙げられており、ネットワーク処理の例の1つとしてPing (ICMP Echo) に応答するeBPFプログラムが紹介されていました。本記事は同じ題材について下記のアレンジを加えて入門してみます。
eBPF本 | 本記事 | |
---|---|---|
eBPFのプログラムタイプ | tc | xdp |
プログラミング言語 | Python (BCC) + C言語 | Rust (Aya) |
アレンジする理由はつまるところ筆者の興味ですが、詳細を説明していきます。
Express Data Path (XDP)
プログラムタイプをXDPにしてみます。Express Data Path (XDP) とは、OSのネットワーキングスタックのほとんどをバイパスして高速にパケット処理ができるデータの経路のことです。
下図はLinuxカーネルのネットワーキングスタックの模式図です (Wikipediaより)。図の最下部の「XDP_TX」の経路をみると、XDPのみで処理を行うとフロー上の大部分の処理をすっとばせることがわかり、とてもロマンを感じます。(今回のPingの例では、実際にパフォーマンスの差は感じられないだろうとも思います)
eBPF本の例では、Pingに応答するeBPFプログラムをtc (traffic control) のレイヤで動作させていました。下の図はeBPF Summit 2023のeBayの講演のスライドの1つですが、XDPはtcより下のレイヤに位置することがわかります。
eBayの講演やeBPFのプログラム種別についてのCiliumのドキュメントによると、tcのプログラムではパケットがsk_buff
というメタデータ付きの構造体として利用可能になります。XDPのプログラムではsk_buff
のメタデータが利用できない代わりに、sk_buff
のバッファの確保やメタデータのパース処理を回避できるようです。
Rust (Aya)
プログラミング言語はRustを使ってみます。eBPF本でRustでもeBPFプログラムが書けることが紹介されていた一方で、コード例も筆者のRustの経験もなく、なんとなく挑戦したかったためです。
RustでeBPFプログラムを開発するためのライブラリとして、libbpf-rs, Redbpf, Aya, Rust-bccが紹介されていましたが、eBPF本の書き味が高評価に見えたAyaを使ってみます。(名前が日本語っぽい響きですがどうなんだろうか)
チュートリアルがとても丁寧な一方でドキュメントの網羅性はあまりなく、本記事で行った開発もところどころソースコードを読みながら理解していく必要がありました。
「Pingを返す」とは
さっそくeBPF本のサンプルコード (chapter8/network.bpf.cのtc_pingpong()
) を読み解いて、やるべきことを整理してみます。下記はサンプルコードの抜粋に筆者が読み取った処理を日本語でコメントしたものです。
int tc_pingpong(struct __sk_buff *skb) {
bpf_trace_printk("[tc] ingress got packet");
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
if (!is_icmp_ping_request(data, data_end)) {
bpf_trace_printk("[tc] ingress not a ping request");
return TC_ACT_OK;
}
// ^^^ (処理1) ICMPのEcho request以外の場合は、eBPFではなにもせず通過させる
struct iphdr *iph = data + sizeof(struct ethhdr);
struct icmphdr *icmp = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
bpf_trace_printk("[tc] ICMP request for %x type %x\n", iph->daddr,
icmp->type);
// ^^^ (処理2) IPやICMPのヘッダをパースして、内容をログ出力する
swap_mac_addresses(skb);
swap_ip_addresses(skb);
// ^^^ (処理3) IPとMACのsource/destinationアドレスを入れ替える (=返信する)
update_icmp_type(skb, 8, 0);
// ^^^ (処理4) ICMPヘッダのtypeフィールドをEchoからEcho replyに書き換える
// Redirecting the modified skb on the same interface to be transmitted
// again
bpf_clone_redirect(skb, skb->ifindex, 0);
// ^^^ (処理5) Echo requestを受信したインターフェースからreplyを送信する
// We modified the packet and redirected a clone of it, so drop this one
return TC_ACT_SHOT;
}
読み取った処理を再掲します。
- (処理1) ICMPのEcho request以外の場合は、eBPFではなにもせず通過させる
- (処理2) IPやICMPのヘッダをパースして、内容をログ出力する
- (処理3) IPとMACのsource/destinationアドレスを入れ替える
- (処理4) ICMPヘッダのtypeフィールドをEchoからEcho replyに書き換える
- (処理5) Echo requestを受信したインターフェースからreplyを送信する
このプログラムはtc用のプログラムなのでパケットがstruct __sk_buff
の形をしていてそのままxdpに書き直すことは難しそうです。上記で分解した5つの処理を、自作のXDPのプログラムへ順番に実装していくことにします。
RustとAyaの環境構築
マシンとOS
Amazon Linux 2023のEC2インスタンス (t3.medium) で開発を行いました。
Rustupのインストール
The Bookのとおりにやりました。
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
Ayaの依存ライブラリのインストール
ところどころライブラリが足りず、gccとopenssl-develをdnfでインストールしてからThe Aya Bookの下記の手順を実施することで環境構築できました。
$ rustup install stable
$ rustup toolchain install nightly --component rust-src
$ cargo install bpf-linker
$ cargo install cargo-generate
eBPFプログラムを開発する
プロジェクトのひな形の作成
まずはAyaの新規プロジェクトを作ります。プログラムの種別はXDPを選択します。
$ cargo generate --name hello-aya -d program_type=xdp https://github.com/aya-rs/aya-template
下記のcrateを含んだCargoのworkspaceが生成されました。(GitHubのコミットはこちら)
- hello-aya: ユーザスペースのプログラム
- hello-aya-ebpf: eBPFのプログラム
- hello-aya-common: 両者に共通のライブラリ
- xtask: ビルドスクリプト
eBPFのプログラム (hello-aya-ebpf/src/main.rs) は下記のようになっており、パケットを受信するごとにreceived a packet
とロギングすることがわかります。
#![no_std]
#![no_main]
use aya_bpf::{bindings::xdp_action, macros::xdp, programs::XdpContext};
use aya_log_ebpf::info;
#[xdp]
pub fn hello_aya(ctx: XdpContext) -> u32 {
match try_hello_aya(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
fn try_hello_aya(ctx: XdpContext) -> Result<u32, u32> {
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
ユーザスペースのプログラム (hello-aya/src/main.rs) は下記のようになっており、コンパイル済みのeBPFプログラムを読み込んでXDPプログラムとしてネットワークインターフェースにアタッチし、Ctrl-Cが押されるまで実行するものです。
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
// (中略)
#[cfg(debug_assertions)]
let mut bpf = Bpf::load(include_bytes_aligned!("../../target/bpfel-unknown-none/debug/hello-aya"))?;
#[cfg(not(debug_assertions))]
let mut bpf = Bpf::load(include_bytes_aligned!("../../target/bpfel-unknown-none/release/hello-aya"))?;
if let Err(e) = BpfLogger::init(&mut bpf) {
// This can happen if you remove all log statements from your eBPF program.
warn!("failed to initialize eBPF logger: {}", e);
}
let program: &mut Xdp = bpf.program_mut("hello_aya").unwrap().try_into()?;
program.load()?;
program.attach(&opt.iface, XdpFlags::default())
.context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
さっそく実行してみます。
$ RUST_LOG=info cargo xtask run -- --iface ens5
Error: failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE
エラーで落ちました。エラーメッセージはSKB mode を指定することを提案していますが、前述のeBayの講演によるとSKB mode (XDPのgeneric mode) はsk_buff
を一度パースしてからXDPのデータ形式にさらに変換するためオーバーヘッドが比較的大きいそうです。したがってSKB modeには変更せず、エラーの原因を探ります。
カーネルから下記のエラーログが出ており、MTUが大きすぎることがわかりました。
kernel: ena 0000:00:05.0 ens5: Failed to set xdp program, the current MTU (9001) is larger than the maximum allowed MTU (3498) while xdp is on
素直にMTUを小さくします。
$ sudo ip link set ens5 mtu 3498
再度実行すると今度は下記のエラーが出ました。
kernel: ena 0000:00:05.0 ens5: Failed to set xdp program, the Rx/Tx channel count should be at most half of the maximum allowed channel count. The current queue count (2), the maximal queue count (2)
素直にチャネル数を最大値の半分に設定します。
$ ethtool -l ens5
Channel parameters for ens5:
Pre-set maximums:
RX: n/a
TX: n/a
Other: n/a
Combined: 2
Current hardware settings:
RX: n/a
TX: n/a
Other: n/a
Combined: 2
$ sudo ethtool -L ens5 combined 1
再度プログラムを実行すると、大量のreceived a packet
のログが確認でき、動作できていることがわかります。
$ RUST_LOG=info cargo xtask run -- --iface ens5
(略)
[INFO hello_aya] Waiting for Ctrl-C...
[INFO hello_aya] received a packet
[INFO hello_aya] received a packet
(略)
先人の下記のサイトにXDPをEC2で実行するときの注意点がいい感じにまとまっています。エラーに対応する上で大変参考になりました。
https://zenn.dev/suicide_student/articles/2755385740fb2b
Pingをパースしてみる
The Aya Bookにパケットをパースする例がありました。これに倣ってPingのパケット (ICMP Echo) を判別する処理を盛り込んでいきます。
- (処理1) ICMPのEcho request以外の場合は、eBPFではなにもせず通過させる
パケットのヘッダのデータ構造は、network_typesクレートに定義されている構造体を使ってパースします。
The Aya Bookで紹介されている下記のptr_at
関数を使うと、バッファオーバーラン防止のチェックをしつつ任意の構造体へのポインタを得ることができます。(チェックをしないとBPF検証器を通りません)
#[inline(always)]
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)
}
Ethernet、IPv4、ICMPのヘッダをそれぞれパースし、ICMP Echo以外の場合でearly returnしていったところ、eBPFプログラムは下記のようになりました。(GitHubのコミットはこちら)
fn try_hello_aya(ctx: XdpContext) -> Result<u32, ()> {
let mut cursor = 0usize;
// eth -> ip
let ethhdr: *const EthHdr = ptr_at(&ctx, cursor)?;
cursor += EthHdr::LEN;
if unsafe {(*ethhdr).ether_type} != EtherType::Ipv4 {
return Ok(xdp_action::XDP_PASS)
}
// ip -> icmp
let iphdr: *const Ipv4Hdr = ptr_at(&ctx, cursor)?;
cursor += Ipv4Hdr::LEN;
if unsafe {(*iphdr).proto} != IpProto::Icmp {
return Ok(xdp_action::XDP_PASS)
}
// icmp -> echo request
let icmphdr: *const IcmpHdr = ptr_at(&ctx, cursor)?;
cursor += IcmpHdr::LEN;
if unsafe {(*icmphdr).type_} != ICMP_TYPE_ECHO_REQUEST {
return Ok(xdp_action::XDP_PASS)
}
info!(&ctx, "received an icmp echo request");
Ok(xdp_action::XDP_PASS)
}
このプログラムを実行して、別インスタンスからPingを送ると、下記のようにPingの受信ログが出ました。
$ RUST_LOG=info cargo xtask run -- --iface ens5
(略)
[INFO hello_aya] Waiting for Ctrl-C...
[INFO hello_aya] received an icmp echo request
[INFO hello_aya] received an icmp echo request
[INFO hello_aya] received an icmp echo request
(略)
次に、受信した情報についてもう少し詳しい内容をロギングしてみます。
- (処理2) IPやICMPのヘッダをパースして、内容をログ出力する
マルチバイトの整数が多く出現するので、演算する前にu32::from_be
などを用いてnative endiannessに変換する必要がありました。
// info!(&ctx, "received an icmp echo request");
// の部分を下記に変更
let icmp_echo_req = unsafe { (*icmphdr).un.echo };
// log packet contents
let orig_ip_src_addr = unsafe { (*iphdr).src_addr };
let orig_ip_src_addr_native_endian = u32::from_be(orig_ip_src_addr);
info!(
&ctx,
"received an icmp echo request src={}.{}.{}.{} ihl={} len={} ttl={} id={} seq={}",
(orig_ip_src_addr_native_endian >> 24) & 0xff,
(orig_ip_src_addr_native_endian >> 16) & 0xff,
(orig_ip_src_addr_native_endian >> 8) & 0xff,
(orig_ip_src_addr_native_endian >> 0) & 0xff,
unsafe { (*iphdr).ihl() },
u16::from_be(unsafe { (*iphdr).tot_len }),
unsafe { (*iphdr).ttl },
u16::from_be(icmp_echo_req.id),
u16::from_be(icmp_echo_req.sequence),
);
プログラムを実行した状態で別インスタンスからPingを送ると、下記のようにPingの受信ログが出ました。
$ RUST_LOG=info cargo xtask run -- --iface ens5
(略)
[INFO hello_aya] Waiting for Ctrl-C...
[INFO hello_aya] received an icmp echo request src=172.31.38.12 ihl=5 len=38 ttl=127 id=32 seq=1
[INFO hello_aya] received an icmp echo request src=172.31.38.12 ihl=5 len=38 ttl=127 id=32 seq=2
(略)
Pingに応答する
ついにPingに応答するときが来ました。GitHubのコミットはこちらです。
- (処理3) IPとMACのsource/destinationアドレスを入れ替える
// update ip
unsafe {
let orig_src_addr = (*iphdr).src_addr;
(*iphdr).src_addr = (*iphdr).dst_addr;
(*iphdr).dst_addr = orig_src_addr;
}
// update eth
unsafe {
let orig_src_addr = (*ethhdr).src_addr;
(*ethhdr).src_addr = (*ethhdr).dst_addr;
(*ethhdr).dst_addr = orig_src_addr;
}
- (処理4) ICMPヘッダのtypeフィールドをEchoからEcho replyに書き換える
// update icmp
unsafe {
(*icmphdr).type_ = ICMP_TYPE_ECHO_REPLY; // == 0
}
- (処理5) Echo requestを受信したインターフェースからreplyを送信する
- Ok(xdp_action::XDP_PASS)
+ info!(&ctx, "sending an echo reply");
+ Ok(xdp_action::XDP_TX)
パケットの内容を更新するために、パケットへのポインタ (ethhdr
, iphdr
, icmphdr
) はmutableにする必要がありました。
この状態でプログラムを実行して別のEC2からPingを送ってみます。sending an echo reply
のログが出ていたのでBPFプログラムは応答を返しているつもりのようですが、Pingの送信元のインスタンスではタイムアウトになってしまいました。
64 bytes from 172.31.35.246: icmp_seq=3 ttl=127 time=0.320 ms
64 bytes from 172.31.35.246: icmp_seq=4 ttl=127 time=0.352 ms
<--- ここでBPFプログラムを起動
no answer yet for icmp_seq=5
no answer yet for icmp_seq=6
no answer yet for icmp_seq=7
no answer yet for icmp_seq=8
no answer yet for icmp_seq=9
<--- ここでBPFプログラムを終了
64 bytes from 172.31.35.246: icmp_seq=10 ttl=127 time=0.433 ms
64 bytes from 172.31.35.246: icmp_seq=11 ttl=127 time=0.365 ms
Ping送信元のインスタンスでpcapをとってみたところ、図の通りICMPのチェックサムが誤っていることがわかりました。確かに現状のプログラムでは、ICMPのtypeフィールドを変更しているのにチェックサムを更新できていませんでした。
チェックサムを更新する
ICMPのチェックサムを更新する処理を盛り込みます。IPチェックサムの計算を行うBPFのヘルパ関数 bpf_csum_diff
のバインディングをAyaが定義してくれていたので、これを活用してチェックサムの計算を行います。
bpf-helpersのmanページによると、この関数はパケットにデータを追加する場合・除去する場合・diffをとる場合の3通りの使い方ができるようです。
s64 bpf_csum_diff(__be32 *from, u32 from_size, __be32 *to, u32 to_size, __wsum seed)
(略)
This is flexible enough to be used in several ways:
- With from_size == 0, to_size > 0 and seed set to checksum, it can be used when pushing new data.
- With from_size > 0, to_size == 0 and seed set to checksum, it can be used when removing data from a packet.
- With from_size > 0, to_size > 0 and seed set to 0, it can be used to compute a diff. Note that from_size and to_size do not need to be equal.
今回のEcho Request -> Echo Replyへのパケット上書きは、ICMPのヘッダ部分を下記のようにゼロクリアすることに相当します。
octet 0 1 2 3
field | Type | Code | Checksum |
request | 0x08 | 0x00 | 0x0000 |
reply | 0x00 | 0x00 | 0x0000 |
このことを踏まえ、bpf_csum_diff
のfrom
引数に上書き前のICMPヘッダを指定し、to_size
引数に0を指定することでデータを除去した場合のチェックサムを再計算します。(GitHubのコミットはこちら)
ICMPのチェックサムはICMPパケットの16ビットごとの1の補数和を計算した後、その結果をビット反転することにより求められます。bpf_csum_diff
はビット反転前のチェックサムを扱うため、seed
には上書き前のチェックサムをビット反転したものを渡しています。
// update icmp
unsafe {
let orig_csum = (*icmphdr).checksum;
(*icmphdr).checksum = 0;
// calculate checksum after clearing the current icmp type field
let new_csum = fold_checksum(
bpf_csum_diff(
icmphdr as *mut u32, 4,
0 as *mut u32, 0,
!orig_csum as u32
)
);
(*icmphdr).type_ = ICMP_TYPE_ECHO_REPLY; // == 0
(*icmphdr).checksum = new_csum;
}
bpf_csum_diff
は16ビットごとの足し込みのみを行ってくれるので、ICMPヘッダに設定できる状態にする (桁溢れを折り返してビット反転) 部分は自前で実装しました。
#[inline(always)]
fn fold_checksum(sum: i64) -> u16 {
let mut csum = sum;
csum = (csum & 0xffff) + (csum >> 16);
csum = (csum & 0xffff) + (csum >> 16);
csum = (csum & 0xffff) + (csum >> 16);
csum = (csum & 0xffff) + (csum >> 16);
return !csum as u16;
}
この状態でプログラムを実行したところ、Pingに応答が返ってきました!
PING 172.31.35.246 (172.31.35.246) 56(84) bytes of data.
64 bytes from 172.31.35.246: icmp_seq=1 ttl=127 time=0.257 ms
64 bytes from 172.31.35.246: icmp_seq=2 ttl=127 time=0.335 ms
...
Pingの応答がeBPFから返ってきているのかどうかがわかりにくくちょっと感動が少ないので、次の章ではpingコマンドの出力にあらわれるTTL値もeBPFで変えてみます。
IPのTTL値を変えてみる
開発に使っていたインスタンスのTTL値のデフォルトは127だったので、適当に100を引いて27を設定します。IPヘッダを更新したので、チェックサムも計算しなおします。
(*iphdr).ttl = 27;
(*iphdr).check = 0;
(*iphdr).check = fold_checksum(
bpf_csum_diff(
0 as *mut u32, 0,
iphdr as *mut u32, Ipv4Hdr::LEN as u32,
0u32
)
);
この状態でプログラムを実行してPingを送ってみます。たしかにTTLが変わっていました!
64 bytes from 172.31.35.246: icmp_seq=1 ttl=127 time=0.343 ms
64 bytes from 172.31.35.246: icmp_seq=2 ttl=127 time=0.327 ms
64 bytes from 172.31.35.246: icmp_seq=3 ttl=127 time=0.314 ms
64 bytes from 172.31.35.246: icmp_seq=4 ttl=127 time=0.288 ms
<-- ここでBPFプログラムを起動
64 bytes from 172.31.35.246: icmp_seq=5 ttl=27 time=0.332 ms
64 bytes from 172.31.35.246: icmp_seq=6 ttl=27 time=0.372 ms
64 bytes from 172.31.35.246: icmp_seq=7 ttl=27 time=0.395 ms
<--- ここでBPFプログラムを終了
64 bytes from 172.31.35.246: icmp_seq=8 ttl=127 time=0.446 ms
64 bytes from 172.31.35.246: icmp_seq=9 ttl=127 time=0.315 ms
まとめ
学んだこと
- XDPでパケットを読み込む (Ayaの
XdpContext
, eBPFのxdp_md
) - Rustの構造体へパケットをパースし、パケットを編集する (
XdpContext::data()
) - XDPでパケットを送り返す (
xdp_action::XDP_TX
) - BPFのヘルパ関数をAyaから呼び出す (
aya_bpf::helpers::bpf_csum_diff
)
微妙だったこと
- Rustを初めて使ってみたが、結局はunsafeなコードばかり書いていた
- BPFプログラムのコードはunsafeが多くなる、とAyaのドキュメントも言っていた。ユーザスペースのコードの複雑度が上がってくるとRustの恩恵が強くなってきそう
- ケースによっては正しそうなコードでもBPF検証器に弾かれてしまった
- 当初はICMPのチェックサムをICMPのデータ部含めて丸ごと計算しようとしたが、パケットの始点・終点のチェックをきちんと行っているつもりでも「始点がパケット外」の検証器エラーが出た。検証器ログに出力されるコードもコンパイラの最適化が適用されたあとのもののようで、Rustのコードをどう変更するとエラーの解消につながるかの見極めにかなり経験が必要に思えた
やってみたいこと
- eBPF本にロードバランサを作る例があったので、これもアレンジして試してみたい
おわりに
eBPF本のおかげで、eBPFやカーネルプログラミング未経験でもXDPでPingに応答することができました。
それではみなさんも良い1年 & eBPFライフを!