1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust × eBPF/XDP ― カーネルの入り口でパケットを捌く「最速の筋肉」

1
Last updated at Posted at 2026-03-26

はじめに

第4回 では、制御プレーン(NETCONF)とデータプレーン(XDP)の役割の違いと、Rust/Go/Python の3層アーキテクチャの設計思想を解説しました。

今回はその「最速の筋肉」である Rust × eBPF/XDP の実装に踏み込みます。リポジトリの ztna-tetragon-maf/xdp-ebpf/main.rs をベースに、カーネル内でパケットを判定する仕組みを解説します。

※本記事の内容は個人の研究成果であり、所属組織の業務や公式見解とは一切関係ありません。


C言語から Rust/Aya への移行 ― なぜ書き直したか

最初の eBPF プログラムは C言語で書きました

IPS(Intrusion Prevention System)機能の最初のバージョンは、AI支援(LLM によるコード生成)を活用しながら、AIエージェントオーケストレーションを含む形で着手から約3人日で動くところまで持っていきました。eBPF の C言語実装は情報が豊富で、libbpf や bpftool も整備されており、LLM との相性もよく、スピード優先で動かすには合理的な選択です。

ただし、開発を続ける中でいくつかの課題が出てきました。

C言語 eBPF の課題

① メモリ安全性:
   カーネル内で動作するため、バッファオーバーフローや
   ヌルポインタ参照は即カーネルパニックにつながる。
   eBPF Verifier がある程度防いでくれるが、
   C言語ではコンパイル時の保証がない。

② パケットヘッダ解析の煩雑さ:
   Ethernet → IP → TCP/UDP のオフセット計算を
   手動で管理する必要があり、ミスが起きやすい。

③ 型の共有:
   Go コントロールプレーンと BPF Map の構造体を
   C言語と Go で二重に定義・同期する必要がある。

Rust/Aya を選んだ理由

Aya は Rust でeBPF プログラムを書くためのフレームワークです。Rust の eBPF ライブラリには libbpf-rs という選択肢もありますが、libbpf-rs は C言語の libbpf をバインディングしているため C言語ランタイムへの依存が残ります。一方 Aya は純粋な Rust 実装で C言語ランタイムに依存しません。no_std 環境との親和性が高く、Rust の型システムをフル活用できる点が決め手でした。最終的に Aya で書き直した理由はさらに3つあります。

  1. コンパイル時のメモリ安全性: Rust の借用チェッカーがカーネル内コードのメモリ安全性をコンパイル時に保証します。unsafe ブロックは必要な箇所に限定でき、リスクを局所化できます。

  2. network-types クレート: Ethernet/IP/TCP/UDP のヘッダ構造体が整備されており、オフセット計算をライブラリに任せられます。EthHdr::LEN のような定数が使えるため、手動計算のミスがなくなりました。

  3. #[no_std] 環境への対応: カーネル内は標準ライブラリが使えない no_std 環境ですが、Aya はこれを自然に扱えます。C言語と同様のパフォーマンスを維持しながら Rust の型システムの恩恵を受けられます。

IPS(初期版)から ZTNA(現行版)への移行で、コードの信頼性と保守性が大きく向上しました。


XDP プログラムの基本構造

まずプロジェクトの依存関係を確認します。

# ztna-tetragon-maf/xdp-ebpf/Cargo.toml
[dependencies]
aya-ebpf     = { git = "https://github.com/aya-rs/aya", branch = "main" }
aya-log-ebpf = { git = "https://github.com/aya-rs/aya", branch = "main" }
network-types = "0.0.5"   # Ethernet/IP/TCP/UDP ヘッダ構造体

#![no_std]#![no_main] が必須です。カーネル内プログラムのため、標準ライブラリもエントリポイントも使いません。

#![no_std]
#![no_main]

use aya_ebpf::{
    bindings::xdp_action,
    helpers::{bpf_ktime_get_ns, bpf_redirect},
    macros::{map, xdp},
    maps::HashMap,
    programs::XdpContext,
};
use network_types::{eth::EthHdr, ip::Ipv4Hdr, tcp::TcpHdr, udp::UdpHdr};

