XDP: Xử Lý Gói Ở Điểm Sớm Nhất, Viết Một Firewall
Part III dựng công cụ quan sát — execsnoop chỉ đọc, không can thiệp. Part IV bước vào nơi eBPF nổi tiếng nhất và cũng chủ động nhất: networking. Ở đây chương trình eBPF không chỉ nhìn gói, nó quyết định số phận từng gói — cho qua, vứt, bật ngược, đá sang nơi khác. Bài này bắt đầu từ hook sớm nhất: XDP.
XDP là gì, và tại sao "sớm nhất"
XDP (eXpress Data Path) gắn một chương trình eBPF thẳng vào driver của card mạng. Khi một gói tới, chương trình XDP chạy trước khi nhân cấp phát sk_buff — cấu trúc mô tả gói mà toàn bộ network stack dựa vào. Đây là điểm sớm nhất phần mềm có thể đụng vào một gói: gói còn nằm trong buffer của driver, chưa thành "gói của nhân".
Sớm tới mức đó có giá trị thật: nếu định vứt một gói (chống DDoS, firewall), vứt ở XDP nghĩa là không tốn một xu cấp phát sk_buff hay đi qua netfilter — Cloudflare, Facebook dùng XDP drop hàng triệu gói tấn công mỗi giây chính vì lẽ này.
Gói tới NIC
│
▼
┌─────────┐ XDP chạy ở ĐÂY (trong driver, trước sk_buff)
│ XDP │── trả verdict ──► DROP (vứt ngay, rẻ nhất)
└────┬────┘ TX (bật ngược ra cùng NIC)
│ PASS REDIRECT (đá sang NIC/CPU khác)
▼
cấp phát sk_buff
│
▼
tc ingress ──► network stack ──► socket (xem Bài 12)
Verdict: bốn số phận của một gói
Một chương trình XDP trả về một trong các hằng số — đó là toàn bộ giao diện của nó với nhân:
XDP_PASS— cho gói đi tiếp vào stack bình thường (cấpsk_buff, lên tc, IP, socket).XDP_DROP— vứt gói ngay tại driver. Không log, không ICMP báo lỗi, gói biến mất.XDP_TX— gửi gói ngược ra cùng interface nó vừa tới (load balancer, phản xạ).XDP_REDIRECT— đẩy gói sang interface khác, sang CPU khác, hoặc lên một socket AF_XDP.
Ta sẽ viết một firewall: drop ICMP, pass mọi thứ khác.
Đọc gói trong XDP: con trỏ và bounds check
XDP đưa cho chương trình một struct xdp_md chứa hai con trỏ: data (đầu gói) và data_end (cuối). Muốn đọc header Ethernet rồi IP, ta ép con trỏ và phải bounds-check trước mỗi lần đọc — đây đúng là luật của verifier ở Bài 2: chạm bộ nhớ ngoài [data, data_end) là bị từ chối nạp.
SEC("xdp")
int xdp_fw(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) // đủ chỗ cho header Ethernet?
return XDP_PASS;
if (eth->h_proto != bpf_htons(ETH_P_IP)) // không phải IPv4 -> cho qua
return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end) // đủ chỗ cho header IP?
return XDP_PASS;
if (ip->protocol == IPPROTO_ICMP) {
__u32 key = 0;
__u64 *n = bpf_map_lookup_elem(&icmp_drops, &key);
if (n) __sync_fetch_and_add(n, 1); // đếm số gói đã drop
return XDP_DROP; // vứt ICMP
}
return XDP_PASS; // còn lại: cho qua
}
Mỗi if (... > data_end) return XDP_PASS; không phải phòng thủ thừa — bỏ một cái là verifier từ chối nạp ngay ("invalid access to packet"). bpf_htons đổi thứ tự byte vì giá trị trên dây là big-endian. Một map icmp_drops (ARRAY 1 phần tử) đếm số gói vứt. Cả chương trình chỉ chừng 20 dòng.
Vì sao chọn ICMP để drop? Đây là chạy trên một node thật, đang SSH vào. SSH là TCP — drop ICMP không đụng tới nó. Quy tắc khi nghịch XDP trên máy ở xa: đừng bao giờ drop thứ đang giữ phiên của bạn. Mặc định cho mọi thứ
XDP_PASS, chỉ drop đúng cái vô hại đã chọn.
Gắn vào interface thật
Biên dịch như mọi chương trình eBPF (Bài 9), rồi nạp và gắn vào card mạng bằng bpftool:
clang -O2 -g -target bpf -I. -c xdp_fw.bpf.c -o xdp_fw.bpf.o # biên dịch
sudo bpftool prog loadall xdp_fw.bpf.o /sys/fs/bpf/xdpfw # nạp + pin
sudo bpftool net attach xdpgeneric pinned /sys/fs/bpf/xdpfw/xdp_fw dev ens5
Chú ý xdpgeneric — đó là generic mode (SKB mode): nhân chạy chương trình XDP sau khi đã cấp sk_buff, chậm hơn native nhưng chạy trên mọi driver và dễ gỡ. Có ba chế độ gắn XDP:
- native (
xdpdrv) — chạy trong driver, nhanh nhất, cần driver hỗ trợ. - offload (
xdpoffload) — chạy thẳng trên NIC SmartNIC, nhân không đụng gói. - generic (
xdpgeneric) — nhân mô phỏng, mọi driver chạy được, dùng để thử nghiệm.
Trên một node sản xuất từ xa, generic là lựa chọn an toàn để thử.
Ping rớt từ 0% lên 100%
Trước khi gắn, ping từ node tới LB nội bộ chạy bình thường:
-- before attach: ping LB --
2 packets transmitted, 2 received, 0% packet loss
rtt min/avg/max/mdev = 0.191/0.193/0.196/0.002 ms
Gắn firewall vào ens5, rồi ping lại:
== attached (xdpgeneric on ens5) ==
-- with XDP firewall: ping (expect 100% loss) --
4 packets transmitted, 0 received, 100% packet loss
100% loss. Lý do tinh tế: XDP chạy ở ingress (gói tới NIC). Khi node gửi ICMP echo-request đi (egress, không bị XDP đụng), gói tới đích bình thường; nhưng echo-reply quay về tới ens5 thì rơi vào XDP và bị XDP_DROP. Không reply nào lọt → ping báo 100% loss. Trong khi đó phiên SSH không hề gián đoạn — chính lệnh in ra dòng trên vẫn đang chạy qua TCP, gói TCP nhận XDP_PASS.
Đọc counter trong map để xác nhận đúng số gói đã vứt:
-- icmp_drops map --
[{ "key": 0, "value": 4 }]
Đúng 4 — khớp 4 echo-reply của ping -c 4. Firewall hoạt động đúng như viết: thấy ICMP, đếm, vứt; còn lại cho qua.
XDP nằm trước datapath Cilium
Lúc gắn xong, bpftool net show dev ens5 cho thấy thêm một điều:
xdp:
ens5(2) generic id 4656 <- firewall ta vừa gắn
tc:
ens5(2) tcx/ingress cil_from_netdev prog_id 2969
ens5(2) tcx/egress cil_to_netdev prog_id 2960
Trên cùng một interface ens5 đã có sẵn hai chương trình eBPF của Cilium gắn ở tầng tc (cil_from_netdev ingress, cil_to_netdev egress) — đó là datapath thật định tuyến mọi gói pod trên node này. Sơ đồ ở đầu bài hiện ra ngay trong thực tế: XDP của ta chạy trước, gói nào XDP_PASS mới đi xuống tc nơi Cilium tiếp quản. Bài 12 mổ thẳng tầng tc đó.
🧹 Dọn dẹp
Bước này bắt buộc — một chương trình XDP còn gắn sẽ tiếp tục lọc gói:
sudo bpftool net detach xdpgeneric dev ens5 # gỡ khỏi interface
sudo rm -rf /sys/fs/bpf/xdpfw # xóa pin
Gỡ xong, ping trở lại 0% loss ngay; node về 140 chương trình (datapath Cilium không bị đụng tới). Trong script test, lệnh detach đặt trong trap ... EXIT để luôn chạy kể cả khi một bước giữa lỗi — thói quen nên có khi nghịch XDP trên máy ở xa. Mã nguồn (xdp_fw.bpf.c, lệnh build/attach) ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 11-xdp-firewall.
Tổng kết
XDP gắn eBPF vào driver mạng, chạy trên mỗi gói tới trước khi cấp sk_buff — điểm sớm nhất, rẻ nhất để xử lý gói, nên là nền của drop DDoS và LB tốc độ cao. Chương trình trả một verdict: XDP_PASS (đi tiếp), XDP_DROP (vứt tại chỗ), XDP_TX (bật ngược), XDP_REDIRECT (đá đi nơi khác). Đọc gói qua data/data_end trong xdp_md, bounds-check trước mỗi lần chạm (luật verifier Bài 2). Ta viết một firewall drop ICMP, gắn vào ens5 bằng bpftool net attach xdpgeneric, thấy ping rớt 0%→100% loss (echo-reply bị vứt ở ingress) trong khi SSH/TCP vẫn thông — counter map xác nhận đúng 4 gói. Ba chế độ gắn: native (nhanh), offload (trên NIC), generic (mô phỏng, an toàn để thử). Và bpftool net show lộ ra XDP của ta nằm trước datapath tc của Cilium trên cùng interface.
Bài 12 đi xuống tầng đó: tc/sched_cls — hook nơi gói đã có sk_buff, nơi Cilium đặt phần lớn datapath. Ta sẽ mổ 74 chương trình sched_cls đang chạy trên cụm để thấy một gói pod được định tuyến, cân bằng tải, áp policy như thế nào.