XDPを本番環境で6ヶ月運用して変わったこと
10 GbpsのエッジサーバーでiptablesベースのDDoSフィルタをXDPプログラムに切り替えたとき、チームの第一反応は懐疑的なものだった。eBPFは何年も前から注目していたが、XDPは中規模の構成には過剰に思えた。6ヶ月後の今、これはこのスタックで下した最善のインフラ判断だと断言できる。以前は緊急のレート制限やアップストリームのnullroutingが必要だったトラフィックスパイクも、今では安定して乗り越えられている。
この記事では、XDPが従来のパケット処理とどう違うのか、実際に優れている点(そうでない点も含めて)、動作環境の構築方法、そしてパケットフィルタとシンプルなレイヤー4ロードバランサの実装について説明する。
アプローチ比較:XDP vs iptables vs DPDK
コードに触れる前に、各ツールがLinuxネットワークスタックのどこに位置するかを理解しておくと役立つ。アーキテクチャの違いがパフォーマンスの差を説明してくれる。
iptables / nftables
iptablesはカーネルのnetfilterフレームワーク内でパケットを処理する。パケットがルールに到達する時点で、カーネルはすでにsk_buff(ソケットバッファ)を割り当て、パケットをカーネルメモリにコピーし、複数のネットワークスタック層を通過させている。
ステートフルなルールを使うかどうかに関わらず、すべてのパケットはconntackを通過する。1秒あたり100万パケットの処理では、このオーバーヘッドがすぐに積み重なり、CPU使用率が上昇する。シンプルなDROPルールでさえ、60〜120マイクロ秒のレイテンシが発生することを覚悟すべきだ。
DPDK(Data Plane Development Kit)
DPDKはカーネルを完全にバイパスし、ユーザースペースから直接NICをポーリングする。スループットは印象的で、100 GbpsカードでのラインレートDPDKも実現可能だ。ただし代償がある。ポーリング専用にCPUコアを割り当て、DPDKがNICの排他的な所有権を持つことになる。
そのインターフェース上では通常のLinuxネットワークスタックが機能しなくなる。DPDKコードの記述も、メモリプール、mbuf構造体、リングバッファを自前で管理することを意味する。実際には、専門チーム、慎重なキャパシティプランニング、そしてSSH接続用の別の管理インターフェースが必要になる。
XDP(eXpress Data Path)
XDPはカーネルの受信パスの最も早い段階、つまりsk_buffが割り当てられる前のNICドライバ内部で、eBPFプログラムを実行する。ドロップすべきパケットは残りのスタックに一切触れない。リダイレクトや変更が必要なパケットはゼロコピーで処理される。
DPDKとは異なり、XDPは通常のLinuxネットワークスタックと共存できる。インターフェースはOSから見え続け、マシンへのSSH接続も維持される。またip、ss、tcpdumpなどのツールも、インターセプトされていないトラフィックに対して引き続き機能する。
私の環境でのベンチマーク結果(Intel X710、Xeon E-2288G、Debian 12):
ツール | 64バイトUDPフラッド | CPU(1コア) | レイテンシ(p99)
----------- | ------------------ | ------------ | ----------------
iptables DROP | ~800 Kpps | 95% | 80–120 µs
nftables DROP | ~1.1 Mpps | 88% | 60–90 µs
XDP DROP | ~14 Mpps | 12% | 1–3 µs
シングルコアで1400万パケット/秒のDROPレート、p99レイテンシ3マイクロ秒以下という数値は、ラボ環境の産物ではない。これらの数値は、9 Mppsのピークを記録した実際のUDP増幅攻撃中にxdp-bench dropで計測したものだ。
メリットとデメリット
XDPへの投資が価値ある理由
- カーネルモジュール不要のカーネル統合 — eBPFプログラムは検証された上で安全にロードされる。カーネルの再コンパイルは不要。
- 通常のLinuxネットワーキングが引き続き機能 — DPDKとは異なり、SSHセッションが維持される。
- ライブな状態更新のためのeBPFマップ — XDPプログラムを稼働させたまま、ユーザースペースからブロックリストへのIP追加、バックエンドの重みの調整、per-CPUカウンタの読み取りが可能。再起動は一切不要。
- 複数のアタッチモード:ネイティブ(ドライバレベル、最速)、オフロード(NIC自体がプログラムを実行)、ジェネリック(ソフトウェアフォールバック、任意のNICで動作するが低速)。
- コンポーザブル — libxdpのプログラムディスパッチャを使って複数プログラムを連鎖させられる。
XDPの弱点
- 学習曲線が急峻 — eBPF Cは制約が多い。無制限ループ不可、動的メモリ割り当て不可、スタック制限512バイト。バリファイアは不透明な形でプログラムを拒否することがあり、数時間のエラー経験を積まないと理解しにくい。
- ネイティブモードはドライバサポートが必要 — Intel、Mellanox、Broadcomのメインストリームなら問題ないが、古い機器や廉価なハードウェアはジェネリックモードにフォールバックし、低速になる。
- iptablesよりデバッグが難しい —
bpf_trace_printkは存在するがオーバーヘッドがある。本番環境のデバッグはeBPFマップとbpftoolに依存する。 - インバウンドのみ、conntackなし — XDPは受信パケットのみを処理する。完全なステートフルファイアウォールの動作が必要な場合は、nftablesが引き続き必要になるか、XDPとTC(Traffic Control)eBPFを組み合わせてエグレスに対応する必要がある。
推奨セットアップ
ほとんどのチームにとって現実的な出発点:
- XDPをインバウンドフィルタリングに使用 — ブロックリスト、レート制限、DDoS緩和
- ステートフルなルール、アウトバウンドフィルタリング、DNATにはnftablesを維持
- ユーザースペースでのパケット処理が必要な場合はXDP リダイレクト + AF_XDP を使用
- 生の
bpf()システムコールを直接使うのではなく、libxdp(xdp-toolsプロジェクト)でXDPプログラムを管理する
最低カーネルバージョン:安定したXDPサポートには5.10以上が必要。マルチプログラムサポートと改善されたマップヘルパーを備えたカーネル6.1以上(Debian 12、Ubuntu 22.04 HWE)が推奨ターゲット。
実装ガイド
1. 依存関係のインストール
# Debian 12 / Ubuntu 22.04
apt install -y clang llvm libelf-dev libbpf-dev linux-headers-$(uname -r) \
bpftool xdp-tools iproute2 gcc make
# カーネルのBPFサポートを確認
bpftool feature | grep -E 'xdp|prog_type'
2. eBPF Cで基本的なXDPパケットフィルタを記述する
このプログラムは、ブロックリスト内のソースIPからのポート53宛てのUDPパケットをすべてドロップする。DNS増幅攻撃への直接的な対策だ:
// xdp_filter.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
// eBPFマップ:ソースIPのブロックリスト(CIDRサポートのためLPMトライを使用)
struct {
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
__uint(max_entries, 10000);
__type(key, struct bpf_lpm_trie_key); // prefixlen + data
__type(value, __u32);
__uint(map_flags, BPF_F_NO_PREALLOC);
} blocklist SEC(".maps");
SEC("xdp")
int xdp_filter_prog(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
if (eth->h_proto != bpf_htons(ETH_P_IP))
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
if (ip->protocol != IPPROTO_UDP)
return XDP_PASS;
// ソースIPのLPMルックアップ
struct {
__u32 prefixlen;
__u32 addr;
} key = { .prefixlen = 32, .addr = ip->saddr };
if (bpf_map_lookup_elem(&blocklist, &key))
return XDP_DROP;
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
3. コンパイルとロード
# BPFバイトコードにコンパイル
clang -O2 -target bpf -c xdp_filter.c -o xdp_filter.o \
-I/usr/include/$(uname -m)-linux-gnu
# インターフェースにアタッチ(ネイティブモード推奨)
ip link set dev eth0 xdpdrv obj xdp_filter.o sec xdp
# ロードされたことを確認
bpftool net show dev eth0
bpftool map list
4. ユーザースペースからブロックリストを管理する(Python)
プログラムが起動したら、何もリロードせずにIPの追加や削除が可能だ。データプレーンは稼働し続け、マップを更新するだけでよい:
#!/usr/bin/env python3
# manage_blocklist.py — bpftoolを使用、追加のPython依存関係は不要
import socket
import subprocess
BLOCKLIST_MAP = "/sys/fs/bpf/blocklist" # ピン留めされたマップのパス
def block_ip(ip_str: str):
"""XDPブロックリストマップに/32ホストエントリを追加する。"""
packed = socket.inet_aton(ip_str).hex()
# キー:prefixlen(32、リトルエンディアン4バイト)+ addr(4バイト)
key_hex = "20000000" + packed # 0x20 = LEで32
value_hex = "01000000"
subprocess.run([
"bpftool", "map", "update", "pinned", BLOCKLIST_MAP,
"key", "hex", *[key_hex[i:i+2] for i in range(0, len(key_hex), 2)],
"value", "hex", *[value_hex[i:i+2] for i in range(0, len(value_hex), 2)]
], check=True)
print(f"ブロック済み: {ip_str}")
def unblock_ip(ip_str: str):
packed = socket.inet_aton(ip_str).hex()
key_hex = "20000000" + packed
subprocess.run([
"bpftool", "map", "delete", "pinned", BLOCKLIST_MAP,
"key", "hex", *[key_hex[i:i+2] for i in range(0, len(key_hex), 2)]
], check=True)
print(f"ブロック解除済み: {ip_str}")
if __name__ == "__main__":
import sys
if len(sys.argv) == 3 and sys.argv[1] == "block":
block_ip(sys.argv[2])
elif len(sys.argv) == 3 and sys.argv[1] == "unblock":
unblock_ip(sys.argv[2])
# ユーザースペースツールがパスでアクセスできるようにマップをピン留め
bpftool map pin id $(bpftool map list | grep blocklist | awk '{print $1}' | tr -d ':') \
/sys/fs/bpf/blocklist
# サービス再起動なしでIPをオンザフライでブロック・解除
python3 manage_blocklist.py block 198.51.100.42
python3 manage_blocklist.py unblock 198.51.100.42
5. XDP_TXを使ったシンプルなレイヤー4ロードバランサ
本番向けL4 LBは独立した記事に値する内容だが、核心的なアイデアは一文で説明できる。宛先ポートを解析し、5タプルのハッシュを使ってBPF配列マップからバックエンドを選択し、宛先IPとMACアドレスを書き換えて、同じインターフェース上で再送信するためにXDP_TXを返す。Katran(Metaのオープンソースのレイヤー4ロードバランサで、数千万パケット/秒を処理)とCiliumはどちらもこのアプローチをスケールで使用しており、ほとんどのチームが必要とする規模をはるかに超えて拡張できる。
# XDPプログラムのper-CPUドロップ/パスカウンタを確認
bpftool map dump id <stats_map_id>
# XDPプログラムをクリーンにデタッチ
ip link set dev eth0 xdp off
6. systemdで再起動後も永続化する
# /etc/systemd/system/xdp-filter.service
[Unit]
Description=XDP パケットフィルタ
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/ip link set dev eth0 xdpdrv obj /opt/xdp/xdp_filter.o sec xdp
ExecStop=/usr/sbin/ip link set dev eth0 xdp off
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now xdp-filter
モニタリングと可観測性
最初からXDPプログラムにper-CPUカウンタを追加しておくこと。カウンタが紐付いていないドロップされたパケットは、深夜2時のインシデント中に絶対に経験したくないデバッグセッションへの招待状だ。
# XDPドロップカウンタをリアルタイムで監視(マップとしてエクスポートしている場合)
watch -n 1 'bpftool map dump name xdp_stats'
# インターフェースごとのカーネル側XDP統計
ip -s link show dev eth0 | grep -A2 'RX\|TX'
# bpftraceワンライナー:すべてのXDP判定をトレース
bpftrace -e 'kprobe:xdp_do_generic_redirect { @[retval] = count(); }'
おわりに
本番エッジノードでXDPを6ヶ月運用した後、インバウンドフィルタリングを純粋なiptablesに戻すことは考えられない。パフォーマンスの余裕があることで、同じサーバーが以前は緊急のレート制限やアップストリームのnullroutingが必要だったトラフィックイベントを処理できるようになった。
純粋なスループットは話の一部に過ぎない。XDPが日々の運用で魅力的なのは、その組み合わせにある。通常のLinuxサーバーの運用モデルを維持しながら、カーネルレベルのパフォーマンスを実現できる点だ。モニタリングは引き続き機能する。
SSHも機能し続ける。tcpdumpも機能し続ける。eBPFマップのインターフェースにより、データプレーンプログラムに触れることなく、Pythonスクリプトからリアルタイムで脅威に対応できる。自社でエッジインフラを運用するチームにとって、これは単に従来のものが高速化されたというだけでなく、根本的に異なる運用姿勢をもたらすものだ。