// エントリポイント: NICがパケットを受信するたびに呼ばれる
#[xdp]
pub fn xdp_filter(ctx: XdpContext) -> u32 {
    match try_xdp_filter(ctx) {
        Ok(ret) => ret,
        Err(_)  => xdp_action::XDP_ABORTED,
    }
}

#[xdp] マクロがこの関数を XDP プログラムとして登録します。戻り値は XDP_DROPXDP_PASSXDP_REDIRECT などの定数です。


BPF Map ― カーネルとユーザー空間をつなぐ共有メモリ

BPF Map はカーネル内の XDP プログラムと、ユーザー空間(Go コントロールプレーン)が共有するデータ構造です。Go から「この IP をブロックせよ」と書き込むと、Rust の XDP プログラムがそれを読んで DROP します。

// 5つの BPF Map を定義
#[map]
static mut STATS_MAP: HashMap<FlowKey, Stats> =
    HashMap::with_max_entries(16384, 0);   // フロー統計

#[map]
static mut DROP_LIST: HashMap<FlowKey, u32> =
    HashMap::with_max_entries(16384, 0);   // ブロックリスト(Go が書き込む)

#[map]
static mut QOS_MAP: HashMap<u32, QosConfig> =
    HashMap::with_max_entries(16384, 0);   // QoS 設定

#[map]
static mut AUTH_IPS: HashMap<u32, AuthInfo> =
    HashMap::with_max_entries(4096, 0);    // 認証済み IP(ZTNA)

#[map]
static mut CONFIG_MAP: HashMap<u32, u64> =
    HashMap::with_max_entries(16, 0);      // マジックナンバー等の設定値

#[map] マクロが Map をカーネルに登録します。Go 側の cilium/ebpf ライブラリが同じ Map に名前でアクセスします(coll.Maps["DROP_LIST"])。この仕組みが「Python/MAF の判断 → Go の REST API → BPF Map 更新 → Rust が即座に DROP」という連携を可能にしています。

構造体の共有

BPF Map のキーと値は、Go と Rust で同じメモリレイアウトを持つ必要があります。

// Rust 側(xdp-ebpf/main.rs)
#[repr(C)]          // C言語互換のメモリレイアウトを保証
#[derive(Clone, Copy)]
pub struct FlowKey {
    pub ip:       u32,
    pub port:     u16,
    pub protocol: u8,
    pub pad:      u8,   // アライメント調整
}

#[repr(C)]
#[derive(Clone, Copy)]
pub struct Stats {
    pub packets:         u64,
    pub bytes:           u64,
    pub dropped_packets: u64,
    pub syn_packets:     u64,
    pub rst_packets:     u64,
    pub ack_packets:     u64,
    pub last_ts:         u64,
    pub flow_start_ns:   u64,
    pub user_id:         u32,
    pub policy_status:   u32,
    pub l7_proto_label:  u32,
    pub pkt_min:         u32,
    pub pkt_max:         u32,
    pub _pad:            u32,
}
// Go 側(go-control-plane/main.go)
type FlowKey struct {
    Ip       uint32
    Port     uint16
    Protocol uint8
    Pad      uint8
}

type IpStats struct {
    Packets        uint64 `json:"packets"`
    Bytes          uint64 `json:"bytes"`
    DroppedPackets uint64 `json:"dropped_packets"`
    // ... 同じフィールド順
}

#[repr(C)] が両言語間のレイアウト互換を保証します。フィールドの順序・型・パディングが一致していないと、Map の読み書きで値が化けます。

特に AUTH_IPSCONFIG_MAP は次回の Go コントロールプレーンが MAF からの指示を受けて書き換える「エンドポイント」になります。MAF Admin Agent が /drop/block/auth/revoke を叩くと、Go がこれらの Map を更新し、Rust の XDP プログラムが次のパケット到着時に即座に反映します。


パケット処理の流れ

try_xdp_filter がパケット1つごとに呼ばれます。L2 → L3 → L4 と順にヘッダを剥いていき、BPF Map を参照してアクションを決める流れを図で示します。

Step 1: パケットの安全な読み出し

eBPF Verifier はカーネル内プログラムの安全性を検証します。パケットの境界チェックを省略するとVerifier に弾かれます。

// 境界チェック付きのポインタ取得ヘルパー
#[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)
}

