Cilium 数据路径配置机制详解:用 DECLARE_CONFIG 与 BPF Static Data 替代 #define
【免费下载链接】ciliumeBPF-based Networking, Security, and Observability项目地址: https://gitcode.com/GitHub_Trending/ci/cilium
导读:Cilium 的数据路径(datapath)BPF 程序在加载时需要获取特性开关、地址、超时、Security ID 等大量运行时配置。本文基于 Cilium 官方开发文档 Configuring the Datapath,完整讲解这套基于 Linux 5.2+ static data(read-only map)的配置机制:如何用DECLARE_CONFIG/NODE_CONFIG/ASSIGN_CONFIG宏声明与使用配置变量、Go 侧脚手架代码如何由dpgen自动生成、agent 如何把值“接线”进 BPF 程序,以及测试中覆盖配置值的正确姿势。读完后,你可以在 Cilium 数据路径中安全地新增、移植和测试任何配置项。
1. 背景:从#define/#ifdef到结构化静态数据
早期 Cilium 将 agent 配置以运行时生成的#define语句的形式“喂”进数据路径,可选代码段用#ifdef等手段围起来。这套方式撑了很多年,但随着 agent 与数据路径复杂度的上升,官方认为需要一种更结构化、更易维护的配置方式。
新机制依赖 Linux 内核 5.2 起支持的只读 map(static data):
- 配置数据存放在内核验证程序之后不可更改的只读区域;
- 当这些值被用于分支判断时,verifier 可以做死代码消除(DCE),把不可达分支裁掉,减少后续验证步骤的工作量,让最终 BPF 程序映像尽可能精简;
- 不再需要按配置条件编译(conditional compile)数据路径代码——BPF 代码在编译期构建并嵌入 agent,因此 agent 容器不再需要携带 LLVM(所谓 “clang-free”),镜像更小、启动更快、CPU 占用更低,配置变更时 endpoint 重生成也更快。
2. 快速上手:一个配置变量的完整生命周期
下面用一个贯穿全文的例子(来自bpf/include/bpf/config/lxc.h,被bpf_lxc.c引入)展示声明、生成、接线、读取四个阶段。
2.1 阶段一:声明 C 变量
在bpf/include/bpf/config/lxc.h中声明:
DECLARE_CONFIG(__u16, endpoint_id, "The endpoint's security ID")该宏声明了一个名为endpoint_id的 16 位无符号整型配置值,第二个参数是描述文本——这个描述后面会直接出现在生成的 Go 代码注释里。仓库中该文件实际内容见 bpf/include/bpf/config/lxc.h:
DECLARE_CONFIG(__u16, endpoint_id, "The endpoint's security ID") #define LXC_ID CONFIG(endpoint_id) /* Backwards compatibility */声明宏的底层实现在 bpf/lib/static_data.h,可以看到几个关键设计:
#define __CONFIG_SECTION ".rodata.config" #define DECLARE_CONFIG(type, name, description) \ DECLARE_CONFIG_KIND("object", type, name, description) #define DECLARE_CONFIG_KIND(kind, type, name, description) \ __section(__CONFIG_SECTION) \ __attribute__((btf_decl_tag("kind:" kind))) \ __attribute__((btf_decl_tag(description))) \ volatile const type __config_##name;要点:
- 变量被放到
.rodata.config段,编译器会为其中的所有变量生成 BTF Datasec,方便工具遍历并生成 Go 配置脚手架(ebpf-go 会将其暴露在CollectionSpec.Variables中); - 两个 BTF decl tag:
kind区分 object 级配置与 node 级配置(决定 dpgen 生成到哪个 Go struct),description把 C 里的描述带入生成的 Go 文档注释; - 变量本身是
volatile const,volatile防止编译器消除所有访问从而把变量从 ELF 中丢掉。
声明完成后,重新构建数据路径并运行dpgen生成 Go 代码:
make -C bpf -j$(nproc)生成逻辑位于 tools/dpgen,输出会落到pkg/datapath/config包的 Go 配置脚手架中。
2.2 阶段二:自动生成的 Go 结构体
make之后,pkg/datapath/config包中会出现对应结构体字段。实际的生成文件 pkg/datapath/config/lxc_config.go 开头即标注// Code generated by dpgen. DO NOT EDIT.:
// BPFLXC is a configuration struct for a Cilium datapath object. // // Warning: do not instantiate directly! Always use [NewBPFLXC] to ensure the // default values configured in the ELF are honored. type BPFLXC struct { ... // MTU of the device the bpf program is attached to. DeviceMTU uint16 `config:"device_mtu"` ... // The endpoint's security ID. EndpointID uint16 `config:"endpoint_id"` ... }结构体字段上的注释正是我们在 C 侧DECLARE_CONFIG里写的描述,字段通过config:"endpoint_id"标签与 C 变量名绑定。注意结构体注释中的警告:不要直接实例化,必须经由NewBPFLXC等构造函数创建,以确保 ELF 中已配置好的默认值被正确保留(这正是第 5 节ASSIGN_CONFIG默认值机制的配套要求)。
2.3 阶段三:把 Go 值“接线”到 agent
生成字段只是脚手架,还需要 agent 在 BPF 加载时填入真实值。具体填在哪里取决于对象类型,以及该值是“节点级配置”还是“对象专属配置”。以bpf_lxc.c的 endpoint 配置为例,赋值发生在pkg/datapath/config包的 endpoint 配置逻辑中,pkg/datapath/config/endpoint.go 中可以看到:
cfg.EndpointID = uint16(ep.GetID())警告:这个接线工作每个需要访问该变量的对象都要做一遍!如果你在
bpf_lxc.c与bpf_host.c共同引用的头文件中声明了一个变量,就必须保证 agent 同时向两个对象对应的 struct 供值。文档说明,撰写时填充 Go 配置脚手架的工作仍大多散落在
pkg/datapath/loader等少数几处;目标是为每个配置对象建立 StateDB 表,由 Hive Cell 管理,并在任一值变化时自动触发相应 BPF 程序重载。文档会随该演进持续更新。若本文档与代码库不一致,可 grep 各 struct 及其字段的用法,沿既有代码模式扩展。
2.4 阶段四:在 BPF C 代码中读取
统一通过CONFIG()宏访问变量。该宏展开为对特殊变量名的引用(实现见 bpf/lib/static_data.h),宏存在的意义是:未来如果底层表示方式变化,可以避免大规模交叉改动代码。
CONFIG(endpoint_id)用法与平常的变量一致——直接取值:
__u16 endpoint_id = CONFIG(endpoint_id);或在分支中判断:
if (CONFIG(endpoint_id) != 0) { ... }CONFIG()宏内部值得注意的细节:它用asm volatile在每次访问时重新构造 rodata 指针,阻止编译器在相邻多次访问间复用同一指针。从源码注释看,这样能让基于配置变量的 load/deref/branch 指令彼此靠近、不被跨基础块复用,从而更利于 verifier 通过回溯做出可预测的分支推断。
注意:配置变量不是编译期常量,因此不能用来控制 BPF map 大小,也不能在编译期初始化其他全局
const变量。
3. 节点级配置(Node Configuration)
警告:历史上 agent 的绝大多数配置都以“节点配置”(
node_config.h)的形式提供给数据路径。该模式今后不推荐继续使用,未来可能被移除,新代码应优先使用DECLARE_CONFIG()(见第 4 节约定)。
为让从#define风格配置向新机制迁移更平滑,项目保留了节点配置这一概念,只是值改为运行时提供而非#ifdef。
节点配置声明在bpf/include/bpf/config/node.h中,bpf/include/bpf/config/node.h 中有大量真实例子:
NODE_CONFIG(__u32, cilium_net_ifindex, "Interface index of the cilium_net device") NODE_CONFIG(union macaddr, cilium_net_mac, "MAC address of the cilium_net device") NODE_CONFIG(__u32, trace_payload_len, "Length of payload to capture when tracing native packets.") #define TRACE_PAYLOAD_LEN CONFIG(trace_payload_len) /* Backwards compatibility */ NODE_CONFIG(__u32, cluster_id, "Cluster ID")NODE_CONFIG与DECLARE_CONFIG的区别仅在于 BTF decl tag 中的kind字段为"node"(见 bpf/lib/static_data.h),dpgen 据此把它们生成到独立的 Node 结构体,可嵌入所有对象级配置 struct。在 Go 脚手架中的形态为:
type Node struct { // The foo value. Foo uint64 `config:"foo"` }agent 侧通过pkg/datapath/config.NodeConfig()填充,例如:
func NodeConfig(lnc *config.Config) Node { ... node.Foo = 42 ... }在 BPF C 代码中,节点配置的读取方式与 object 配置完全一致,同样是CONFIG(name)。
4. 约定与推荐(Guidelines and Recommendations)
官方文档给出了几条指导原则,决定了配置变量应声明在哪里:
- 避免“死代码”——即声明了却从未被 agent 赋值的变量。例如只有
bpf_lxc.c使用某变量时,不要把它放进多个 BPF 对象共用的头文件;如需与其他对象共享类型,请把类型放进单独的头文件。 - 在使用点附近声明变量,例如放在实现该特性的头文件里。
- 避免条件式
#include。
判断配置声明位置的决策流程:
- 新特性:在实现该特性的头文件中使用
DECLARE_CONFIG(),并只在该特性实际被使用的 BPF 对象中引入该头文件; - 既有特性的新配置:在尽可能靠近消费该配置代码处使用
DECLARE_CONFIG(); - 从
node_config.h(WriteNodeConfig)移植节点配置:先尽量缩小该配置的用途范围,看能否改为在使用它的少量 BPF 对象所引入的头文件中DECLARE_CONFIG()。这种重构是值得的——它能避免在不使用该节点配置的对象中产生死代码; - 以上都不适用时,才使用
NODE_CONFIG()。
bpf/include/bpf/config/目录按对象拆分的头文件(lxc.h、node.h、host.h、sock.h、xdp.h、overlay.h、endpoint.h、global.h)即这一组织方式的落地体现。
5. 默认值:ASSIGN_CONFIG
如果希望某个配置变量的默认值不是 0,并直接在 C 侧给出默认值,可在声明之后使用ASSIGN_CONFIG()宏。这在 agent 不提供值时自动生效一个合理默认值,很有用。
agent 对设备 MTU 就是这么做的,见 bpf/lib/nodeport.h:
DECLARE_CONFIG(__u16, device_mtu, "MTU of the device the bpf program is attached to") ASSIGN_CONFIG(__u16, device_mtu, MTU)而ASSIGN_CONFIG的宏实现(bpf/lib/static_data.h)还包含一个“先引用再赋值”的防护:
#define ASSIGN_CONFIG(type, name, ...) \ void __check_##name(void) \ { CONFIG(name); /* Error: variable was assigned before declaring. */ }; \ volatile const type __config_##name = __VA_ARGS__;从源码结构看,这段先对CONFIG(name)取址的桩函数保证“必须先声明后赋值”,否则编译会直接报错。
警告:
ASSIGN_CONFIG()每个变量在每个编译单元中只能使用一次。这意味着该变量无法在测试中被覆盖(除非使用第 6 节的 workaround),因此应谨慎使用。
6. 测试中覆盖配置值
编写数据路径测试时,往往需要覆盖配置值以走到不同代码路径。做法与第 5 节相同:在导入被测主对象(如bpf_lxc.c)之后,在测试文件中用ASSIGN_CONFIG()覆盖。最及时的示例请直接参考测试套件本身,例如 bpf/tests/bpf_nat_tests.c:
ASSIGN_CONFIG(__u16, device_mtu, 1500);需注意的限制:传给ASSIGN_CONFIG()的字面量必须是编译期常量,不能是其他变量的名字。
偶尔你可能需要覆盖一个已经用ASSIGN_CONFIG()设置了默认值的配置,此时需要一个 workaround:
#ifndef OVERRIDABLE_CONFIG DECLARE_CONFIG(__u8, overridable, "Config with a default and an override from tests") ASSIGN_CONFIG(__u8, overridable, 42) #define OVERRIDABLE_CONFIG CONFIG(overridable) #endif然后在测试文件中,#include被测对象之前先#define OVERRIDABLE_CONFIG使覆盖值生效:
#define OVERRIDABLE_CONFIG 1337 #include "bpf_lxc.c"文档强调这种写法“有点反直觉”,应少用,并优先考虑重构代码以规避这种需求。
7. 小结:一次声明,三端联动
整套机制的核心链路是:
| 环节 | 位置 | 作用 |
|---|---|---|
| 声明 | bpf/include/bpf/config/*.h | DECLARE_CONFIG/NODE_CONFIG生成.rodata.config段变量 + BTF tag |
| 生成 | tools/dpgen →pkg/datapath/config/*_config.go | 从 BTF 生成带config:"name"标签的 Go struct |
| 接线 | pkg/datapath/config、pkg/datapath/loader | agent 在 BPF 加载时为每个对象填充字段 |
| 读取 | BPF C 代码 | 统一经CONFIG()宏访问,verifier 可做死代码消除 |
| 默认/覆盖 | ASSIGN_CONFIG | C 侧编译期默认值;测试中按规则覆盖 |
理解这条链路后,你就能在 Cilium 数据路径中正确新增配置项:选对DECLARE_CONFIG还是NODE_CONFIG、为每个消费该变量的 BPF 对象完成 agent 侧接线、并借助ASSIGN_CONFIG为测试与默认值兜底。
【免费下载链接】ciliumeBPF-based Networking, Security, and Observability项目地址: https://gitcode.com/GitHub_Trending/ci/cilium
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考