eBPF/XDPでパケット処理をするときにやってるテスト方法の紹介

この記事は BBSakura Networks Advent Calendar 2024 の 24 日目の記事です。

こんにちは。BBSakuraでソフトウェアエンジニアをしています、早坂(@takemioIO) と申します。

先日、eBPF Meetup Japan #2 をさくらインターネット東京支社から配信しました。今回はオーガナイザーとして参加し、50名を超える参加者を集めたミートアップカンファレンスを実現できました。 ご参加いただいた皆さんありがとうございました。 本記事では、さらに前に開催された第一回のMeetupでお話しした内容のフォローアップとして、実装上のテストテクニックに関する落穂拾いをしていきたいと思います。

ちなみに前回発表のスライドはこちらです。よろしければご覧ください。 speakerdeck.com

eBPFヘルパーとは何か?

eBPFヘルパーと呼ばれる、eBPFプログラム内部から呼び出せる便利な関数群があります。これらは多くの場合、カーネルに対するAPI的な側面を持ち、UAPIの一部として定義されます。そのため、カーネルバージョン間でのコンパチビリティが担保されています。
もしコンパチビリティを特に考慮する必要がない場合は、KFuncと呼ばれる仕組みを用いてカーネルとやり取りするインターフェースを作ることも可能です。

eBPFヘルパーが実際にどのように定義されているかは、カーネルのコードを見るとわかります。
github.com

たとえば bpf_fib_lookup というヘルパー関数は、引数としてIPアドレスなどを渡すことで、Nexthopに接続するインターフェースとそのMACアドレスを取得できます。XDPなどを用いたパケット処理においては、ARPパケットをカーネルのプロトコルスタックに通すことで、ARPによるMACアドレス解決が可能です。

この結果はLinuxカーネル内部でFIBエントリとして保持されていますが、bpf_fib_lookupを利用することでその結果を取得できます。
詳しくは以下のリンクをご覧ください。
docs.ebpf.io

しかしながら、eBPFヘルパーを利用したプログラムをテストするには、Linuxカーネル内部にあるような情報をどこかで保持する必要があります。これはeBPFプログラムに対するテストの大変さの一つでもあります。

eBPFヘルパーを利用したeBPFプログラムをテストするには

eBPFプログラム自体をテストする方法として、主に以下の2つが考えられます。

1. QEMUを用いて実機相当の環境を再現したテスト

この方法では、実際のトポロジーを組むことで、eBPFヘルパーを利用したプログラムもテスト可能です。 また最も確実でもあります。実際のカーネル上で動作させ、virtio-netドライバレベルでの不具合検出なども可能です。
しかし、テスト環境を構築する手間やオーバーヘッドが大きいため、開発イテレーションを素早く回すには不向きです。

たとえば、ciliumのような大規模プロジェクトでは当然この手法が利用されており、参考資料として以下を挙げます。
Yutaro Hayakawaさんの資料

また、弊社環境でも同様のアプローチを採用しており、弊社CTOの日下部による資料が参考になります。

www.docswell.com

補足として、最近ではcilium/ebpfがvirtmeではなくvirttoというものに置き換わっています。
こうしたツールチェーンの変遷も興味深いです。

2. eBPF Helperの関数をモックする

BPF_PROG_TEST_RUNを使うと、eBPFプログラムに対してユニットテストを書くことができます。

一方で、BPF_PROG_TEST_RUNだけではeBPFヘルパー利用部分をテストするのは難しいです。なぜならカーネル内部からデータを取得する操作は、多くの場合副作用を伴うためです。

そこで、コンパイル時にC言語のプリプロセッサを用いてeBPFヘルパーをモック実装と切り替え可能にすることで、BPF_PROG_TEST_RUNを利用した簡易なユニットテストが実現できます。この方法は、特定の入力に対するテストを手軽に記述でき、環境を模すのに必要だったVMのオーバーヘッドがなく、開発のフィードバックループを高速化することが可能です。

BPF_PROG_TEST_RUN自体に関しては以下をご覧ください。
docs.ebpf.io

eBPFヘルパーのモックを使ったユニットテストの実装例

ここからは実際のサンプルコードを交えながら実装例を示します。
サンプルコードは以下のリポジトリにあります。XDPを用いたパケット処理実装の際に便利に使えるテンプレートです。
github.com

まず、テスト実行の例を抜粋します。全体は以下のリンクから参照してください。
github.com

このコードでは、eBPFプログラムに対して期待する入力を渡し、期待する出力が返ってくるかをテストしています。retが処理後のパケットデータ、gotが戻り値、errがシステムコール実行結果となります。これらを用いて期待通りのパケットかどうかをチェックします。

   ret, got, err := objs.XdpProg.Test(generateInput(t))
    if err != nil {
        t.Error(err)
    }

    // return code should be XDP_TX
    if ret != 3 {
        t.Errorf("got %d want %d", ret, 3)
    }

    // check output
    want := generateOutput(t)
    if diff := cmp.Diff(want, got); diff != "" {
        t.Errorf("output mismatch (-want +got):\n%s", diff)
    }

パケットはgopacketを用いると簡単に生成できます。以下はVXLANパケットの例です。

