一粟 · yisu.app
· 14 min read
ebpf实践

用 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 / sockmapsocket 层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, &eth, 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 + 重启"的事,变成了"加载程序 + 热更新"。这是基础设施的一次范式转移。