工业边缘设备的问题往往发生在现场:进程突然重启、块设备延迟抖动、网络重传升高、CPU 被某个采集任务吃满、容器里的服务偶发卡顿。传统排障依赖日志、top、iostat 和 tcpdump,采样粒度粗,也难以回答"当时内核里发生了什么"。
eBPF 的价值在于:在内核事件发生的位置采集和聚合数据,再把有限的结果交给用户态。它适合做低侵入的动态观测,但不是魔法探针。生产使用前仍要确认内核能力、权限模型、程序复杂度、输出频率和观测组件自身的资源开销。
本文从能力边界、工具选择、现场排障、代码实现和生产化控制五个层面展开。
一、eBPF 能解决什么问题
1. 基本工作方式
eBPF 程序挂载到内核 hook 上,在事件发生时执行:
text
内核事件
├─ 系统调用 tracepoint
├─ 调度 / 块设备 / 网络事件
├─ kprobe / kretprobe
└─ cgroup / socket / 网卡事件
↓
eBPF 程序
↓
maps / ring buffer / perf buffer
↓
用户态采集与导出
程序加载前由内核 verifier 检查:
- 内存访问是否安全;
- 指针是否有效;
- 循环是否有界;
- 栈和 helper 调用是否超出限制;
- 该程序类型能否调用对应 helper。
通过检查后由 JIT 编译执行。verifier 保证的是内核内存安全和程序可终止,不保证业务语义正确,也不代表观测本身没有开销。
2. 适合的观测目标
| 目标 | 典型信号 | 适合的 hook |
|---|---|---|
| 进程异常 | exec / exit / 进程参数 | syscall tracepoint |
| 文件访问 | openat / close / 读写延迟 | syscall、VFS 路径 |
| 块设备延迟 | IO issue 到 complete 的耗时 | block tracepoint |
| 调度延迟 | 进程等待 CPU 的时间 | sched tracepoint |
| 网络质量 | 重传、连接失败、往返时延 | TCP tracepoint |
| CPU 热点 | on-CPU / off-CPU 栈 | perf event |
| 容器资源 | cgroup CPU / 内存 / IO | cgroup、tracepoint |
| 用户态函数耗时 | 指定函数 entry / return | uprobe |
3. 不适合当成什么用
eBPF 不适合替代:
- 访问控制和权限体系;
- 完整安全审计与入侵防御系统;
- 应用内部的业务调用链;
- 时间序列数据库和告警系统;
- 结构化的应用日志。
它更适合作为内核级行为观测的数据源。安全场景可以使用 eBPF 辅助发现异常,但告警、取证和处置仍需要完整流程。
二、现场前置检查
1. 内核与 BTF
先确认内核版本和 BTF:
bash
uname -r
ls -lh /sys/kernel/btf/vmlinux
bpftool feature probe
几点判断:
- 较新的发行版 LTS 内核通常更适合 CO-RE;
- 内核版本旧不代表完全不能用,但可用 hook、helper 和 map 类型可能缺失;
- BTF 存在时,libbpf 可以做 CO-RE,减少对具体内核结构的依赖;
- kprobe 绑定的是内核函数符号,函数名和参数可能随内核变化;
- syscall tracepoint 通常比内核内部函数 kprobe 更适合做长期观测;
- 最终以
bpftool feature probe和目标板实测为准。
2. 权限
加载 eBPF 程序是特权操作。常见方式:
- 调试阶段直接使用 root;
- 生产环境优先使用最小 capability;
- 根据程序类型准备
CAP_BPF、CAP_PERFMON、CAP_NET_ADMIN; - 旧内核可能还涉及
CAP_SYS_RESOURCE和 memlock 限制; - LSM、安全启动、内核 lockdown 策略可能进一步限制加载。
可用下面命令查看当前限制:
bash
capsh --print
ulimit -l
cat /proc/sys/kernel/unprivileged_bpf_disabled
生产代理不要长期以 root 运行。更稳妥的做法是由安装阶段的特权任务完成加载,运行期降低权限,并通过审计日志记录加载和卸载事件。
3. 观测组件自身的资源
工业边缘设备资源有限,观测系统本身要有预算:
- 代理进程 CPU / 内存上限;
- map 数量和容量;
- ring buffer 大小;
- 本地落盘速率;
- 上传带宽;
- flash 写入寿命;
- 断网时的缓冲策略。
如果观测代理把 CPU 或磁盘打满,它就从诊断工具变成了故障源。
三、工具链怎么选
| 工具 | 定位 | 优点 | 注意点 |
|---|---|---|---|
| bpftrace | 一次性排障脚本 | 表达能力强,适合现场验证 | 高频输出要控制;版本和 tracepoint 名随内核变化 |
| BCC | 工具集和 Python 快速开发 | 生态成熟,自带很多观测工具 | 运行时编译依赖较重,嵌入式环境要裁剪 |
| libbpf | 生产 C 程序 | CO-RE、预编译、体积可控 | 需要构建链、BTF 和更完整的工程化 |
| libbpf-rs | Rust 工程集成 | 类型安全,便于打包部署 | 需要 Rust 工具链和 ABI 管理 |
| bpftool | 检查和调试 | 查看 map、prog、链接、feature | 主要面向诊断,不是业务运行时 |
选择建议:
- 现场临时排障:先用 bpftrace;
- 通用指标:优先使用发行版提供的 BCC 工具;
- 长期部署:使用 libbpf / libbpf-rs 做预编译 CO-RE 程序;
- 嵌入式镜像:避免在设备上安装完整编译环境;
- 多内核批量部署:建立内核能力矩阵和兼容性测试。
四、bpftrace 现场实战
1. 查看可用事件
先确认事件名,避免拿网上脚本直接套:
bash
bpftrace -l 'tracepoint:syscalls:*openat*'
bpftrace -l 'tracepoint:sched:*'
bpftrace -l 'tracepoint:block:*'
bpftrace -l 'tracepoint:tcp:*'
2. 观察进程启动
bash
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {
printf("%lld %s %s\n", pid, comm, str(args->filename));
}'
适合排查未知脚本、异常子进程和周期任务。
3. 统计进程切换
bash
bpftrace -e 'tracepoint:sched:sched_switch {
@[args->prev_comm, args->next_comm] = count();
}'
按 Ctrl+C 结束时会输出聚合结果。适合快速判断哪些任务在频繁切换。
4. 观察 VFS 读延迟分布
bash
bpftrace -e 'kprobe:vfs_read {
@start[tid] = nsecs;
}
kretprobe:vfs_read /@start[tid]/ {
@read_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
这个例子用了 kprobe / kretprobe,适合临时诊断。由于它绑定内核内部函数,不同内核版本可能需要调整;长期部署优先使用稳定 tracepoint 或 block 层事件。
5. 观察 TCP 重传
bash
bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb {
@[comm, pid] = count();
}'
如果重传集中在采集进程,可能是上传链路抖动、缓冲区设置不合理或代理重试策略过于激进。
6. 常用现成工具
bash
biolatency # 块设备延迟分布
execsnoop # 新进程
opensnoop # 文件打开
tcpconnect # TCP 主动连接
runqlat # 调度等待延迟
biosnoop # 单次块 IO 详情
tcptop # TCP 收发统计
这些工具输出细节可能因版本不同而有差异。生产环境中不要长期运行 *snoop 这类逐事件输出工具,优先使用延迟聚合类工具。
五、BCC Python 示例
下面统计每个进程的 openat 次数:
python
import time
from bcc import BPF
program = r"""
#include <uapi/linux/ptrace.h>
BPF_HASH(counts, u32, u64);
int trace_open(struct pt_regs *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 zero = 0;
u64 *count;
count = counts.lookup_or_try_init(&pid, &zero);
if (count) {
__sync_fetch_and_add(count, 1);
}
return 0;
}
"""
bpf = BPF(text=program)
bpf.attach_kprobe(
event=bpf.get_syscall_fnname("openat"),
fn_name="trace_open",
)
try:
while True:
time.sleep(5)
print("=" * 30)
for key, value in bpf["counts"].items():
print(f"pid={key.value} openat={value.value}")
except KeyboardInterrupt:
pass
这个例子适合理解 map 的更新方式。BCC 的优势是开发快;缺点是运行时编译需要内核头文件和 LLVM 相关依赖,交叉编译、镜像体积和启动时间都要评估。
六、libbpf CO-RE 示例
生产部署更推荐 libbpf:程序预先编译成对象,运行时由 libbpf 根据目标内核 BTF 做 CO-RE 重定位。
1. eBPF 侧
c
// trace.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u32);
__type(value, u64);
__uint(max_entries, 10240);
} open_counts SEC(".maps");
SEC("tp/syscalls/sys_enter_openat")
int count_openat(struct trace_event_raw_sys_enter *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 one = 1;
u64 *count;
count = bpf_map_lookup_elem(&open_counts, &pid);
if (count) {
__sync_fetch_and_add(count, 1);
} else {
bpf_map_update_elem(&open_counts, &pid, &one, BPF_ANY);
}
return 0;
}
char LICENSE[] SEC("license") = "GPL";
这里使用 syscall tracepoint,比绑定 sys_openat 内核函数更适合跨内核部署。
2. 用户态加载器
c
// trace.c
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <bpf/libbpf.h>
#include "trace.skel.h"
static volatile sig_atomic_t stop;
static void handle_signal(int sig)
{
stop = 1;
}
static void dump_map(struct trace_bpf *skel)
{
int fd = bpf_map__fd(skel->maps.open_counts);
__u32 key = -1;
__u32 next_key;
__u64 value;
while (bpf_map_get_next_key(fd, &key, &next_key) == 0) {
if (bpf_map_lookup_elem(fd, &next_key, &value) == 0) {
printf("pid=%u openat=%llu\n", next_key, value);
}
key = next_key;
}
}
int main(void)
{
struct trace_bpf *skel;
int err;
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
skel = trace_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "failed to open and load BPF object\n");
return 1;
}
err = trace_bpf__attach(skel);
if (err) {
fprintf(stderr, "failed to attach BPF program: %d\n", err);
trace_bpf__destroy(skel);
return 1;
}
while (!stop) {
sleep(5);
dump_map(skel);
}
trace_bpf__destroy(skel);
return 0;
}
3. 构建流程
以 x86_64 为例:
bash
clang --target=bpf -D__TARGET_ARCH_x86 \
-O2 -g -c trace.bpf.c -o trace.bpf.o
bpftool gen skeleton trace.bpf.o > trace.skel.h
cc -O2 -g -Wall trace.c -o trace -lbpf -lelf -lz
交叉编译时把 __TARGET_ARCH_x86 换成目标架构,例如 ARM64 对应 __TARGET_ARCH_arm64。生成 vmlinux.h、选择内核 BTF、静态链接和最小根文件系统裁剪都应纳入构建流水线。
七、工业边缘常见观测方案
1. 块设备与存储抖动
关注指标:
- IO 延迟 P95 / P99;
- 队列深度;
- 每秒读写字节;
- 写放大;
- flash 写入速率。
适合判断数据落盘、日志刷写和数据库提交是否影响实时任务。
2. 调度与 CPU
关注指标:
- 运行队列延迟;
- on-CPU 热点;
- off-CPU 等待;
- 优先级反转;
- 采集任务周期抖动。
适合排查协议轮询、压缩、解析和上传任务对实时性的影响。
3. 网络与上传链路
关注指标:
- TCP 重传;
- 连接失败;
- 连接耗时;
- 收发队列;
- 网卡丢包。
适合判断断线重连、云端接口延迟和本地队列积压之间的关系。
4. 进程与文件行为
关注指标:
- 异常 exec;
- 频繁打开配置或日志文件;
- 关键文件读写;
- 僵尸进程;
- 进程退出原因。
适合辅助定位脚本异常、误配置、日志轮转失控和供应链问题。
5. 容器与 cgroup
容器场景要把内核事件和 cgroup / namespace 关联起来:
- cgroup 级 CPU 与内存;
- 容器内进程映射;
- IO 归属;
- 网络命名空间;
- 容器重启前后的行为。
只看容器引擎自身的指标,往往无法解释内核级抖动。
八、输出与开销控制
eBPF 本身有较低开销,但"每个事件都输出到用户态"会明显放大成本。生产方案要区分三类数据:
| 类型 | 例子 | 建议 |
|---|---|---|
| 聚合指标 | 计数、直方图、P95 | 内核态聚合,周期性导出 |
| 事件样本 | 异常 exec、慢 IO | 限流、采样、保留上下文 |
| 全量原始流 | 每个 syscall、每条包 | 只用于短时调试,不长期开启 |
降载策略
- 在 eBPF 内先过滤 PID、cgroup、挂载点和方向;
- 使用 histogram / count / log2 聚合,避免逐条输出;
- ring buffer 设置丢弃策略,不阻塞业务路径;
- 用户态批量消费;
- 异常事件设置每分钟上限;
- 栈采集按需开启;
- map 设置
max_entries,控制基数; - 周期任务错峰执行;
- 输出数据先落内存或队列,再异步上传;
- 断网时限制本地磁盘保留量。
必须监控观测组件自身
至少记录:
- eBPF 代理 CPU / 内存;
- map 使用率;
- ring buffer 丢弃次数;
- 程序加载失败原因;
- verifier 拒绝日志;
- 采集任务耗时;
- 本地队列长度;
- 上传失败和重试次数。
上线前做 A/B 对比:开启观测前后分别记录业务延迟、CPU、上下文切换、IO 和网络指标,确认影响在预算内。
九、权限与部署策略
1. 分阶段部署
text
开发环境验证
↓
目标内核矩阵测试
↓
单站点灰度
↓
只读观测
↓
设置资源上限
↓
批量启用
2. 安装期与运行期分离
安装期可以提升权限完成:
- 检查内核能力;
- 加载 eBPF 程序;
- 创建 pin map;
- 配置 capability;
- 写入审计记录。
运行期只需要读取 map、消费事件和导出指标,尽量降低权限。
3. 升级与回滚
- eBPF 对象记录版本号;
- 保留内核与程序版本兼容矩阵;
- 升级失败自动卸载新程序;
- map schema 变更要有迁移策略;
- 长期 pinned map 要纳入清理机制;
- 卸载后确认 hook 和 map 已释放。
十、常见坑与修正
| 问题 | 原因 | 修正 |
|---|---|---|
| 程序在开发机可用,现场加载失败 | 内核版本、BTF、helper 或安全策略不同 | 建立内核矩阵,使用 bpftool feature probe |
| kprobe 脚本跨内核失效 | 内核函数名和参数变化 | 长期观测优先 tracepoint,kprobe 只做临时诊断 |
| CPU 开销突然升高 | 逐事件输出、高频栈采集、map 基数过大 | 聚合、过滤、限流、设置容量 |
| 磁盘被观测数据写满 | 全量日志落盘 | 样本化、限额、轮转和断网保护 |
| 权限不足 | capability、LSM、lockdown 限制 | 明确程序类型所需权限并纳入部署检查 |
| verifier 拒绝循环 | 循环上界无法证明 | 使用有界循环和更小的数据结构 |
| 数据缺失 | ring buffer 溢出或事件被过滤 | 记录丢弃次数并调整预算 |
| 安全误判 | 把 eBPF 当完整审计系统 | 结合进程上下文、资产、策略和人工复核 |
十一、生产落地检查清单
- 已确认内核版本、BTF、可用 hook 和 helper;
- 已区分调试脚本和长期采集程序;
- 长期程序优先使用 tracepoint 和 CO-RE;
- 已交叉编译并验证目标架构;
- 已在测试环境记录 verifier 错误场景;
- 已配置最小权限,不以 root 长期运行代理;
- 已设置 map 容量和 ring buffer 大小;
- 高频事件已聚合或采样;
- 异常事件有输出上限;
- 断网时本地缓存有磁盘上限;
- 观测代理自身 CPU / 内存 / 队列有监控;
- 加载、卸载、失败和升级有审计日志;
- 已做灰度、回滚和内核兼容测试;
- 已确认观测开启前后的业务性能差异。
TL;DR
eBPF 适合工业边缘的内核级动态观测:不重启进程、不改业务代码,就能采集系统调用、调度、块 IO、网络和 cgroup 行为。
关键工程结论:
- bpftrace 适合现场临时诊断,BCC 适合快速开发和通用工具,libbpf / libbpf-rs 更适合长期部署;
- CO-RE 依赖 BTF,最终以目标内核能力探测为准;
- 长期观测优先稳定 tracepoint,慎用内核内部 kprobe;
- eBPF 不是零开销,必须在内核态聚合、过滤和限流;
- 权限需要最小化,加载失败可能来自 capability、LSM 或 lockdown;
- 观测组件自身也必须被监控;
- 上线前做内核矩阵、资源预算、灰度和回滚方案。
用 eBPF 看见内核事件只是第一步,把它变成资源可控、权限清晰、可回滚的观测能力,才是工业边缘生产化的重点。