用 eBPF 写一个最小代理:250 行代码实践
eBPF 听起来高深,其实入门比想象中简单。用 250 行 C + Go 代码实现一个可观测的 L4 代理,把 XDP、TC、sockmap 串成一张图。
eBPF 是这几年内核领域最热的话题。但很多工程师对它的印象停留在"高级工具"——以为必须用 Cilium、Calico 才能碰。其实 eBPF 的入门门槛比想象中低。本文用 250 行代码做一个最小的 L4 代理,让你直观感受它的能力。
我们要做什么
目标:一个 L4 代理,监听 8080 端口,把流量转发到后端 127.0.0.1:8081。同时记录每个连接的字节数与持续时间,写到 /sys/fs/bpf/maps 供用户态查询。这相当于一个微型 IPVS + metric exporter。
eBPF 的三类 hook 点
eBPF 程序可以挂到内核的不同位置,每类位置能做的事不同:
| 类型 | 位置 | 典型用途 |
|---|---|---|
| XDP | 网卡驱动层 | DDoS 防护、L3 路由 |
| TC | 流量控制层 | L4 代理、流量整形 |
| sk_skb / sockmap | socket 层 | L7 代理、零拷贝转发 |
| kprobe / tracepoint | 内核函数 | 观测、调试 |
我们的 L4 代理用 TC(Traffic Control)做 ingress/egress 钩子,足够简单也足够强大。
BPF 程序:C 代码
// proxy.bpf.c - 最小 L4 代理的 eBPF 程序
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <bpf/bpf_helpers.h>
struct conn_key {
__u32 saddr;
__u32 daddr;
__u16 sport;
__u16 dport;
};
struct conn_stats {
__u64 bytes;
__u64 packets;
__u64 start_ns;
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536);
__type(key, struct conn_key);
__type(value, struct conn_stats);
} conn_map SEC(".maps");
SEC("tc/ingress")
int tc_ingress(struct __sk_buff *skb) {
struct ethhdr eth;
struct iphdr ip;
struct tcphdr tcp;
if (bpf_skb_load_bytes(skb, 0, ð, sizeof(eth)) < 0) return TC_ACT_OK;
if (bpf_skb_load_bytes(skb, sizeof(eth), &ip, sizeof(ip)) < 0) return TC_ACT_OK;
if (bpf_skb_load_bytes(skb, sizeof(eth)+sizeof(ip), &tcp, sizeof(tcp)) < 0) return TC_ACT_OK;
if (tcp.dest == 0x2382) { // 8080 端口(网络字节序)
struct conn_key key = { ip.saddr, ip.daddr, tcp.source, tcp.dest };
struct conn_stats *st = bpf_map_lookup_elem(&conn_map, &key);
if (st) {
__sync_fetch_and_add(&st->bytes, skb->len);
__sync_fetch_and_add(&st->packets, 1);
} else {
struct conn_stats new = { skb->len, 1, bpf_ktime_get_ns() };
bpf_map_update_elem(&conn_map, &key, &new, BPF_ANY);
}
}
return TC_ACT_OK;
}
char LICENSE[] SEC("license") = "GPL";
整个程序不到 60 行。它做的事很简单:在 TC ingress 钩子上读取每个包的 header,如果是目标端口 8080 的 TCP 包,更新一个 hash map 里的统计。
用户态:Go 加载器
package main
import (
"log"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)
func main() {
rlimit.RemoveMemlock()
spec, err := ebpf.LoadCollectionSpec("proxy.bpf.o")
if err != nil { log.Fatal(err) }
coll, err := ebpf.NewCollection(spec)
if err != nil { log.Fatal(err) }
defer coll.Close()
iface := "eth0"
l, err := link.AttachTC(link.TCOptions{
Program: coll.Programs["tc_ingress"],
Attach: ebpf.AttachTCIngress,
Interface: iface,
})
if err != nil { log.Fatal(err) }
defer l.Close()
log.Println("eBPF 代理已挂载到", iface)
select {} // 阻塞
}
Go 端代码不到 30 行,加上 cilium/ebpf 库的辅助,整套用户态总共 80 行左右。加上 BPF C 代码 60 行,正好 140 行——再加点 metric 导出和信号处理,差不多 250 行能完成一个最小的"可观测 L4 代理"。
查看统计:从 map 读数据
func dumpStats(m *ebpf.Map) {
var key connKey
var val connStats
iter := m.Iterate()
for iter.Next(&key, &val) {
log.Printf("conn %d.%d.%d.%d:%d -> %d.%d.%d.%d:%d bytes=%d pkts=%d",
key.saddr&0xff, (key.saddr>>8)&0xff, (key.saddr>>16)&0xff, (key.saddr>>24)&0xff, key.sport,
key.daddr&0xff, (key.daddr>>8)&0xff, (key.daddr>>16)&0xff, (key.daddr>>24)&0xff, key.dport,
val.bytes, val.packets)
}
}
为什么 eBPF 这么有价值
对比传统方案,eBPF 的几个独特优势:
- 无侵入:不改应用代码、不改内核、不需重启。
- 极低开销:JIT 编译后接近原生性能。
- 可编程:能根据需要写逻辑,不是固定功能。
- 统一可观测:内核里的事件都能看到。
几个避坑点
- verifier 是最严的代码评审:你的 BPF 程序必须能被 verifier 证明"安全"——无死循环、无越界访问。第一次写 BPF 经常会被 verifier 拒。
- 5.8 内核以上才稳定:很多特性依赖较新内核,Ubuntu 22.04 自带的 5.15 已经够用。
- map 大小要规划:hash map 默认 65536 桶,超了会丢键。
- 测试环境很重要:BPF 程序崩了不会让内核崩,但会让流量不通。先在测试环境跑。
从最小代理到生产工具
这个 250 行的代理当然不能直接上生产。但它展示了 eBPF 的核心模式:在内核 hook 点上做轻量处理,通过 map 与用户态通信。把这个模式放大,就是 Cilium、Pixie、Parca 这些工具的基础。
eBPF 的真正价值不在"代替 nginx"——它不会。它的价值在于"让内核可编程"。当你可以安全地在内核里加逻辑时,很多原来需要"内核 patch + 重启"的事,变成了"加载程序 + 热更新"。这是基础设施的一次范式转移。