はじめに
RustとRaspberry Piを使って、ネットワークの低レイヤを直接触る作品を作りました。
不要なUDP通信を XDP / nftables / Application の3つのレイヤ(場所)でそれぞれ破棄し、Raspberry PiのCPU使用率やHTTP通信への影響がどのように変わるかを比較・検証したものです。
ちなみに名前は「ぱけっとぽいぽい」です。センスないですね。
本記事では、構想の背景から実装の裏側、そして実際の実験から得られた知見までをまとめていきます。
背景と動機
ネットワークやOSのように、普段のアプリケーション層の抽象化された世界では意識しなくてよい領域をちゃんと掘り始めたきっかけは今年2月にJANOG57へ参加したことでした。
もちろんその次のJANOG58(6月)にも行きました。
次のJANOG59(翌1月)はどうかな…行けたらいいな…。
普段のアプリ開発であれば、通信がアプリケーションに届くまでに何が起きているのかを詳しく知らなくても開発が完結します。
しかしJANOGの場でネットワークを深く扱うエンジニアたちの議論に触れる中で、自分が普段依存しているスタックの下で何が起きているのかを自分の手で理解したいという強い欲求が生まれました。
もう一つ、近年の開発環境の変化に対して感じていた思いもあります。
コーディングエージェントの登場・進化によって、アプリケーションやコードを構築すること自体のハードルは劇的に下がりました。
非常に便利で恩恵を受けている一方で、自分の中では「コードを書いてものを作る」というプロセスそのものの面白みが薄れていくような感覚もありました。
もっと自分の手でOSやネットワークの仕組みをハックしてブラックボックスの中身を暴き、実際に動かして確かめたい。
そんな動機から自作ルータを作ったりRustで低レイヤを叩いたりするうちに、普段見えていなかったレイヤの挙動が理解できていく過程に強く惹かれていきました。
せっかくなら自分が面白いと思ったこの試行錯誤を、他の人にも触ってもらえる展示というゴールに落とし込みたい。
そう考えて本プロダクトの開発をスタートさせました。
アーキテクチャ概要
Raspberry Pi 4を2台使い、同じ不要通信をどの位置で破棄するかによって、受信側の処理負荷や同一ホスト上のWebサービスへの影響がどう変わるかを比較します。
構成は、通信を送る Raspberry Pi A と、
通信を受けて計測する Raspberry Pi B に分かれています。
Raspberry Pi AとRaspberry Pi Bは、外部ネットワークとは切り離した有線Ethernetの実験用LAN上に配置しました。
- Raspberry Pi A:
192.168.50.10 - Raspberry Pi B:
192.168.50.20
Raspberry Pi AではRustで実装した traffic-node を動かし、
Raspberry Pi Bに対して2種類の通信を同時に送ります。
1種類目は、実験用の負荷として流す IPv4 / UDP :4000 です。
UDPのペイロードは128 bytesに固定し、送信レートだけを段階的に変化させます。
2種類目は、Raspberry Pi B上のWebサービスが負荷中も正常に応答できているかを確認するための HTTP/1.1 over TCP :8080 です。
Raspberry Pi AからRaspberry Pi Bの /api/ping に対して、GETリクエストを約200ms間隔で繰り返し送信します。
ここで重要なのは、UDPとHTTPそのものの性能を比較しているわけではないという点です。
比較対象は、3条件全てで同じ IPv4 / UDP :4000 の負荷通信です。
変えているのは、そのUDP通信を
- XDPで破棄する
- nftablesで破棄する
- 利用者空間のUDPソケットまで到達させる
という破棄位置だけです。
HTTP通信は比較対象ではなく、UDP負荷をかけている間に同じRaspberry Pi B上で動いているWebサービスへ「どの程度影響が出るか」を確認するための通信として使います。
各HTTPリクエストでは新しくTCP接続を確立し、Connection: close を付けて接続を使い回さないようにしています。
そのうえで、
- HTTP成功率
- HTTP応答時間のp95
- 最大応答時間
を記録します。
Raspberry Pi Bでは、以下のプログラムを動かしています。
| プログラム | 役割 |
|---|---|
xdp-hello |
XDP/eBPFプログラムの読み込み・制御 |
experiment-runner |
破棄位置の切り替え、負荷条件の変更、計測、反復実行 |
observation-hub |
HTTPサービスの提供、イベント集約 |
| UDPソケット | 利用者空間まで到達したUDP :4000の受信数を計測 |
受信した通信は、概念的には
XDP → Linuxネットワークスタック内の処理 → Netfilter / nftables → 利用者空間
という経路を通ります。
nftablesはLinuxネットワーク処理とは別に存在する独立した「層」ではなく、NetfilterのhookとしてLinuxネットワークスタック内に組み込まれています。
今回のnftables条件では、inet familyの input hookにUDP :4000をdropするルールを追加しています。
また、今回使用したXDPは Generic XDP です。
Raspberry Pi Bの有線EthernetでNative XDPのattachも試みましたが、今回使用したカーネル・NICドライバの組み合わせでは利用できなかったため実測にはGeneric XDPを使用しました。
そのため、今回の結果をNative XDPの性能へそのまま一般化することはできません。
特に、Native XDPでよく説明される「通常のパケット構造を生成する前に破棄できる」という特性を、今回の測定結果の解釈にそのまま当てはめることはしていません。
利用者空間まで到達させる条件では、単にUDPソケットの受信バッファを溢れさせてカーネル側で破棄しているわけではありません。
experiment-runner がUDP :4000にソケットをbindし、recv_from() で実際に受信するたびにカウンタを加算しています。
つまりこの条件ではUDP通信を実際に利用者空間まで届け、受信処理まで行わせています。
また、負荷を生成する側のRaspberry Pi Aが先に限界へ達すると、Raspberry Pi B側の性能差を正しく評価できません。
そこで目標ppsだけではなく、実際に送信できたパケット数と計測時間から actual pps を算出しています。
目標ppsの90%未満しか送信できなかった測定は「測定不成立」とし、Raspberry Pi B側の性能の限界としては扱わないようにしました。
つまり今回の検証で観測したいものをもう一度整理すると、
同じ不要通信でも、どこまで処理させてから破棄するかによって、受信側で支払うコストや同一ホスト上のサービスへの影響はどう変わるのか
ということになります。
3つのパケット破棄条件
前章で説明した通り、この実験で変えるのは IPv4 / UDP :4000 をどこで破棄するかだけです。
比較したのは、次の3条件です。
| 条件 | XDPの処理 | nftablesの処理 | UDP :4000 の最終到達地点 |
|---|---|---|---|
| XDP |
XDP_DROP(自作プログラムの protect モード) |
実験用のルールはなし | XDPプログラムで破棄 |
| nftables |
XDP_PASS(monitor モード) |
inet family の input hookで drop
|
Netfilterの input hook |
| 利用者空間 |
XDP_PASS(monitor モード) |
実験用のルールはなし | UDPソケットで recv_from() して読み捨て |
ここでいう protect と monitor はXDPの標準的な動作モードではなく、今回実装した xdp-hello 内で定義しているモードです。
-
protect:条件に一致するUDP :4000へXDP_DROPを返す -
monitor:観測は行うが、対象通信にはXDP_PASSを返す
3条件とも、同じUDPソケットをRaspberry Pi B上で起動したままにしています。
XDP条件やnftables条件ではそのソケットへ到達する前にUDP :4000が破棄されるため、利用者空間まで通信は届きません。
一方で利用者空間条件では、XDPとnftablesのどちらでも破棄せずにUDPソケットまで実際に通信を届けます。
UDPソケットでは recv_from() でデータを受信した後、その内容を利用せずに読み捨てます。
つまりこの3条件で切り替えているのは、
同じ通信を、システムのどの位置で破棄するか
という一点です。
なお、今回使用したXDPは Generic XDP です。
Native XDPでは対応するNICドライバの受信経路上でXDPプログラムが実行されますが、Generic XDPでは実行位置が異なります。
そのため以降で示すXDP条件の結果は全てGeneric XDPの環境で得られたものとして扱い、Native XDPの性能へそのまま一般化することはしません。
実装
主要なコンポーネントはRustで実装しています。
XDP / eBPFの実装には Aya を使用し、パケットを処理するeBPFプログラムだけでなく、負荷の生成、実験条件の切り替え、計測、観測結果の集約までRustで実装しました。
主要なコンポーネントは次の4つです。
-
traffic-node(Raspberry Pi A)
UDP負荷を生成し、Raspberry Pi Bへ送信します。
同時に、約200ms間隔でHTTPリクエストを送り、負荷中もWebサービスが正常に応答できているかを確認します。 -
xdp-hello(Raspberry Pi B)
Ayaを使って実装したXDPプログラムと、その読み込み・制御を行うローダーです。
eBPF側ではパケットを解析し、実験条件に応じてXDP_PASSまたはXDP_DROPを返します。 -
experiment-runner(Raspberry Pi B)
XDPとnftablesの条件切り替え、利用者空間のUDP受信、CPU使用率などの計測、反復実験、終了時の後片付けまでをまとめて制御します。 -
observation-hub(Raspberry Pi B)
HTTPサービスを提供するとともに、各コンポーネントから送られてくる観測結果や実験結果を集約します。
このように、通信を生成する側からXDPによるパケット処理、実験全体の制御、結果の集約までを複数のRustプログラムに分けて実装しています。
以下ではこのうち3つの破棄条件を実現している部分と、実験を自動化する仕組みを順に見ていきます。
1. XDP / eBPFでのパケット処理
XDP側では、受信したパケットをEthernetヘッダから順に解析しています。
eBPFでは、パケットの範囲外を参照するようなメモリアクセスは許されません。
そのためヘッダを読み取る際には ctx.data() と ctx.data_end() を使い、読み取ろうとしている位置がパケット終端を超えないことを確認してからアクセスしています。
今回の実装では、この境界チェックを ptr_at() にまとめています。
#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, u32> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();
if start + offset + len > end {
return Err(xdp_action::XDP_ABORTED);
}
Ok((start + offset) as *const T)
}
この関数を経由してポインタを取得することで、EthernetヘッダやIPv4ヘッダ、UDP / TCPのポート番号を読み取る前に必要な範囲が存在することを確認しています。
今回対象にしているのはEthernet上のIPv4です。
まずEtherTypeを確認し、IPv4でなければそのまま XDP_PASS します。
その後、IPv4ヘッダから以下の情報を読み取ります。
- プロトコル番号
- 送信元IPアドレス
- 宛先IPアドレス
UDPの場合はさらに送信元ポートと宛先ポートを取得し、設定された条件から通過させるか破棄するかを決定します。
判定と破棄を行う中心部分は次のようになっています。
let action = udp_action(dst_port);
record_packet(
src_addr,
dst_addr,
src_port,
dst_port,
IPPROTO_UDP,
action,
);
if action == PACKET_ACTION_DROP {
return Ok(xdp_action::XDP_DROP);
}
udp_action() では、BPF mapの DEFENSE_CONFIG に設定されている
- 現在の動作モード
- 破棄対象のUDP宛先ポート
を確認します。
今回の実験では、protect モードかつ宛先ポートが4000の場合に PACKET_ACTION_DROP と判定し、XDPプログラムから XDP_DROP を返します。
つまり利用者空間へ「このパケットを捨ててください」と通知しているわけではなく、各パケットの判定から破棄までをXDPプログラム内で完結させています。
一方で観測処理そのものが負荷となり、測定結果を変えてしまうことは避ける必要があります。
そこでPASS / DROPの全数は PerCpuArray を使ったCPUごとのカウンタに記録し、個々のパケット情報をすべて利用者空間へ送ることはしていません。
負荷対象であるUDP :4000については、64パケットに1件だけをRingBufへ送り、画面表示などに使用するようにしました。
pub const ATTACK_FLOW_SAMPLE_EVERY: u64 = 64;
つまり、
- 集計値:BPF mapで全数を保持
- 個々の通信イベント:RingBufへサンプリングして送信
と役割を分けています。
また、packet-poipoi自身の観測・制御に使うTCP通信まで観測すると、観測イベントを送るために発生した通信を再び観測してさらに新しいイベントを生成する自己観測ループが起こり得ます。
そのためSSHや実験の制御、またダッシュボード表示などに使用するTCP通信は観測対象から除外しています。
なお、現在のパーサは実験用途に必要な範囲へ意図的に限定しています。
IPv6やVLANタグ付きフレームには対応しておらず、IPv4についてもIHLが5ではない、つまりIPv4オプションを含むパケットは解析せず XDP_PASS します。
また、IPv4フラグメントを考慮したトランスポート層の解析も実装していません。
汎用的なパケットフィルタを作ることよりも今回の実験条件を明確に限定し、その中で破棄位置の違いを比較することを優先しました。
2. nftablesによるフィルタリング
2つ目の条件では、XDPでは通信を通過させ、LinuxのNetfilterでUDP :4000を破棄します。
今回使用したルールではinet familyに実験専用のtableを作成し、input hookへ設定しています。
設定しているルールは、概ね次のようなものです。
table inet packet_poipoi_experiment {
chain input {
type filter hook input priority 0; policy accept;
udp dport 4000 drop
}
}
input hookは、受信したパケットがRaspberry Pi B自身を宛先とするものだとルーティング処理で判断された後に通過する位置です。
そのため、この条件では
XDPを通過 → Linuxのネットワーク処理を進む → Netfilterの input hookで破棄
という流れになります。
XDP条件ではXDPプログラムで破棄するのに対し、nftables条件ではパケットをNetfilterの input hookまで通過させて利用者空間のUDPソケットへ配送される前に破棄します。
なお通常の構成では、この位置に到達するまでにConntrackによる接続追跡処理も行われます。
ただしConntrackを無効化・迂回する構成も存在するため、今回の比較では「Conntrackの有無」そのものを独立した実験条件として扱っているわけではありません。
また、実験用のnftables設定は既存のルールへ直接追加せずにpacket_poipoi_experiment という専用テーブルとして分離しました。
条件を切り替える際にはこのtableを作成・削除することで、実験終了時に不要なフィルタリング設定がシステム上へ残らないようにしています。
3. 利用者空間での処理
3つ目の条件では、XDPとnftablesの両方でUDP :4000を通過させます。
Raspberry Pi Bでは experiment-runner がUDP :4000へソケットをbindしており、Tokioの UdpSocket::recv_from() を使って実際にデータを受信します。
let socket = UdpSocket::bind(listen).await?;
tokio::spawn(async move {
let mut buffer = [0_u8; 2048];
loop {
match socket.recv_from(&mut buffer).await {
Ok(_) => {
received.fetch_add(1, Ordering::Relaxed);
}
Err(error) => {
// エラー処理
}
}
}
});
この条件では、UDP :4000を利用者空間まで確実に到達させることが重要です。
利用者空間条件では、UDP :4000にbindしたソケットから recv_from() を継続的に呼び出し、到着したデータグラムを実際に読み取ります。
そのため、ソケットを読み取らずに受信キューを溢れさせ、カーネル内で破棄される状態を利用しているわけではありません。
したがって、この条件では
Linuxのネットワーク処理 → Netfilter → UDPソケットへの配送 → 利用者空間での受信
まで処理を進めます。
XDPやnftablesで破棄する条件とは異なり、ソケットへの配送や利用者空間での受信処理まで発生することになります。
受信後はデータの内容そのものについて追加処理を行わず、受信できたパケット数だけをカウンタへ記録します。
received.fetch_add(1, Ordering::Relaxed);
このカウンタは受信数の記録だけを目的としており、他のメモリアクセスとの順序関係を保証する必要がないため、Ordering::Relaxed を使用しています。
そのため、記事中では便宜上『利用者空間で破棄』と表現していますが、厳密にはUDP :4000を利用者空間のソケットまで到達させ、プログラムで受信した後に読み捨てています。
4. ベンチマークと実験の自動化
3つの条件を手動で切り替えながら測定すると、設定ミスだけでなく測定する順番による偏りも入りやすくなります。
そこで条件の切り替えから計測→結果の記録→後片付けまでを experiment-runner から自動で実行できるようにしました。
1回の測定では、おおまかに次の処理を行います。
- XDPとnftablesを対象の条件へ切り替える
- 条件切り替え後に一定時間待つ
- Raspberry Pi BのCPU、NET_RX、UDP受信数の開始値を取得する
- Raspberry Pi Aへ指定ppsでUDP送信を開始させる
- UDP負荷をかけながらHTTPサービスの成功率と応答時間を測る
- 10秒後にUDP送信を停止する
- CPU、NET_RX、UDP受信数の終了値を取得する
- 実際に送信できたパケット数からactual ppsを計算する
- 1回分の測定結果として保存する
CPU使用率は、Raspberry Pi Bの /proc/stat にある全CPU合算の累積カウンタを測定開始前と終了後に取得して計算しています。
busy % = (Δtotal - Δidle) / Δtotal × 100
つまりある瞬間のCPU使用率を見るのではなく、測定区間全体におけるRaspberry Pi Bの平均的なCPU使用率を求めています。
なおここで測っているのはマシン全体のCPU使用率であり、CPUコアごとの使用率や特定の処理だけが消費したCPU時間ではありません。
測定順による偏りを減らす
3つの条件を毎回同じ順番で測ると、Raspberry Piの温度状態や時間経過など測定順序に依存する要因が特定の条件に偏って影響する可能性があります。
そこで、反復ごとに測定する条件の順番をずらしました。
| 反復 | 1番目 | 2番目 | 3番目 |
|---|---|---|---|
| 1回目 | 利用者空間 | nftables | XDP |
| 2回目 | nftables | XDP | 利用者空間 |
| 3回目 | XDP | 利用者空間 | nftables |
UDPの送信レートについても、
低いppsから高いppsへ測る回と、高いppsから低いppsへ測る回を交互にしました。
1回目: 500 → 2,000 → 5,000 → 10,000 → 20,000 → 50,000 pps
2回目: 50,000 → 20,000 → 10,000 → 5,000 → 2,000 → 500 pps
3回目: 500 → 2,000 → 5,000 → 10,000 → 20,000 → 50,000 pps
送信側の限界を受信側の限界と区別する
もう1つ注意したのが、負荷を生成するRaspberry Pi A側の性能です。
例えば100,000 ppsを指定しても、Raspberry Pi Aが実際には70,000 ppsしか生成できていなければ、その結果からRaspberry Pi Bが100,000 ppsを処理できたとは当然言えません。
そこで設定した目標ppsとは別に、実際に送信したパケット数と計測時間からactual ppsを算出しています。
actual pps = packets sent / measured duration
さらに、
actual pps / target pps >= 90%
を満たさなかった測定は「測定不成立」とし、Raspberry Pi B側の性能限界を判断する材料には含めないようにしました。
実験終了時のクリーンアップ
測定を繰り返すためには、各試行の終了後にRaspberry Pi Bを元の状態(初期状態)へ戻す必要があります。
例えば前の試行で使用したnftablesのルールやXDPの破棄設定が残っていると、次の試行にもその設定が影響してしまいます。
そこで experiment-runner を設けて、実験の終了時に次の処理を行うようにしました。
- 実験用のnftables tableを削除する
- XDPを
monitorモードへ戻す - Raspberry Pi Aで動作しているUDP送信を停止する
通常の終了時だけでなく、Ctrl+C で中断された場合にも tokio::signal::ctrl_c() でSIGINTを受け取り、同じクリーンアップ処理を実行するようにしています。
また、nftablesについてはRustの Drop traitを使った NftGuard も用意しています。
impl Drop for NftGuard {
fn drop(&mut self) {
let _ = Self::disable();
}
}
通常のクリーンアップ処理に加えて、NftGuard が破棄される際にも実験用tableの削除を試みる構成です。
このように、
条件設定 → 試行 → 観測 → 記録 → 後片付け
までをまとめて実験のライフサイクルとして扱っています。
性能評価
ここでは、3つの破棄条件を同じ負荷条件で比較し、Raspberry Pi BのCPU使用率とHTTPサービスへの影響を評価します。
実験条件と計測方法
実装した3つの条件について、
UDPの送信レートを段階的に上げながらRaspberry Pi BのCPU使用率と、同一ホスト上で動作するHTTPサービスへの影響を測定しました。
本実験で使用したUDP送信レート(pps)は、次の6段階です。
500
2,000
5,000
10,000
20,000
50,000
それぞれの送信レートについて、
- XDPで破棄
- nftablesで破棄
- 利用者空間まで到達
の3条件を測定しました。
1回の測定時間は10秒とし、各条件を3回ずつ測定しています。
また、今回のXDP条件は前述の通りすべて Generic XDP です。
CPU使用率・HTTP成功率・遅延の結果
まずCPU使用率を見ると、UDPの送信レートが高くなるにつれて3条件の差が見えるようになりました。
50,000 ppsを設定したときの3回の測定結果は次の通りです。
| 条件 | 1回目 | 2回目 | 3回目 | 中央値 |
|---|---|---|---|---|
| XDP | 7.2% | 5.0% | 5.3% | 5.3% |
| nftables | 6.9% | 6.7% | 4.6% | 6.7% |
| 利用者空間 | 9.8% | 11.6% | 7.2% | 9.8% |
3つのパターンのうち、利用者空間までUDPを到達させた条件でCPU使用率が最も高くなりました。
50,000 ppsにおける中央値を比較すると、
XDP 5.3%
nftables 6.7%
利用者空間 9.8%
となりました。
XDPとnftablesの差は1.4ポイント、nftablesと利用者空間の差は3.1ポイントです。
今回の環境ではXDPとnftablesの間よりも、nftablesと利用者空間の間でCPU使用率の差が大きくなりました。
一方で、HTTPサービスへの明確な影響は確認できませんでした。
50,000 pps設定時のHTTP成功率は、3条件・3反復のすべてで100%でした。
HTTP応答時間のp95は次の通りです。
| 条件 | 1回目 | 2回目 | 3回目 |
|---|---|---|---|
| XDP | 1 ms | 1 ms | 1 ms |
| nftables | 1 ms | 1 ms | 1 ms |
| 利用者空間 | 2 ms | 1 ms | 1 ms |
すべての測定で、
HTTP成功率 >= 99%
HTTP応答時間 p95 <= 100 ms
という事前に設定した基準を満たしました。
つまり50,000 ppsまでの測定ではCPU使用率に条件間の差が見られた一方で、HTTP成功率やp95応答時間には、サービス維持の可否を分けるほどの差は現れませんでした。
予想と異なった結果
開発当初は、パケットをより早い位置で破棄するほどRaspberry Pi Bの処理負荷が下がり、UDPの送信レートを上げていけばHTTPサービスを維持できる限界にも条件ごとの差が現れると考えていました。
実際には、CPU使用率には条件による差が見られたものの、HTTPサービスの維持限界までは確認できませんでした。
XDPとnftablesの差はそれほど大きくなかった
50,000 pps設定時のCPU使用率の中央値は、
XDP 5.3%
nftables 6.7%
利用者空間 9.8%
でした。
XDP条件が最も低い値にはなりましたが、XDPとnftablesの差は1.4ポイントでした。
一方でnftablesと利用者空間の差は3.1ポイントあり、今回の環境ではXDPとnftablesの間よりも、利用者空間まで通信を届けた場合の方がCPU使用率の差が大きく現れました。
今回使用したのはNative XDPではなくGeneric XDPです。
Generic XDPとNative XDPではパケットを処理する位置が異なるため、この点がXDPとnftablesの差に影響した可能性はあります。
ただし今回測定したのはRaspberry Pi B全体のCPU使用率であり、ネットワークスタック内の各処理に要したCPU時間を個別に測定したわけではありません。
そのため、XDPとnftablesの差が小さかった理由を今回の結果だけから特定することはできません。
HTTPサービスの限界まで到達できなかった
もう1つ予想と異なったのが、HTTPサービスを維持できなくなる負荷まで到達できなかったことです。
正式な測定では50,000 ppsまで送信レートを上げましたが、3条件ともHTTP成功率は100%を維持しました。
そこで追加で、60,000〜100,000 ppsまで送信レートを上げる探索的な測定も行いました。
100,000 ppsを設定した1回の測定結果は次の通りです。
| 条件 | actual pps | CPU使用率 | HTTP成功率 |
|---|---|---|---|
| XDP | 92,191 pps | 7.3% | 100% |
| nftables | 91,291 pps | 8.1% | 100% |
| 利用者空間 | 95,281 pps | 16.6% | 100% |
HTTP応答時間のp95も0〜1msで、3条件ともサービス維持の基準を満たしました。
ただし、この追加測定は各条件1回のみの探索的な測定です。
また、100,000 ppsを設定したのに対してactual ppsは約91,000〜95,000 ppsでした。
これは測定成立の基準である90%を満たしてはいるものの、目標ppsとの乖離は大きくなっています。
負荷を生成する側のRaspberry Pi A自体も、これ以上の高い送信レートを生成する余裕がなくなってきていることが分かります。
そのため、今回の構成ではRaspberry Pi B上のHTTPサービスが維持できなくなる地点まで十分に負荷を上げることができませんでした。
なお、Raspberry Pi BのCPU使用率は全CPUを合算した値です。
今回の計測からは、ネットワーク処理が特定のCPUコアへ偏っていたか、HTTP処理とどのように分散されていたかまでは分かりません。
HTTPサービスが限界に到達しなかった理由をより深く調べるには、コアごとのCPU使用率やIRQ / SoftIRQの分布なども合わせて計測する必要があります。
したがって、今回確認できたのは、
- UDPの破棄位置によってRaspberry Pi B全体のCPU使用率には差が生じた
- 今回の環境では、利用者空間まで通信を届けた条件でCPU使用率が最も高かった
- XDPとnftablesの差は比較的小さかった
- 測定できた範囲では、HTTPサービスの維持限界に条件ごとの差は確認できなかった
というところまでです。
考察と得られた知見
今回の実験を通して得られたものは、単に「XDPの方がCPU使用率が低かった」という結果だけではありませんでした。
むしろ自分にとって大きかったのは、
- 普段はクラウド基盤の中に隠れている処理を、自分で組み直して理解できたこと
- エッジ環境では数ポイントのCPU使用率の差も無視しにくいこと
- 処理をどのレイヤの責務として持たせるかを考える必要があること
の3つです。
クラウドの裏側にある処理を、自分で組み直して理解する
普段AWSやGoogle Cloud上でWebアプリケーションを作っていると、アプリケーションに通信が届くまでの多くの処理を自分で意識する必要はありません。
ファイアウォール、ロードバランサ、DDoS対策など不要な通信をアプリケーションより前で処理するための仕組みの多くは、クラウドの基盤やマネージドサービスとして提供されています。
そのためアプリケーション開発者から見ると、
「HTTPリクエストがアプリケーションに届いたところ」
からシステムが始まっているように見えることがあります。
そこを今回Raspberry PiとLinuxだけで環境を組み、
XDP → Linuxネットワークスタック → Netfilter / nftables → ソケット → 利用者空間
という経路を一つずつ自分で触ったことで、普段はクラウド側に隠れている処理をかなり具体的に理解できるようになりました。
個人的には今回の実験でCPU使用率に差が出たこと以上に、普段利用している抽象化を一度取っ払って、
「通信がアプリケーションへ届くまでに、OSは実際に何をしているのか」
を自分で構築しながら確認できたことが大きな収穫でした。
エッジ環境では数ポイントの差も無視できない
先述したように50,000 pps設定時のCPU使用率の中央値は、
XDP 5.3%
nftables 6.7%
利用者空間 9.8%
でした。
絶対値だけを見ると、これはほんの数ポイント程度の違いにも見えます。
しかし今回使用したRaspberry Piのようなエッジ端末では、CPU性能以外にも利用できるリソースにかなり制約があります。
そのためこの数ポイントを単純に「小さな差」として扱うこともできないと感じました。
特に今回の利用者空間条件では、不要なUDP通信を利用者空間まで到達させてプログラム側で受信しています。そのため、XDPやnftablesで破棄する条件よりも後段の処理までCPUが担うことになります。
その通信を処理する必要がなければ、その分のリソースを本来動かしたいサービスへ残すことができます。
もちろん、今回測定したのはRaspberry Pi B全体のCPU使用率であり、この差があらゆる環境でそのまま再現されるわけではありません。
それでも、リソースが潤沢とは限らないエッジ環境では、
「不要な処理をどこまで後段へ持ち込むか」
を考える意味は十分にあると感じました。
処理する場所を、「責務の分離」として考える
今回の結果を、「できるだけXDPで処理すればよい」と単純に捉えるのも違います。
どの位置で処理できるかは、その時点で何を根拠に判断できるかによって変わります。
今回のUDP :4000であれば、
- IPプロトコル
- IPアドレス
- ポート番号
といった情報だけで対象を判定できるため、XDPやnftablesの段階で破棄できます。
一方で、HTTPリクエストの内容や認証状態、アプリケーション内部の状態を見なければ判断できない処理は当然そこまで通信を届ける必要があります。
つまり重要なのは単純に「できるだけ早く処理する」ことではありません。
その判断に必要な情報が揃った段階で、それより後ろへ不要な処理を持ち込まないこと
が重要だと考えています。
ネットワークの情報だけで不要だと判断できる通信を、わざわざアプリケーションまで届ける必要はありません。
反対に、アプリケーションでしか判断できないことを無理に低いレイヤで処理することも好ましくありません。
今回の実験を通して、こうした
「前段で判断できることは前段で処理し、後段には後段でしかできない仕事を残す」
という責務の切り分けも、システム設計では重要なのだと感じました。
「ぱけっとぽいぽい」は、パケットをどこで破棄するかを比較するごく小さな実験です。
それでも実際に作ることによって、
「この処理を、本当にここまで持ってくる必要があるのか?」
と考えることは、ネットワークに限らずシステム全体の設計にもつながるものでした。
技育博Vol.02で展示してみて
「ぱけっとぽいぽい」は、サポーターズ主催の学生エンジニア向けのプロダクト展示イベントである 技育博Vol.02 に出展しました。
技育博は学生が自分で開発したプロダクトを会場で展示し、企業のエンジニアや人事、他の学生に直接見てもらうイベントです。
出展には事前選考があり、今回のVol.02は応募に対して採択倍率が2倍強程度だったと認識しています。
もともと万人受けを狙った作品ではなく、かなり自分の興味に寄せた(ある意味では)自己満足的な展示でした。
そのためWebアプリやモバイルアプリの展示と比べると、ひと目で用途や面白さが伝わりにくく会場ではどうしても見劣りする部分がありました。
画面を見ればすぐに用途が分かるサービスとは違い、
- XDP
- nftables
- パケット処理
- CPU使用率
といった話は、そもそも何を面白がればよいのかが初見では伝わりにくいです。
そのため全体として広く受けた展示ではなかったと思います。
記憶違いだったら本当に申し訳ないのですが、自分が認識している範囲ではプラチナスポンサーの各社には一度もブースを見てもらえませんでした。
自分の呼び込み方や展示の見せ方にも改善の余地はかなりあったと思います。
ただ、こういう題材を展示に持っていくと興味を持つ人がかなり限られるという現実も改めて感じました。
SREやインフラ、ネットワークに関わるエンジニアの方が足を止めてくれたのはかなり嬉しかったです。
一方でそうした方々と実際に話していると、今度は別の課題も見えてきました。
技術的に尖ったテーマを展示するのであれば、そのテーマについて突っ込まれたときに、もっと深いところまで話せる必要がある
ということです。
XDPやLinuxのネットワークスタックを題材にしている以上、詳しい人から見れば自分の理解がまだ浅い部分も当然あります。
展示中にスポンサーのエンジニアの方と話していて、
「この題材を持ってくるのであれば、自分はもっと高い解像度で話せるべきだった」
と感じる場面が何度かありました。
専門外の人には入口が難しく、専門の人には深さを見られる(そして浅さを暴かれる)。
低レイヤの技術を展示する難しさは、まさにそこにあるのかもしれません。
今回の展示を通して、
分かりやすく見せることと、技術的に深く理解することの両方が必要
だと実感しました。
今後にどう繋げていくか
「ぱけっとぽいぽい」は、あくまでネットワーク上のパケットを題材にした小さな実験です。
ただ今回作っていて面白かったのは、XDPそのものよりも、
低いレイヤで実際の挙動を観測し、必要に応じてその場で介入する
という考え方でした。
今回の実験では、
- パケットがどこまで到達したかを観測する
- XDP、nftables、利用者空間のどこで処理を止めるかを切り替える
- 実行前後のCPU使用率やHTTPサービスの状態を記録する
- 実験が終わったら設定を元に戻す
という仕組みを作りました。
これを今後は、ネットワークだけではなくソフトウェアの実行そのものへ広げていきたいと考えています。
例えばあるプログラムを実行したときに、
- どのファイルを書き換えたのか
- どのプロセスを生成したのか
- どのネットワーク通信を行ったのか
- OSにどのような変更を加えたのか
といった実際の挙動を低いレイヤから観測できれば、
「このプログラムは何をするつもりなのか」ではなく、
「実際にシステムへ何をしたのか」
を基準にソフトウェアを評価できるようになります。
さらに変更前の状態を保存しておけば、
観測 → 保存 → 実行 → 再観測 → 差分確認 → 必要なら元に戻す
という流れも作れます。
最終的には、予測しづらいソフトウェアでもいきなり本番環境で動かすのではなく、
一度安全な環境で実行し、実際に起きた変更と、その変更を元に戻せるかを確認してから扱う
ための基盤へ発展させたいと思っています。
「ぱけっとぽいぽい」で扱ったのは、通信というシステムの一部分だけでした。
しかし、
低いレイヤから実際の挙動を観測し、必要な場所で介入する
という考え方自体は、もっと広い範囲へ応用できそうです。
今回作ったものを完成品として終わらせるのではなく、ここで得たLinux、Rust、eBPF、実験設計そのものの知識を使いながら、次はソフトウェア実行全体の安全性を扱う仕組みに発展させていきます。
おわりに
Raspberry PiとRustを使って、普段はあまり意識することのない「通信がアプリケーションへ届くまで」の処理を、自分で実装しながら追いかけてみました。
XDP、nftables、利用者空間という異なる位置で同じUDP通信を処理してみると「できるだけ手前で破棄すれば、それだけ大きくCPU負荷が下がる」という単純な話ではなく、どの段階で何を判断し、どこまで後段の処理へ渡すのかという設計の問題が見えてきました。
また実験そのものについても、目標ppsと実際の送信レートを区別したり、測定順による偏りを減らしたり、測定できなかった限界をそのまま「確認できなかったもの」として扱ったりと実際に手を動かしたからこそ気づけたことが多くありました。
アプリケーション開発の抽象化は素晴らしいものです。
その前提を基にしても、その下でOSやネットワークがどのように汗をかいているのかを知ることで、システム全体のアーキテクチャを見る解像度は確実に上がりました。
一方でまだ理解が浅い部分も多く、技育博で展示した際にもそれを改めて痛感しました。
今後もさらに理解を深めていきたいと思っています。
そして次は今回扱った「低いレイヤから実際の挙動を観測し、必要な場所で介入する」という考え方を、ネットワークだけでなくソフトウェアの実行基盤全体へ広げていくつもりです。
これからも「ブラックボックスの中身を暴く」楽しさを忘れず、低レイヤの技術を探求しながらそれを実際のシステム設計へ還元できるエンジニアを目指してコードを書いていきます。
かなり長文になってしまいましたが、最後まで読んでいただきありがとうございました。










