rust_http_proxy 会发布三个 Linux 二进制:
- 普通版
rust_http_proxy - 动态链接 BPF 版
rust_http_proxy_bpf - 静态链接 BPF 版
rust_http_proxy_bpf_static
这次改动的目标是让三个产物都只要求 glibc 2.17,同时保留后两个版本的 eBPF 流量统计能力。
先说结论:
glibc 2.17 约束的是用户态 ELF ABI,eBPF 能否加载取决于内核能力。二者是两条独立的兼容线,不能因为二进制兼容 glibc 2.17,就宣称它完整支持使用 3.10 内核的 CentOS 7。
为什么 Rust 程序里加入 BPF 后更难兼容旧 glibc
纯 Rust 依赖通常比较容易交给 cargo-zigbuild 统一编译。但基于 libbpf 的程序还会引入一条原生依赖链:
Rust 程序
└── libbpf
├── libelf
└── zlib
如果这几个 C 库或静态归档来自较新的构建主机,它们可能引用比 glibc 2.17 更新的符号。即使 Rust 代码本身使用 Zig 的 glibc 2.17 sysroot,最终 ELF 仍可能被原生依赖抬高最低 glibc 版本。
因此,不能只把 cargo build 换成 cargo zigbuild,还要分别处理动态链接和静态链接场景。
统一使用 cargo-zigbuild
三个构建矩阵都启用:
use_zigbuild: true
zig_version: "0.15.2"
zig_glibc_version: "2.17"
其核心效果相当于面向下面的 target 构建:
x86_64-unknown-linux-gnu.2.17
这表示最终程序最多使用 GLIBC_2.17 符号,并不表示运行环境必须“恰好”安装 glibc 2.17;正常情况下,它也可以运行在更新的 glibc 上。
动态链接 BPF:不要把整个 /usr/include 交给 Zig
动态链接版本使用:
--features bpf
构建 libbpf 时需要 libelf.h、gelf.h、zlib.h 和 zconf.h。最直接的处理似乎是:
-I/usr/include
但这是这次遇到的关键陷阱。
cargo-zigbuild 会有意隔离构建主机的系统头文件,以确保 C 代码使用 Zig 提供的目标 glibc 头文件。如果重新加入整个 /usr/include,第三方头文件之外的 glibc 头文件也会被构建主机版本覆盖;其内部重定向和 feature 定义可能再次把编译过程带回高版本 glibc。
解决办法是只复制 libbpf 确实需要的第三方头文件:
libbpf_zig_include=/tmp/libbpf-zig-include
install -d "$libbpf_zig_include"
install -m 0644 \
/usr/include/libelf.h \
/usr/include/gelf.h \
/usr/include/zlib.h \
/usr/include/zconf.h \
"$libbpf_zig_include/"
export LIBBPF_SYS_EXTRA_CFLAGS="-isystem $libbpf_zig_include"
export LIBBPF_SYS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu
这样可以做到:
- libelf/zlib 自己的头文件来自构建容器;
- 它们继续引用的
stdint.h、sys/types.h等系统头文件来自 Zig 的 glibc 2.17 sysroot; - 动态库从明确的系统库路径查找,避免把整个系统 include 路径重新暴露给编译器。
动态链接产物的 DT_NEEDED 中仍然包含:
libelf.so.1
libz.so.1
因此目标机器还需要安装兼容的 libelf 和 zlib 运行库。
静态链接 BPF:使用 vendored,而不是复用主机静态库
原来的静态特性是:
--features bpf_static
它会优先复用构建主机提供的 libelf/zlib 静态归档。问题是,这些 .a 文件已经由构建主机的工具链编译完成,cargo-zigbuild 无法回头改变其中代码使用的 glibc ABI。
现在改成:
--features bpf_vendored
让 libbpf、libelf 和 zlib 的源码也进入本次构建,由 Zig 面向 glibc 2.17 重新编译。静态 BPF 产物因此不再动态依赖 libelf.so 和 libz.so。
这里的“static”只针对 BPF 原生依赖,并不是把整个 Rust 程序构建成完全静态 ELF。产物仍然动态依赖 glibc,例如:
libc.so.6
libm.so.6
libpthread.so.0
libdl.so.2
ld-linux-x86-64.so.2
在 CI 中验证结果,而不是相信构建参数
仅仅配置 zig_glibc_version: "2.17" 不足以证明最终产物真的满足要求,CI 还需要检查 ELF。
首先提取动态符号和版本信息中的最高 glibc 版本:
max_glibc=$(
readelf -W --version-info --dyn-syms "$binary" |
grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' |
sed 's/^GLIBC_//' |
sort -Vu |
tail -n 1
)
如果版本高于 2.17,构建直接失败。
对于静态 BPF 版本,再检查它没有意外恢复对 libelf/zlib 的动态依赖:
if readelf -d "$binary" | grep -Eq '\[(libelf|libz)\.so'; then
echo "Static BPF binary still has a dynamic libelf/libz dependency"
exit 1
fi
实际验证结果如下:
| 产物 | 最高 glibc 符号版本 | libelf.so / libz.so |
|---|---|---|
| 普通版 | ≤ 2.17 | 无 |
| BPF 动态链接版 | ≤ 2.17 | 有 |
| BPF 静态链接版 | ≤ 2.17 | 无 |
DT_NEEDED 中没有 libelf/libz 代表什么
DT_NEEDED 是 ELF 动态段中交给动态加载器的直接共享库依赖。
静态 BPF 版本中没有 libelf.so 和 libz.so,表示这些库的机器码已经链接进主程序:
- 目标机器不需要额外安装 libelf/zlib 运行库;
- 不会因为缺少这两个
.so或其版本不匹配而启动失败; - 相关功能仍然存在,并不是被裁掉了;
- 系统升级 libelf/zlib 不会更新二进制里的嵌入版本,需要重新构建和发布才能获得修复。
它也不表示程序完全没有动态依赖。判断是否为完全静态 ELF,仍应查看完整的 readelf -d 或 ldd 结果。
动态 BPF 版本则相反:主程序本身最高只使用 GLIBC_2.17,但运行时加载的 libelf.so.1 和 libz.so.1 也必须与目标系统匹配。因此,主 ELF 的 glibc 检查不能代替对目标系统动态库的检查。
glibc 2.17 与 eBPF 内核兼容性
把用户态 ABI 降到 glibc 2.17,不会降低 BPF 字节码所需的内核能力。
当前程序包含两类 BPF 统计:
| 功能 | 主要要求 |
|---|---|
| socket filter 流量统计 | 使用 BPF_MAP_TYPE_PERCPU_ARRAY,上游内核从 4.6 引入 |
| cgroup ingress/egress 统计 | 使用 BPF_PROG_TYPE_CGROUP_SKB,上游内核从 4.10 引入,并要求 cgroup v2 |
相关版本可以参考 Linux 的 BPF array map 文档,以及 Linux 4.9 和 Linux 4.10 的 BPF UAPI 差异。
实际部署时还要考虑:
- 发行版是否回移了相关 BPF 能力;
- 内核是否启用了所需 BPF 配置;
- cgroup v2 是否挂载并启用;
- 进程是否具有加载和挂载 BPF 程序所需的权限;
- 内核 verifier 是否接受最终程序。
所以完整 BPF 功能可以把“具备 Linux 4.10 对应能力且启用 cgroup v2”作为基础门槛,但最终仍应在目标发行版上实际加载验证。
CentOS/RHEL 7 是最容易误解的例子:它的用户态 glibc 2.17 满足本次 ELF 目标,但常见的 3.10 系列内核通常不满足这里的完整 BPF 能力。程序可以启动,不代表 BPF 指标一定可用。
rust_http_proxy 当前会捕获 BPF 初始化错误、记录 warning,并让对应指标返回 0,所以不支持的内核通常表现为 BPF 监控降级,而不是代理进程直接退出。