func generateInput(t *testing.T) []byte {
    t.Helper()
    opts := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}
    buf := gopacket.NewSerializeBuffer()
    iph := &layers.IPv4{
        Version: 4, Protocol: layers.IPProtocolUDP, Flags: layers.IPv4DontFragment, TTL: 64, IHL: 5, Id: 1212,
        SrcIP: net.IP{192, 168, 10, 1}, DstIP: net.IP{192, 168, 10, 5},
    }
    udp := &layers.UDP{SrcPort: 4789, DstPort: 4789}
    udp.SetNetworkLayerForChecksum(iph)
    vxlan := &layers.VXLAN{VNI: 0x123456}
    err := gopacket.SerializeLayers(buf, opts,
        &layers.Ethernet{DstMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x53, 0x01}, SrcMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x53, 0x02}, EthernetType: layers.EthernetTypeIPv4},
        iph, udp, vxlan,
        &layers.Ethernet{DstMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x01}, SrcMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x02}, EthernetType: layers.EthernetTypeIPv4},
        &layers.IPv4{
            Version: 4, Protocol: layers.IPProtocolICMPv4, Flags: layers.IPv4DontFragment, TTL: 64, IHL: 5, Id: 1160,
            SrcIP: net.IP{192, 168, 100, 200}, DstIP: net.IP{192, 168, 30, 1},
        },
        gopacket.Payload(payload),
    )
    if err != nil {
        t.Fatal(err)
    }
    return buf.Bytes()
}

今回は前述した bpf_fib_lookup に関してモック化しています。
モック化したC関数を示します。中を見ると、eBPFマップでモックとして返すべき値を取得して返しています。
これらはコンパイル時にifdefマクロで切り替えています。

#ifdef XDP_TEST
// bpf_fib_lookupのモック実装
static __always_inline bool ipv4_fib_lookup(struct xdp_md *xdp, struct bpf_fib_lookup *params,
                                            struct iphdr *iph, __u32 ifindex, __u32 flags)
{

    struct bpf_fib_lookup_mock map_key = {};

    // tos, tot_len, sport, dport, l4_proto, ifindexを無視
    map_key.family = AF_INET;
    map_key.ipv4_src_or_ipv6_src[0] = iph->saddr;
    map_key.ipv4_dst_or_ipv6_dst[0] = iph->daddr;
    map_key.ifindex = ifindex;

    DEBUG_PRINT("ipv4_fib_lookup (mock) called iph->saddr %x", iph->saddr);
    DEBUG_PRINT("ipv4_fib_lookup (mock) called iph->daddr %x", iph->daddr);
    DEBUG_PRINT("ipv4_fib_lookup (mock) called ifindex %d", ifindex);
    struct fib_lookup_mock_result *mockres = (struct fib_lookup_mock_result *)bpf_map_lookup_elem(&fib_lookup_mock_table, &map_key);
    if (!mockres)
    {
        DEBUG_PRINT("ipv4_fib_lookup (mock) failed, return false");
        return false;
    }
    if (mockres->status != BPF_FIB_LKUP_RET_SUCCESS)
    {
        DEBUG_PRINT("ipv4_fib_lookup (mock) success, but status is not success, return false");
        return false;
    }

    __builtin_memcpy(params, &mockres->params, sizeof(struct bpf_fib_lookup));
    DEBUG_PRINT("ipv4_fib_lookup (mock) success");
    return true;
}
#else
// 通常時のbpf_fib_lookup呼び出し
static __always_inline bool ipv4_fib_lookup(struct xdp_md *xdp, struct bpf_fib_lookup *params,
                                            struct iphdr *iph, __u32 ifindex, __u32 flags)
{
    params->family = AF_INET;
    params->tos = iph->tos;
    params->l4_protocol = iph->protocol;
    params->sport = 0;
    params->dport = 0;
    params->tot_len = bpf_ntohs(iph->tot_len);
    params->ipv4_src = iph->saddr;
    params->ipv4_dst = iph->daddr;
    params->ifindex = ifindex;
    int rc = bpf_fib_lookup(xdp, params, sizeof(struct bpf_fib_lookup), flags);
    if (rc != BPF_FIB_LKUP_RET_SUCCESS)
        return false;
    return true;
}
#endif

モック時はGoのユニットテスト側で事前にマップへ期待結果を設定します。
たとえば以下のように設定します。

if err := UpdateIPv4FibLookUpMockMap(
    objs,
    IPv4FibLookUpMockKey{
        IPv4Src: netip.AddrFrom4([4]byte{192, 168, 100, 200}),
        IPv4Dst: netip.AddrFrom4([4]byte{192, 168, 30, 1}),
        Ifindex: 1,
    },
    &xdpFibLookupMockResult{
        Status: xdptool.BPF_FIB_LKUP_RET_SUCCESS,
        Params: xdpBpfFibLookupMock{
            Dmac: byteArrayToUint8Array([6]byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x11}),
            Smac: byteArrayToUint8Array([6]byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x12}),
        },
    },
); err != nil {
    t.Fatal(err)
}

モック化しない場合は実際のbpf_fib_lookupが呼ばれます。

テスト実行時は以下のようにコンパイル時フラグを付与してからテストします。

# モック有効化ビルド
CEXTRA_FLAGS="-DXDP_DEBUG -DXDP_TEST" make

# ユニットテスト実行
make test

このように、モックをうまく使うことで実環境の模擬なしにユニットテストが可能になります。

まとめ

今回は発表時のフォローアップとして、XDPを用いたパケット処理実装時のテストテクニックを紹介しました。
eBPFヘルパーを含むプログラムでも、コンパイル時マクロを用いたモック化によってユニットテストを容易にできることがお分かりいただけたかと思います。
もし、より良い手法やアイデアがありましたら、ぜひ教えてください。

BBSakura Networksでは、このようなパケット処理技術やカーネル内での処理に興味のあるエンジニアを募集しています。