Unkey Heimdall 网络计量 eBPF 深度解析:基于 TCX 与 Pod 侧 eth0 的按 Pod 字节计数实现
【免费下载链接】unkeyThe Developer Platform for Modern APIs项目地址: https://gitcode.com/GitHub_Trending/un/unkey
导读
本文剖析 Unkey 开源仓库中svc/heimdall/internal/network/bpf/目录下的网络计量 eBPF 程序:两个通过 TCX 机制挂载在每个 Pod 网络命名空间内 pod 侧 eth0上的tc程序,如何对进出 Pod 的每个数据包按目的/源 IP 做公网/私网分类,并将字节数原子累加到一张以Pod netns cookie为键的共享 BPF 哈希表中,供 Heimdall 每 5 秒的采集周期读取并写入 ClickHouse,最终用于max(counter) - min(counter)形态的计费计算。读完本文,你将掌握该程序的源码级实现细节、为什么不能在 cgroup 或 host 侧 veth 挂载的原因、TCX 返回码陷阱、无需 CO-RE 的 UAPI 结构体策略、字节级可复现的构建流程,以及生产集群中的在线验证方法。
背景:Heimdall 与按 Pod 计量
Heimdall 是 Unkey 在每个承载客户 Pod 的节点上运行的计量 Agent(DaemonSet 形态),负责从内核数据源读取 CPU、内存、磁盘与网络计数并写入 ClickHouse。其中 CPU、内存、磁盘均来自 sysfs/cgroupfs 文件(如cpu.stat:usage_usec、memory.current),但网络没有内核暴露的按 Pod 字节计数器文件,因此需要自研 eBPF 程序(详见 heimdall.mdx 设计文档)。
整个网络计量子系统位于 svc/heimdall/internal/network/:
- network.go — 跨平台的
Reader接口与Counters类型; - network_linux.go — Linux 实现(TCX 挂载、map 查询、异步 attach 工作池);
- veth_linux.go — setns 进入 Pod netns、定位 eth0、读取 netns cookie、按 Pod 克隆程序并烘焙 POD_KEY、挂载两个 TCX 程序;
- sandbox_linux.go — containerd sandbox → CNI netns 路径解析;
- bpf/network.bpf.c — eBPF 分类程序本体;
- bpf/network_helpers.h — 手写的最小头文件子集(替代 vmlinux.h + libbpf)。
设计总览:两个 tc 程序 + 一张共享计数 Map
按 README.md 的描述,核心架构为:
两个通过 TCX 挂载在每个 Pod 网络命名空间内 pod 侧 eth0上的
tc程序。每个进出的数据包都会经过它们,目的 IP 被分类为公网或私网,然后共享 BPF map(以 Pod netns cookie 为键)中四个字节计数器之一会被原子递增。Heimdall 在 5 秒 tick 上读取这些计数器并写入 ClickHouse。计费时对任意窗口计算max(counter) - min(counter),与 CPU 的形态一致。
四个计数器分别为egress_public、egress_private、ingress_public、ingress_private,定义在 network.bpf.c:
struct counters { __u64 egress_public; __u64 egress_private; __u64 ingress_public; __u64 ingress_private; };各 SEC 段的作用
SEC() | 作用 |
|---|---|
tc(count_egress) | 以 TCX-egress 挂载在 pod 侧 eth0 上。按目的 IP 递增egress_public或egress_private。 |
tc(count_ingress) | 以 TCX-ingress 挂载在 pod 侧 eth0 上。按源 IP 递增ingress_public或ingress_private。 |
license | BPF 许可证声明("Apache-2.0"),verifier 接受程序所必需。 |
注意方向语义:pod 侧 eth0 上,离开 Pod 的数据包 = tc-egress = Pod 的 egress(按iph->daddr分类),进入 Pod 的数据包 = tc-ingress = Pod 的 ingress(按iph->saddr分类)。这与旧版挂在 host 侧 veth 上的布局正好是镜像关系(详见 heimdall.mdx 的说明)。
为什么必须挂在 Pod 侧 eth0:两个"显而易见"方案的失败
README 明确解释了挂载点选择的两个关键约束,源码与设计文档给出了细节佐证:
cgroup_skb在 gVisor 下毫无用处:gVisor(runsc)是用户态内核,客户 syscall 被 runsc 在用户空间拦截,永远不会触达宿主 socket 层,因此 cgroup 钩子只能看到 runsc 自身 socket 的流量,看不到客户逐包流量。本地开发实测中,即便 Pod 在做真实下载,所有 BPF map 条目的公网计数器也全为 0(见 heimdall.mdx)。- host 侧 veth 会被 Cilium 的
bpf_redirect_peer绕过:EKS 上 Cilium 以原生/BPF host-routing 模式运行时,bpf_redirect_peer()会把数据包直接从 host NIC 的cil_from_container送入目标 Pod 的 netns,host 侧 veth(lxc<hash>/eni<hash>)根本看不到数据包。生产事故记录显示:Pod 活跃传输 MB 级流量时,host 侧 veth 的 RX/TX 计数器却停留在几百字节(同样记录在 network.bpf.c 的注释中)。
pod 侧 eth0 是每个数据包都必须经过的唯一接口,与 CNI 路由选择无关,也与负载是 runc 还是 gVisor 无关(runsc 通过 AF_PACKET 写 eth0)。TCX 在那里能看到带真实远端 IP 的 L3 数据包。
源码级实现:从分类到计数
为什么不用skb->ifindex或bpf_get_netns_cookie作为键
- ifindex 不可用:pod 侧 eth0 在每个 CNI netns 中都是 ifindex 3,跨 Pod 必然冲突。
bpf_get_netns_cookie有陷阱:在 tc-ingress 上,经 Ciliumbpf_redirect_peer投递的 skb 会以skb->sk == NULL进入 Pod netns,内核 helper 会回退返回init_net 的 cookie,导致所有 Pod 的 ingress 流量汇聚到同一个 map 槽位。
因此程序改用POD_KEY——一个在加载时烘焙进各程序.rodata的常量(见 network.bpf.c):
volatile const __u64 POD_KEY = 0;volatile防止编译器把零初始化折叠成常量加载;const+ 默认零初始化把符号放入.rodata,使 cilium/ebpf 的CollectionSpec.Variables能在LoadAndAssign前改写它。Go 侧在veth_linux.go的attachPodEth0中为每个 Pod 克隆 spec、把 netns cookie 写入 POD_KEY,于是 egress 与 ingress 写入同一个槽位,每个 Pod 独占一个槽位。
共享计数 Map
struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __type(key, __u64); __type(value, struct counters); __uint(max_entries, 16384); __uint(pinning, LIBBPF_PIN_BY_NAME); } pod_counters SEC(".maps");- LRU 类型:死亡 Pod 的条目无需显式清理,一旦没有程序写入即被自动回收。
- 16384 条目:约为默认
kubelet --max-pods(EKS 上 110、更大机型 250)的 40 倍,为集群级 Pod 频繁创建/销毁留出充足余量,避免 LRU 淘汰触发——淘汰会静默重置某 Pod 的计数器而container_uid不变,破坏计费数学依赖的单调性不变量。内存成本仅为16384 × 32 字节 = 512 KiB 内核内存/节点。 LIBBPF_PIN_BY_NAME:map 固定(pin)在 bpffs,保证 Heimdall Pod 重启后计数器不重置(详见下文"Pin 与版本化")。
计数路径:lookup_or_init与account
lookup_or_init(network.bpf.c)先走显式快速路径bpf_map_lookup_elem,稳态成本为每包一次哈希探测;首次见到某键时以BPF_NOEXIST创建零值条目。
account(network.bpf.c)的完整流程:
- tc 在 veth 上看到的是L2 帧,先跳过 14 字节以太网头(
ETH_HLEN),用skb->protocol(已是大端序)分发,无需 bswap; - 按
ETH_P_IP/ETH_P_IPV6分支,分别把iph/ipv6hdr指针指向 L3 头,并做data_end边界检查; - egress 取
daddr、ingress 取saddr,交给is_v4_private/is_v6_private判定公私网; - 以 POD_KEY 查得计数器条目,对命中槽位执行
__sync_fetch_and_add(slot, (__u64)skb->len)原子累加。
公网/私网地址判定
is_v4_private(network.bpf.c)把__be32按字节读取,逐字节匹配以下全部视为私网(即不可计费的公网出口流量):
0.0.0.0/8("this network",RFC 1122)、10.0.0.0/8(集群 CIDR 全部位于其下)127.0.0.0/8(loopback)172.16.0.0/12((b[1] & 0xF0) == 0x10)192.168.0.0/16169.254.0.0/16(link-local)100.64.0.0/10(CGNAT)224.0.0.0/4(multicast)255.255.255.255(limited broadcast)
is_v6_private(network.bpf.c)匹配:
::1/128(loopback)fe80::/10(link-local)fc00::/7(ULA,Cilium 双栈 Pod IP 的默认值)ff00::/8(multicast)
范围之外的地址一律计为公网。设计文档特别提示一个 IPv6 注意事项:若未来部署使用全局可路由 IPv6(非 ULA)承载 Pod 间流量,集群内流量会被计为公网出口——这是 Pod-CIDR 感知问题而非分类器 bug,修复方向是在挂载时读取 CNI 的 Pod CIDR 并与之一一比对,而不是依赖这些公认私有段(见 heimdall.mdx)。
TCX 返回码陷阱:必须返回TC_ACT_UNSPEC
README 特别指出TC_ACT_UNSPEC/TCX_NEXT陷阱是保持 gVisor Pod DNS 正常工作的关键。源码中两个程序都通过单一出口返回 TC_ACT_UNSPEC:
SEC("tc") int count_egress(struct __sk_buff *skb) { account(skb, 1); return TC_ACT_UNSPEC; }在 TCX 多程序链中这两个返回值含义相反(network_helpers.h):
TC_ACT_UNSPEC(-1)=TCX_NEXT:非终止,交给链中下一个程序;TC_ACT_OK(0)=TCX_PASS:接受数据包并终止整条链。
我们的程序是纯观察者,必须是非终止的,因为同一 pod 侧 eth0 钩子上 Cilium(或任何后续程序)可能挂载了需要在我们之后执行的 TCX 程序——尤其是 gVisor Pod 的ClusterIP → Pod-IP 翻译(如 coredns 的10.96.0.10)就在这些钩子中完成(gVisor 绕过内核 BPF cgroup socket 钩子,tc-BPF 是服务 IP 被改写的唯一场所)。若返回TC_ACT_OK,链在 Cilium 翻译器运行前被终止,该节点所有 gVisor Pod 的 DNS 会静默失效。Go 侧挂载时使用Anchor: link.Head()(见 heimdall.mdx),保证我们的观察者运行在任何可能返回TC_ACT_REDIRECT终止链的程序之前。
安全不变量
由于该数据路径位于每个客户数据包之上,程序被设计为"不可能破坏被测量的 Pod"(详见 heimdall.mdx 的 Safety invariants 小节):
- 只读:仅读
skb->len与 IP 头,绝不调用改写 skb 的 helper; - 无条件非终止:每条路径(截断包、非 IP 协议、分类失败、map 满)都汇入唯一出口
return TC_ACT_UNSPEC,verifier 在加载时强制该不变量; - 无状态处理:无 conntrack、无流表、无缓冲,最坏情况只是少计而非丢包;
- 用户态失败不触碰数据路径:
link.AttachTCX错误、containerd gRPC 超时等只表现为"该 Pod 未挂载",流量照常; - 从不强制 Detach:Pod netns 销毁时 eth0 随之销毁,内核自动摘除 TCX 链接,Heimdall 的
Detach只是尽力而为的清理。
Go 侧加载器:从加载到逐 Pod 挂载
network_linux.go 的NewReader完成一次性的进程级初始化:
rlimit.RemoveMemlock():解除RLIMIT_MEMLOCK限制(cilium/ebpf 负责处理),否则内核内存不足的 BPF 加载会被拒绝;- dial containerd:通过 CRI socket(如
/run/containerd/containerd.sock)解析 gVisor Pod 的 sandbox netns 路径——gVisor 下 Pod cgroup 中的 PID 位于 sandbox netns 而非 CNI netns,且 eth0 无 IP 可匹配,唯一可靠引用是 containerd sandbox 容器 OCI spec 中的linux.namespaces[type=network].path(即/var/run/netns/cni-<uuid>); - 解析并缓存嵌入的 eBPF spec:每个 Pod 挂载时通过
spec.Copy()克隆 spec 以烘焙唯一 POD_KEY,不扰动缓存版本; - 预加载共享计数 map:先以仅含 map 的 spec 打开(复用 pin),使重启时 pin 复用发生在进程启动处而非逐 Pod;
- 启动 8 个异步 attach worker:每个 attach 耗时约 100–300ms(containerd gRPC + setns + 两次
link.AttachTCX),若在 5 秒 tick 内串行执行,110 个冷启动 Pod × 200ms = 22 秒会撑爆 tick 预算。worker 池将挂载工作以有界并发扇出(attachWorkers = 8、attachQueueSize = 256),collect()永不因单个 Pod 阻塞。
异步 Attach 的错误语义
Reader.Attach是非阻塞、幂等的(network.go),失败原因通过哨兵错误区分良性与真实故障,并映射为heimdall_network_attach_failures_total{reason=...}指标标签(network_linux.go):
| 哨兵错误 | 含义 | 分类 |
|---|---|---|
ErrSandboxNotFound | sandbox 容器已不在 containerd(Completed/Failed Pod 常见) | 良性 |
ErrNetnsOpen | 打开/var/run/netns/cni-<uuid>失败(netns 已拆除) | 良性(瞬态) |
ErrVethLookup | Pod netns 中无可用的 veth | 需调查(CNI 配置或竞态) |
ErrTCXAttach | 内核拒绝 TCX 挂载(缺 CAP_NET_ADMIN、内核 < 6.6 或链状态损坏) | 真实问题 |
ErrAttachQueueFull | attach 队列溢出(下个 tick 会重试) | 良性 |
ErrNotAttached | 本进程对该 Pod 尚无挂载记录 | 注意:不等于零字节 |
ErrNotAttached的语义很关键:计数 map 是 pin 的,重启后的 Heimdall 内核中仍保存着该 Pod 全月累计值;若此处误报 0,下一个 tick 的差值就是整个累计值,等于在 5 秒间隔内把一个月出口流量重新计费一次。调用方必须将其视为"未测量"而非"零"。
每 Pod 生命周期与 Reconcile
Attach每次 tick 都会 stat 已记录 netns 路径:gVisor 下内存限制击杀会重建同一 Pod UID 的 sandbox(新 netns、通常新 Pod IP),若路径消失则释放旧链接与 map 条目并重新挂载(network_reattaches_total),不依赖可能被丢弃的 CRI 退出事件或 informer 转换;Detach关闭两个 tc 链接、卸载逐 Pod 程序并删除 map 条目,同时按 generation 取消在途/排队挂载,防止竞态泄漏;Reconcile(active)每个 tick 调用一次,驱逐已不在 informer 活跃集合中的条目——这是 CRI 退出事件与 informer 状态更新双丢失时的兜底,防止 BPF 程序 FD 泄漏到进程生命周期结束。
Pin 与版本化
计数 map pin 在network.PinDir()返回的路径,当前为/sys/fs/bpf/heimdall/v2(network_linux.go),DaemonSet 以 hostPath 方式挂载宿主/sys/fs/bpf。子目录避免与节点上其他 BPF 守护进程(如 Cilium pin 在/sys/fs/bpf/tc/globals)冲突。
尾缀v2是 pin 的版本号:libbpf 拒绝复用 spec 不兼容(max_entries、value 结构、key 类型变化)的已 pin map(报错map spec is incompatible with existing map)。因此任何对pod_countersspec 的修改都必须提升尾缀(v2 → v3);历史记录是 v1 用 u32 ifindex 键、v2 改为 u64 netns cookie 键,触发了旧 pin 的 KeySize 不兼容。PinDir()还被印在每个 checkpoint 的属性上,使查询时无需 node_id 部署历史即可区分新旧 pin 代。
如何重新生成:字节级可复现的构建流程
按 README,重新生成只需:
mise run generate-bpf该命令在固定的linux/amd64Docker 镜像内运行bpf2go。镜像定义在 bpf/Dockerfile.gen,关键设计:
- Ubuntu 24.04 基镜像(带 sha256 摘要锁定),强制
--platform=linux/amd64,Apple Silicon 的 arm64 开发机与 x86_64 CI runner 产出相同字节; - 固定 clang-18 / llvm-18,并通过
update-alternatives把clang、llc、llvm-strip指向 18 版本; - Go 版本与 go.mod 精确一致(当前
GO_VERSION=1.25.10,GOTOOLCHAIN=local),bpf2go版本由 go.mod 解析。
若不做此固定,宿主机 clang 版本漂移(Apple/brew 的 clang 22 与 Ubuntu apt 的 clang 18)会静默改变.o字节,使 CI drift-check 在完全正确的 C 源码上失败。首次运行会拉取镜像与工具链(热缓存下约 10 秒),后续运行复用本地镜像仅需数秒。
生成的 bpf_bpfel.go 与 bpf_bpfel.o 已提交入库:没有 Docker 的 Go 开发者无需重新生成即可构建 heimdall,只有真正编辑 C 源码时才运行mise run generate-bpf。
go:generate指令位于 generate.go,且被bpf_generate构建标签门控,使仓库级mise run generate(即go generate ./...)不会触发 bpf2go、不要求宿主机安装 clang:
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target bpfel -type counters bpf bpf/network.bpf.c -- -I./bpfbpfel(小端)目标覆盖 x86_64 与 arm64——项目仅部署的两种架构;若未来要支持大端平台,需并行添加bpfeb指令。
Header 策略:无 vmlinux.h、无 libbpf
network_helpers.h 是约 120 行的手写头文件,仅覆盖该程序引用的类型与宏:
- 三个内核结构体:
__sk_buff(仅声明程序接触的data、data_end、len、protocol等字段)、iphdr、ipv6hdr(含in6_addr联合体); - 三个 BPF helper:
bpf_map_lookup_elem、bpf_map_update_elem(按enum bpf_func_id的编号声明为函数指针); - 少量 map 类型常量(
BPF_MAP_TYPE_LRU_HASH = 9)与 libbpf 风格宏(__uint/__type、LIBBPF_PIN_BY_NAME、TC_ACT_UNSPEC/TC_ACT_OK); SEC()宏与端序转换宏(bpf_htonl/bpf_htons基于 clang 内建__builtin_bswap*,无头文件依赖)。
该模式与上游 cilium/ebpf 示例目录(common.h)及内核perf工具生成 BPF skel 的方式一致。理由(见头文件头部注释):
- vmlinux.h 是 4.3 MB 的 BTF 转储,而我们只用其中 3 个结构体;
- libbpf 的
bpf_helper_defs.h声明约 200 个 helper,我们只用 3 个; - macOS 的 clang 没有内核头文件,直接
#include <linux/...>无法编译;自包含的精简头可在任何环境编译。
新增一个 BPF helper 只需在network_helpers.h追加一行(README 给出了模板):
static <return-type> (*bpf_new_helper)(<args>) = (void *)<id>;其中<id>取自内核include/uapi/linux/bpf.h的enum bpf_func_id——helper ID 是冻结的内核 UAPI,永不改变。
为什么不需要 CO-RE
CO-RE(Compile-Once Run-Everywhere)在程序加载时重定位结构体字段偏移,使针对一个内核结构体布局编译的程序能在另一个内核上运行;它之所以存在,是因为内部内核结构体(task_struct、sock、skb_shared_info等)的字段会在版本间移动。
本程序只触碰 UAPI 结构体(__sk_buff、iphdr、ipv6hdr),其偏移是内核与用户空间 ABI 契约的一部分,20 多年来未移动过,因此无需 CO-RE(network_helpers.h 亦注明这些布局必须与内核 ABI 匹配)。README 的结论是:"将来若程序触及内部结构体,那时再采用 CO-RE"。这一取舍同时意味着构建流程不需要 BTF 相关依赖(尽管 DaemonSet 仍以只读方式挂载/sys/kernel/btf以备 CO-RE 重定位)。
运行时要求
按 README 的 Runtime requirements 小节:
- 内核 6.6 或更新(TCX 挂载支持是硬约束;CAP_BPF 早在 5.8 就从 CAP_SYS_ADMIN 拆分,但 TCX 才是决定性条件);
- 容器具备
CAP_BPF与CAP_NET_ADMIN(见 DaemonSet manifest dev/k8s/manifests/heimdall.yaml);设计文档进一步说明还需CAP_SYS_ADMIN(放宽 verifier 指针算术限制,并允许setns(CLONE_NEWNET)); - 进程启动时调用
rlimit.RemoveMemlock()(cilium/ebpf 处理)。
补充的部署要求(来自 heimdall.mdx):hostNetwork: true与hostPID: true;宿主机挂载/run/containerd/containerd.sock(rw)、/var/run/netns(ro)、/sys/fs/bpf(rw)、/sys/kernel/btf(ro);内核 ≥ 5.12 才能通过SO_NETNS_COOKIE从用户态读取 netns cookie(BPF helperbpf_get_netns_cookie自 5.7 可用)——若 getsockopt 返回零 cookie 则视为该次挂载致命错误,否则所有 Pod 会坍缩进同一 map 槽。
在生产集群中验证挂载
TCX 程序对每个 Pod netns 内的每个接口可见。从节点上执行(README 原文命令):
kubectl debug node/<node> -it --image=quay.io/cilium/cilium-bpftool -- \ nsenter --net=/proc/<pause-pid>/ns/net bpftool net show预期结果:count_egress与count_ingress分别出现在 eth0 的tcx/egress与tcx/ingress下。若缺失,检查指标heimdall_network_attach_failures_total{reason=...}定位失败类别(tcx_attach、netns_gone、sandbox_not_found、veth_lookup等)。
共享计数 map pin 在network.PinDir()返回的路径(当前为/sys/fs/bpf/heimdall/v2),可直接 dump 检查:
bpftool map dump pinned /sys/fs/bpf/heimdall/v2/pod_counters此外 classifier_linux_test.go 与 classifier_fuzz_test.go 覆盖了私网/公网分类逻辑的单元测试与模糊测试,可在无真实内核环境下验证分类正确性。
总结
Unkey 的 Heimdall 网络计量方案用最小的 eBPF 程序集(两个SEC("tc")程序 + 一张 LRU map)实现了按 Pod 的进出口字节计量:挂在每个数据包必经的 pod 侧 eth0 上,以烘焙进.rodata的 POD_KEY 为键避免 netns cookie helper 的 ingress 回退陷阱,以TC_ACT_UNSPEC保证观察者不终止 TCX 链,以LIBBPF_PIN_BY_NAME让计数器跨 Heimdall 重启保持单调,以固定 Docker 工具链实现字节级可复现构建,并以 UAPI-only 结构体策略彻底规避 CO-RE 复杂度。这套设计始终围绕一个核心不变量——计量可以少计、绝不虚增——从 BPF 程序到用户态加载器再到计费数学,每一步都在守护它。
【免费下载链接】unkeyThe Developer Platform for Modern APIs项目地址: https://gitcode.com/GitHub_Trending/un/unkey
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考