Unkey Heimdall 网络计量 eBPF 深度解析:基于 TCX 与 Pod 侧 eth0 的按 Pod 字节计数实现
2026/9/17 13:01:58 网站建设 项目流程

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_usecmemory.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_publicegress_privateingress_publicingress_private,定义在 network.bpf.c:

struct counters { __u64 egress_public; __u64 egress_private; __u64 ingress_public; __u64 ingress_private; };

各 SEC 段的作用

SEC()作用
tccount_egress以 TCX-egress 挂载在 pod 侧 eth0 上。按目的 IP 递增egress_publicegress_private
tccount_ingress以 TCX-ingress 挂载在 pod 侧 eth0 上。按源 IP 递增ingress_publicingress_private
licenseBPF 许可证声明("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 明确解释了挂载点选择的两个关键约束,源码与设计文档给出了细节佐证:

  1. cgroup_skb在 gVisor 下毫无用处:gVisor(runsc)是用户态内核,客户 syscall 被 runsc 在用户空间拦截,永远不会触达宿主 socket 层,因此 cgroup 钩子只能看到 runsc 自身 socket 的流量,看不到客户逐包流量。本地开发实测中,即便 Pod 在做真实下载,所有 BPF map 条目的公网计数器也全为 0(见 heimdall.mdx)。
  2. 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->ifindexbpf_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.goattachPodEth0中为每个 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_initaccount

lookup_or_init(network.bpf.c)先走显式快速路径bpf_map_lookup_elem,稳态成本为每包一次哈希探测;首次见到某键时以BPF_NOEXIST创建零值条目。

account(network.bpf.c)的完整流程:

  1. tc 在 veth 上看到的是L2 帧,先跳过 14 字节以太网头(ETH_HLEN),用skb->protocol(已是大端序)分发,无需 bswap;
  2. ETH_P_IP/ETH_P_IPV6分支,分别把iph/ipv6hdr指针指向 L3 头,并做data_end边界检查;
  3. egress 取daddr、ingress 取saddr,交给is_v4_private/is_v6_private判定公私网;
  4. 以 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/16
  • 169.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 小节):

  1. 只读:仅读skb->len与 IP 头,绝不调用改写 skb 的 helper;
  2. 无条件非终止:每条路径(截断包、非 IP 协议、分类失败、map 满)都汇入唯一出口return TC_ACT_UNSPEC,verifier 在加载时强制该不变量;
  3. 无状态处理:无 conntrack、无流表、无缓冲,最坏情况只是少计而非丢包;
  4. 用户态失败不触碰数据路径link.AttachTCX错误、containerd gRPC 超时等只表现为"该 Pod 未挂载",流量照常;
  5. 从不强制 Detach:Pod netns 销毁时 eth0 随之销毁,内核自动摘除 TCX 链接,Heimdall 的Detach只是尽力而为的清理。

Go 侧加载器:从加载到逐 Pod 挂载

network_linux.go 的NewReader完成一次性的进程级初始化:

  1. rlimit.RemoveMemlock():解除RLIMIT_MEMLOCK限制(cilium/ebpf 负责处理),否则内核内存不足的 BPF 加载会被拒绝;
  2. 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>);
  3. 解析并缓存嵌入的 eBPF spec:每个 Pod 挂载时通过spec.Copy()克隆 spec 以烘焙唯一 POD_KEY,不扰动缓存版本;
  4. 预加载共享计数 map:先以仅含 map 的 spec 打开(复用 pin),使重启时 pin 复用发生在进程启动处而非逐 Pod;
  5. 启动 8 个异步 attach worker:每个 attach 耗时约 100–300ms(containerd gRPC + setns + 两次link.AttachTCX),若在 5 秒 tick 内串行执行,110 个冷启动 Pod × 200ms = 22 秒会撑爆 tick 预算。worker 池将挂载工作以有界并发扇出(attachWorkers = 8attachQueueSize = 256),collect()永不因单个 Pod 阻塞。

异步 Attach 的错误语义

Reader.Attach是非阻塞、幂等的(network.go),失败原因通过哨兵错误区分良性与真实故障,并映射为heimdall_network_attach_failures_total{reason=...}指标标签(network_linux.go):

哨兵错误含义分类
ErrSandboxNotFoundsandbox 容器已不在 containerd(Completed/Failed Pod 常见)良性
ErrNetnsOpen打开/var/run/netns/cni-<uuid>失败(netns 已拆除)良性(瞬态)
ErrVethLookupPod netns 中无可用的 veth需调查(CNI 配置或竞态)
ErrTCXAttach内核拒绝 TCX 挂载(缺 CAP_NET_ADMIN、内核 < 6.6 或链状态损坏)真实问题
ErrAttachQueueFullattach 队列溢出(下个 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-alternativesclangllcllvm-strip指向 18 版本;
  • Go 版本与 go.mod 精确一致(当前GO_VERSION=1.25.10GOTOOLCHAIN=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./bpf

bpfel(小端)目标覆盖 x86_64 与 arm64——项目仅部署的两种架构;若未来要支持大端平台,需并行添加bpfeb指令。

Header 策略:无 vmlinux.h、无 libbpf

network_helpers.h 是约 120 行的手写头文件,仅覆盖该程序引用的类型与宏:

  • 三个内核结构体__sk_buff(仅声明程序接触的datadata_endlenprotocol等字段)、iphdripv6hdr(含in6_addr联合体);
  • 三个 BPF helperbpf_map_lookup_elembpf_map_update_elem(按enum bpf_func_id的编号声明为函数指针);
  • 少量 map 类型常量BPF_MAP_TYPE_LRU_HASH = 9)与 libbpf 风格宏(__uint/__typeLIBBPF_PIN_BY_NAMETC_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.henum bpf_func_id——helper ID 是冻结的内核 UAPI,永不改变。

为什么不需要 CO-RE

CO-RE(Compile-Once Run-Everywhere)在程序加载时重定位结构体字段偏移,使针对一个内核结构体布局编译的程序能在另一个内核上运行;它之所以存在,是因为内部内核结构体(task_structsockskb_shared_info等)的字段会在版本间移动。

本程序只触碰 UAPI 结构体__sk_buffiphdripv6hdr),其偏移是内核与用户空间 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_BPFCAP_NET_ADMIN(见 DaemonSet manifest dev/k8s/manifests/heimdall.yaml);设计文档进一步说明还需CAP_SYS_ADMIN(放宽 verifier 指针算术限制,并允许setns(CLONE_NEWNET));
  • 进程启动时调用rlimit.RemoveMemlock()(cilium/ebpf 处理)。

补充的部署要求(来自 heimdall.mdx):hostNetwork: truehostPID: 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_egresscount_ingress分别出现在 eth0 的tcx/egresstcx/ingress下。若缺失,检查指标heimdall_network_attach_failures_total{reason=...}定位失败类别(tcx_attachnetns_gonesandbox_not_foundveth_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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询