🥑

aya と eBPF を使用してソケット通信を高速化する練習

2023/04/23に公開

この記事は、「How to use eBPF for accelerating Cloud Native applications」という記事を練習するためのものです。現在、RustでeBPFプログラムを構築するための情報が少ないため、このプロセスを記録するためにこの記事を書きました。

2023年4月現在、eBPFをRustで構築する方法は、バインディングを手動で行う以外に主に次の選択肢があります。

  • aya: Aya is an eBPF library for the Rust programming language, built with a focus on developer experience and operability.
  • redbpf: Rust library for building and running BPF/eBPF modules

これら2つのツールは、機能とAPIがほぼ同じですが、redbpfはLLVM 13を使用してビルドする必要があります。実際に使用してみると、ayaのツールチェーンの完成度が高く、欠点としてはドキュメントの内容が非常に少ないということです。公式のドキュメントであるaya-bookには、わずか3つの例しか含まれておらず、他のページはすべてWIPの状態です。ただし、コードはモジュール化されているため、ソースコードをざっと見れば大まかに理解できます。

本文では、ayaを使用してeBPFプロジェクトを構築し、loopbackを回避してデータを直接相手先のソケットに送信する方法を実現します。もちろん、このプロジェクトは単純化されたデモにすぎません。

原理は上の図と似ています。

本文で使用される関数については、bpf-helpers(7) - Linux Manual ページを使用して、対応するC関数の名称、引数、および内容を調べることができます。

テンプレートから eBPF プロジェクトを作成します。

まず、プロジェクトの構成について説明していきます。以下のプロジェクトテンプレートを使用してプロジェクトを作成します。

https://github.com/aya-rs/aya-template

生成されたプロジェクト構造は、次のようになります(xxxはあなたのプロジェクト名です)。

  • xtask: ビルド関連のロジック。cargo xtask build-ebpf は、cargo run --package xtask build-ebpf を実行しています。このディレクトリのコードは無視して構いません。
  • xxx-common: 2つのプロジェクトで同時に参照されるコードを格納するためのディレクトリです。以下では例を示します。
  • xxx-ebpf: EBPFコードを格納するためのディレクトリで、カーネルに挿入されます。
  • xxx: ユーザースペースのコードで、xxx-ebpfをロードしたり、カスタムロジックを実行したりする責任があります。

eBPF Probe Code

まず最初に、ロードする場所を指定する必要があります。 Cでは、これは __section("xxx") で指定されます。 aya では、macro を使用して指定します。すべての macro は、aya_bpf::macros モジュールの下にあり、名前は C とほぼ同じです。たとえば、 __section("sockops")#[sock_ops] になります。ここではアンダースコアに注意してください。

位置を決定した後、ロジックを記述するために関数を定義する必要があります。関数のシグネチャは、Cのシグネチャと一致する必要があります。引数の型は、aya_bpf::programs モジュールから確認できます。Context で終わるものが対応しています。たとえば、C の bpf_sock_opsSockOpsContext に対応します。

use aya_bpf::macros::sock_ops;  
use aya_bpf::programs::SockOpsContext;

#[sock_ops]
pub fn bpf_sockmap(ctx: SockOpsContext) -> u32 {
    // todo
}  

それでは、ロジックを書くために SockOpsContext から情報を抽出して SockHash に保存する必要があります。これには、この構造を定義する必要があります。SockHash は、KEYのタイプを示すジェネリックパラメータを受け取ります。VALUEのタイプは bpf_sock_ops であり、変更することはできません。より高度なカスタマイズされたハッシュ構造を使用したい場合は、SockMapHashMap を使用して解決できます。

use aya_bpf::maps::SockHash;  
use xxx_common::SockHashKey;

pub const CAPACITY: usize = 8192;

#[map]
static mut SOCKHASH: SockHash<SockHashKey> = SockHash::with_max_entries(CAPACITY as u32, 0); 

この構造体は、カーネル側とユーザーランド側の両方で使用する必要があるため、 #[map] macro で修飾する必要があります。この macro の意味は、ハッシュマップの map ではなく、マップ(映射)であることに注意してください。なぜなら、カーネルとユーザーランドの両方で SockHashKey 構造体が必要だからです。そのため、common ディレクトリを使用する必要があります。SockHashKey をこのディレクトリに置きます。

// xxx-common/src/lib.rs

#![no_std]

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct SockHashKey {  
    pub sip4: u32,  // source IP
    pub dip4: u32,  // destination IP
    pub family: u8, // protocol type
    pub pad1: u8,   // this padding required for 64bit alignment
    pub pad2: u16,  // else ebpf kernel verifier rejects loading of the program
    pub pad3: u32,
    pub sport: u32, // source port
    pub dport: u32, // destination port
}

#[cfg(feature = "user")]
unsafe impl aya::Pod for SockHashKey {} 

次に、bpf_sockmap 的ロジックを見ていきます。アクティブな接続イベントとパッシブな接続イベントをキャプチャし、AF_INET かどうかを判断します。条件を満たす場合は、bpf_sock_ops_ipv4 を呼び出して、Hash 構造体に保存します。

