LoginSignup
6
5
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

eBPF本を読んだのでXDPとRust (Aya) に入門してPingに応答してみた

Last updated at Posted at 2024-01-07

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の例では、実際にパフォーマンスの差は感じられないだろうとも思います)

xdp

eBPF本の例では、Pingに応答するeBPFプログラムをtc (traffic control) のレイヤで動作させていました。下の図はeBPF Summit 2023のeBayの講演のスライドの1つですが、XDPはtcより下のレイヤに位置することがわかります。

Performance Analysis of XDP-native, XDP-generic, and TC eBPF hooks - Vinay Kulkarni 00-01-10.png

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.ctc_pingpong()) を読み解いて、やるべきことを整理してみます。下記はサンプルコードの抜粋に筆者が読み取った処理を日本語でコメントしたものです。

network.bpf.c
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とロギングすることがわかります。

hello-aya-ebpf/src/main.rs
#![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が押されるまで実行するものです。

hello-aya/src/main.rs
#[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が大きすぎることがわかりました。

journalctl -k
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

再度実行すると今度は下記のエラーが出ました。

journalctl -k
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の送信元のインスタンスではタイムアウトになってしまいました。

送信元のping -Oの出力
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フィールドを変更しているのにチェックサムを更新できていませんでした。

image.png

チェックサムを更新する

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_difffrom引数に上書き前の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 -Oの出力
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ライフを!

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5