ctx.data()ctx.data_end() でパケットの先頭と末尾を取得し、アクセスする範囲が収まっているか確認します。これを怠ると Verifier に拒否されます。

Step 2: Ethernet → IP → TCP/UDP のパース

fn try_xdp_filter(ctx: XdpContext) -> Result<u32, ()> {
    let pkt_sz = (ctx.data_end() - ctx.data()) as u32;

    // Ethernet ヘッダを読む
    let eth = unsafe { ptr_at::<EthHdr>(&ctx, 0)? };
    // IPv4 以外は素通し(ARP等)
    if u16::from_be(unsafe { (*eth).ether_type as u16 }) != 0x0800 {
        return Ok(xdp_action::XDP_PASS);
    }

    // IP ヘッダを読む
    let iph      = unsafe { ptr_at::<Ipv4Hdr>(&ctx, EthHdr::LEN)? };
    let src_addr = u32::from_be(unsafe { (*iph).src_addr });
    let protocol = unsafe { (*iph).proto as u8 };
    let l4_offset = EthHdr::LEN + Ipv4Hdr::LEN;

    let mut fk = FlowKey { ip: src_addr, port: 0, protocol, pad: 0 };

    // TCP / UDP でポート番号を取得
    match protocol {
        6  => { // TCP
            let tcp  = unsafe { ptr_at::<TcpHdr>(&ctx, l4_offset)? };
            fk.port  = u16::from_be(unsafe { (*tcp).dest });
            // SYN/RST/ACK フラグを記録(統計・自律防御ループに使用)
        }
        17 => { // UDP
            let udp  = unsafe { ptr_at::<UdpHdr>(&ctx, l4_offset)? };
            fk.port  = u16::from_be(unsafe { (*udp).dest });
        }
        _  => {}
    }

network-types クレートの EthHdr::LENIpv4Hdr::LEN が定数としてオフセット計算を担います。C言語での sizeof(struct ethhdr) の手動計算が不要になりました。

Step 3: DROP_LIST の判定

    // Go から書き込まれた DROP_LIST を確認
    unsafe {
        if let Some(drop) = DROP_LIST.get(&fk) {
            if *drop == 1 {
                update_stats(&fk, pkt_sz, is_syn, is_rst, is_ack,
                             user_id, current_prio, true);
                return Ok(xdp_action::XDP_DROP);  // ラインレートで DROP
            }
        }
    }

MAF Admin Agent が POST /drop/block?ip=10.0.1.30 を叩くと、Go が DROP_LIST に書き込みます。次のパケットが届いた瞬間、この判定で XDP_DROP が返ります。


ZTNA の核心:マジックナンバー認証

IPS版(初期版)との最大の違いが ZTNA(Zero Trust Network Access) です。IPS版はブロックリストに基づく「拒否リスト型」でしたが、ZTNA版は「デフォルト全拒否・認証済みのみ通過」という設計です。

    // 未認証の場合、すべてのトラフィックを DROP(ICMP 含む)
    if !is_authed {
        update_stats(&fk, pkt_sz, is_syn, is_rst, is_ack,
                     user_id, current_prio, true);
        return Ok(xdp_action::XDP_DROP);
    }

認証は UDP ポート 8888 へのマジックナンバー送信で行います。

    // UDP 8888: マジックナンバー認証
    if fk.port == 8888 {
        if let Ok(tag_ptr) = unsafe {
            ptr_at::<u64>(&ctx, l4_offset + UdpHdr::LEN)
        } {
            let tag      = u64::from_be(unsafe { *tag_ptr });
            let magic_key = 0u32;
            let expected  = unsafe {
                CONFIG_MAP.get(&magic_key).unwrap_or(&0)
            };

            if *expected != 0 && *expected != u64::MAX
               && tag == *expected
            {
                let now  = unsafe { bpf_ktime_get_ns() };
                let info = AuthInfo {
                    expiry:   now + DEFAULT_AUTH_DURATION, // 300秒
                    priority: 2,
                    user_id:  src_addr,
                };
                unsafe {
                    let _ = AUTH_IPS.insert(&src_addr, &info, 0);
                    // 認証後はマジックナンバーを番兵値で上書き(再利用防止)
                    let _ = CONFIG_MAP.insert(&magic_key, &u64::MAX, 0);
                }
                return Ok(xdp_action::XDP_PASS);
            }
        }
    }

マジックナンバーを一度使うと u64::MAX(番兵値)で上書きされ、再利用できません。Go の /auth/lock API でチケット発行を恒久ロックすることもできます。


QoS:トークンバケットアルゴリズム

認証済みでもブロックリストにない IP に対して、帯域制御を行います。

#[inline(always)]
fn apply_qos(src_ip: u32, pkt_sz: u32, priority: u32) -> bool {
    let now = unsafe { bpf_ktime_get_ns() };
    unsafe {
        if let Some(config) = QOS_MAP.get_ptr_mut(&src_ip) {
            let elapsed    = now.saturating_sub((*config).last_updated);
            // 優先度2以上はトークン補充を10倍速に
            let multiplier = if priority >= 2 { 10 } else { 1 };
            let refill     = (elapsed * (*config).limit_bytes_per_sec
                              * multiplier) / 1_000_000_000;

            (*config).tokens = core::cmp::min(
                MAX_TOKENS,
                (*config).tokens + refill
            );
            (*config).last_updated = now;

            if (*config).tokens >= pkt_sz as u64 {
                (*config).tokens -= pkt_sz as u64;
                return true;  // 通過
            }
            return false;     // トークン不足 → DROP
        }
    }
    true  // QoS 設定なし → 通過
}

トークンバケットアルゴリズムは帯域制御の古典的手法です。時間経過に比例してトークン(バイト数)が補充され、パケットを送るたびにトークンを消費します。トークンが足りなければ DROP します。認証済み優先ユーザー(priority >= 2)は補充速度が10倍になります。


IPS版(初期版)との比較

C言語から Rust/Aya で書き直し、さらに IPS から ZTNA に進化する過程で何が変わったか:

項目 IPS版(初期・C言語) IPS Rust版 ZTNA版(現行・Rust)
言語 C Rust/Aya Rust/Aya
開発期間 約3人日 約1人日 追加実装
デフォルト PASS(拒否リスト型) PASS(拒否リスト型) DROP(ゼロトラスト型)
認証 なし なし マジックナンバー + 有効期限
BPF Map STATS/DROP/QOS STATS/DROP/QOS + AUTH_IPS + CONFIG_MAP
リダイレクト XDP_PASS のみ XDP_PASS のみ VPP へ XDP_REDIRECT
メモリ安全性 Verifier のみ コンパイル時保証 コンパイル時保証

最も大きな設計変更は 「デフォルト PASS → デフォルト DROP」 です。IPS は悪いものを弾く「拒否リスト型」でしたが、ZTNA は認証されていないものはすべて通さない「許可リスト型」です。ゼロトラストの思想がそのままカーネルレベルで実装されています。

「許可されていないものはすべて DROP」 ― この一行がゼロトラストの本質です。シグネチャもルールも不要で、認証という事実だけがトラフィックの通過を許可します。


パニックハンドラ

#![no_std] 環境では、Rust のパニック機構が使えません。eBPF プログラムにはパニックハンドラの実装が必須です。

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

カーネル内でパニックが起きると無限ループになります。実際には eBPF Verifier がパニックに至るコードを事前に弾くため、このハンドラが実行されることはほぼありません。


まとめと次回予告

今回のポイントをまとめます。

  • LLM オーケストレーションを含む IPS 初期版を AI 支援で約3人日で開発したが、メモリ安全性・保守性の観点から Rust/Aya で書き直した
  • #[map] マクロで BPF Map を定義し、Go コントロールプレーンと構造体を共有する
  • ptr_at ヘルパーで境界チェックを行い eBPF Verifier の要件を満たす
  • network-types クレートでヘッダ解析の手動計算を排除
  • IPS(拒否リスト型)から ZTNA(ゼロトラスト・デフォルト DROP)への設計進化

次回(第6回)は Go × BPF Map の実装を解説します。Rust で定義した BPF Map を Go からどう操作するか、cilium/ebpf ライブラリの使い方、3秒ごとの自律防御ループ、そして REST API で MAF エージェントからの命令を受け付ける仕組みを扱います。


参考リンク

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?