#[sock_ops]
pub fn bpf_sockmap(ctx: SockOpsContext) -> u32 {  
    if ctx.op() == BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB {
        if ctx.family() == 2 {
            info!(
                &ctx,
                "passive established sport {} dport {}",
                ctx.local_port(),
                unsafe { u32::from_be((*ctx.ops).remote_port) }
            );
            bpf_sock_ops_ipv4(ctx);
        }
        return 0;
    }
    if ctx.op() == BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB {
        if ctx.family() == 2 {
            info!(
                &ctx,
                "active established sport {} dport {}",
                ctx.local_port(),
                unsafe { u32::from_be((*ctx.ops).remote_port) }
            );
            bpf_sock_ops_ipv4(ctx);
        }
        return 0;
    }
    return 0;
}

ここで、バイトオーダーについて特に強調する必要があります。local_port はホストバイトオーダーで、remote_port はネットワークバイトオーダーであるため、aya-rs はこれに対して特別な処理を行っていません。remote_port()関数は単純にフィールドを返すだけです。ここで行う処理は比較的直接的であり、一部の奇妙なシステムでは互換性がない可能性があります。

fn bpf_sock_ops_ipv4(ctx: SockOpsContext) {  
    let mut key = SockHashKey {
        sip4: ctx.local_ip4(),
        dip4: ctx.remote_ip4(),
        family: 1,
        pad1: 0,
        pad2: 0,
        pad3: 0,
        sport: unsafe { u32::from_be((*ctx.ops).local_port) },
        dport: ctx.remote_port(),
    };

    let ops = unsafe { ctx.ops.as_mut().unwrap() };
    let ret = unsafe { SOCKHASH.update(&mut key, ops, BPF_NOEXIST.into()) };

    ret.expect("SockHash error");
}

核心的ロジックは SOCKHASH.update です。この関数の役割は、KEYに基づいてハッシュを作成し、ソケットを保存することです。パラメータについては、ドキュメントを参照してください。

       long bpf_sock_hash_update(struct bpf_sock_ops *skops, struct
       bpf_map *map, void *key, u64 flags)

              Description
                     Add an entry to, or update a sockhash map
                     referencing sockets.  The skops is used as a new
                     value for the entry associated to key. flags is one
                     of:

                     BPF_NOEXIST
                            The entry for key must not exist in the map.

                     BPF_EXIST
                            The entry for key must already exist in the
                            map.

                     BPF_ANY
                            No condition on the existence of the entry
                            for key.

                     If the map has eBPF programs (parser and verdict),
                     those will be inherited by the socket being added.
                     If the socket is already attached to eBPF programs,
                     this results in an error.

              Return 0 on succ

次に、sendmsg システムコールをインターセプトします。ここで、aya-rs に関する知識については既に触れているため、ここでは省略します。主な実行ロジックは、呼び出しをインターセプトした後にハッシュ構造を検索する必要があります。このキーをフォーマットする際に、srcdst が反転していることに注意する必要があります。AがBにTCP接続を確立した場合、sock_opsのフックには2つのキーの組が記録されます。1つはアクティブなアクティブ接続であり、Aの視点でキーをフォーマットします。もう1つはBのパッシブなパッシブ接続であり、Bの視点でキーをフォーマットします。そのため、redirectを行う際には、直接相手のソケットに送信する必要があります。そのため、ここでは相手の視点でソケットを検索する必要があります。

#[sk_msg]
pub fn bpf_redir(ctx: SkMsgContext) -> u32 {  
    let mut key = unsafe {
        SockHashKey {
            sip4: (*ctx.msg).remote_ip4,
            dip4: (*ctx.msg).local_ip4,
            family: 1,
            pad1: 0,
            pad2: 0,
            pad3: 0,
            sport: (*ctx.msg).remote_port,
            dport: unsafe { u32::from_be((*ctx.msg).local_port) },
        }
    };

    let ret = unsafe { SOCKHASH.redirect_msg(&ctx, &mut key, BPF_F_INGRESS as u64) };

    if ret == 1 {
        info!(&ctx, "redirect_msg succeed");
    } else {
        info!(&ctx, "redirect_msg failed");
    }

    return sk_action::SK_PASS;
}

注意が必要です。ここでキャッシュからソケットを見つけられなかった場合でも、トラフィックを通過させる必要があります。ここまでeBPF部分が完了しました。

Userland

プロジェクトテンプレートから生成されたコードをベースに改修を行います。2つのプローブを定義しているため、loadattachを2回実行する必要があります。各プローブに必要な attach の種類は異なります。例えば、 sockopscgroupattach する必要があります。これについてはbpfドキュメントから確認できます。。

let program: &mut SockOps = bpf.program_mut("sockops").unwrap().try_into()?;  
program.load()?;  
let cgroup = std::fs::File::open("/sys/fs/cgroup")?;  
program.attach(cgroup)?;

let sock_hash: SockHash<MapRefMut, SockHashKey> = SockHash::try_from(bpf.map_mut("SOCKHASH")?)?;  
let program: &mut SkMsg = bpf.program_mut("bpf_redir").unwrap().try_into()?;  
program.load()?;  
program.attach(&sock_hash)?;

// ...
// detach before exit

プログラムを終了する前に、detachする必要があります。また、bpftoolツールを使用して手動でdetachすることもできます。

プログラムの構築と実行は、デフォルトのドキュメントと同じです。ループバックのRXとTXを監視して確認できます。ローカルのnginxを起動して負荷テストを実行し、効果を比較すると明らかになります。

watch -n 1 "cat /proc/net/dev"  

Discussion