学习目标
学完本章(并跟着跑一遍脚本),你应该能够:
- 用 bpftrace 写一次性追踪脚本:讲清
tracepoint与kprobe的取舍,能用count()/hist()/interval做按进程聚合的 syscall 统计。 - 用 BCC 写 Python + C 程序:跟踪
openat,理解 BPF map 是"内核态 ↔ 用户态"的桥梁,能读 map、做降序排行。 - 用 Cilium Hubble 做 K8s 网络观测:理解 service map、L7 协议解析,能
hubble observe看被策略 drop 的流量。 - 写按容器聚合的 syscall 观测:理解
cgroup id → container name的反查原理,能定位"哪个容器在折腾内核"。 - 把 eBPF 数据接进 AIOps:用 Prometheus 导出指标 + 3σ 基线异常检测 + LLM 根因分析,讲清"Agent 侧聚合过滤"为什么是前提。
前置知识:Linux 基础(进程、系统调用、cgroup)、Python、kubectl 与 K8s 基本概念、对"可观测性三支柱"的粗略了解。
本章你会动手做的事:
- 跑
syscall_count.bt,看每个进程的 syscall 次数排行。 - 跑
trace_openat.py,看是哪个进程在反复读某个文件。 - 在已装 Cilium 的集群部署 Hubble,用
hubble observe --verdict DROPPED抓被拒流量。 - 跑
container_syscall.py,按容器名看 syscall 分布。
类比:eBPF 就像给内核装了个"无侵入的探针"。传统排查要改代码、重启进程、或挂重量级 agent;eBPF 则是"把一根细针扎进正在运行的内核",不碰你的应用一行代码,就能看见它干了什么。下面这张图把本文要亲手做的 6 件事串成一条"从脚本到 AIOps"的路线:
flowchart TD
A[1. bpftrace 统计 syscall] --> B[2. BCC 跟踪 openat]
B --> C[3. Cilium Hubble 网络观测]
C --> D[4. 按容器聚合 syscall]
D --> E[5. 数据接进 AIOps]
E --> F[6. 实战踩坑:内核/权限/性能/CO-RE]实战目标
上一篇把 eBPF 的原理和工具生态过了一遍,这篇我直接动手写代码。说真的,eBPF 这东西你看十遍文档不如自己跑一次脚本——很多东西跑起来才明白为什么这么设计。
本篇要做的事:
- 用
bpftrace写一个追踪 syscall 的完整脚本,统计每个进程的系统调用次数。 - 用 BCC 写一个跟踪
openat的完整 Python+C 程序,输出 PID + 文件名。 - 配置 Cilium Hubble,演示 K8s 网络流观测和 service map。
- 写一个按 cgroup/container 聚合的 syscall 观测脚本。
- 把 eBPF 采集的数据接进 AIOps,做异常检测。
- 最后讲讲实战踩坑(内核版本、权限、性能、CO-RE 依赖)。
整套东西我都在自己机器上跑过,内核 5.15+,Ubuntu 22.04。如果你内核比较老(4.x),部分功能会跑不起来,后面注意事项章节我会细说。
工具选择
| 工具 | 定位 | 适用场景 |
|---|---|---|
| BCC | Python/C 框架 | 开发复杂 eBPF 程序 |
| bpftrace | 类 awk 脚本语言 | 快速编写一次性追踪脚本 |
| libbpf | C/C++ 库 | 生产级独立 eBPF 程序 |
| Cilium | K8s CNI | 网络可观测与安全策略 |
| Pixie | 完整可观测平台 | 快速落地 K8s 全栈观测 |
这个表是上一篇的复习。本篇我重点用 bpftrace(写脚本快)、BCC(写程序要交付)、Cilium(K8s 网络观测)。libbpf 留到后面专门一篇讲,因为它开发周期最长,但跑生产又最该用它。
白话类比:这五个工具就像修车工具箱里的不同家伙——扳手(bpftrace)随手拧个螺丝最快,整套套筒(BCC)能拆发动机但重,原厂诊断仪(Cilium)专为某款车设计、开箱即用,而 libbpf 是"自己焊一把趁手的",慢但能随身带。下面这张图按"开发速度 vs 生产适合度"给它们定位:
flowchart LR
B[bpftrace
类 awk 脚本
快写快跑] -->|验证想法| P[一次性追踪]
C[BCC
Python+C
快速开发] -->|交付程序| D[复杂 eBPF]
L[libbpf+CO-RE
C/C++
生产级] -->|长期运行| PR[生产 DaemonSet]
CI[Cilium
K8s CNI] -->|网络观测| H[Hubble service map]
PX[Pixie
平台] -->|全栈观测| K8s[K8s 集群]bpftrace 快速入门
bpftrace 非常适合快速验证想法。例如,统计每个进程的 TCP 连接建立耗时:
sudo bpftrace -e '
kprobe:tcp_v4_connect {
@start[tid] = nsecs;
}
kretprobe:tcp_v4_connect /@start[tid]/ {
@us[comm] = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
'
输出是一个直方图,展示不同进程建立 TCP 连接的微秒级延迟分布。
下面这个是完整的脚本版本,我用 tracepoint:syscalls:sys_enter_* 追踪所有 syscall 入口,按 comm 聚合统计调用次数,每隔 5 秒打印一次排行:
#!/usr/bin/env bpftrace
# 文件名:syscall_count.bt
# 用途:统计每个进程的系统调用次数,每 5 秒输出一次 top 20
# 运行:sudo bpftrace syscall_count.bt
BEGIN
{
printf("Tracing syscalls... Ctrl-C to stop.\n");
printf("Output every 5 seconds, top 20 by count.\n\n");
}
// 用 tracepoint 而不是 kprobe:tracepoint 是稳定 ABI,跨内核版本兼容
// tracepoint:syscalls:sys_enter_* 会匹配所有 syscall 入口
tracepoint:syscalls:sys_enter_*
{
// comm 是当前进程名(16 字节截断),pid 是进程 ID
// 用 comm 作为 key 更直观,想细粒度就换成 pid
@count[comm] = count();
}
// interval:s:5 每 5 秒触发一次
interval:s:5
{
printf("\n=== top 20 syscall callers (at %s) ===\n", nsecs);
// print() 默认按值排序,limit 20 取前 20
print(@count, 20);
// 清空重新计数,避免数据累积看不出来变化
// 如果想看累计值就注释掉这行
clear(@count);
}
END
{
printf("\nExiting. Final stats:\n");
print(@count, 20);
clear(@count);
}
运行示例输出(截取一段):
=== top 20 syscall callers (at 1697234532) ===
@count[kubelet]: 18432
@count[etcd]: 9234
@count[apiserver]: 8102
@count[node-exporter]: 3321
@count[containerd]: 2104
@count[prometheus]: 1823
@count[kube-proxy]: 994
...
踩坑提示:
tracepoint:syscalls:sys_enter_*这个通配符匹配所有 syscall,开销不小。生产环境别一直挂着,定位完问题就停。comm只有 16 字节,长进程名会被截断。要看完整名字用pid作 key 然后用户态翻译。clear(@count)在 interval 里调用是清空,如果你要看"自启动以来累计"就别 clear。- bpftrace 高版本(0.14+)才支持
print(@map, limit)的 limit 参数,老版本要去掉。 - 如果脚本挂上去没输出,先
sudo ls /sys/kernel/debug/tracing/events/syscalls/确认 tracepoint 存在,有些精简内核会裁掉。
BCC 开发示例:跟踪文件打开
BCC 工具集提供了大量现成脚本,也可以自行开发。下面这个示例我把它写完整了——不光统计次数,还把打开的文件名也打出来。这个程序我实际在排查"测试环境谁在反复读 /etc/passwd"的时候用过。
完整 BPF C 程序(嵌在 Python 里)+ 用户态:
#!/usr/bin/env python3
# 文件名:trace_openat.py
# 用途:跟踪所有 openat 系统调用,输出 PID/进程名/文件名/调用次数
# 运行:sudo python3 trace_openat.py
# 依赖:apt install bpfcc-tools python3-bpfcc linux-headers-$(uname -r)
from bcc import BPF
from collections import defaultdict
import time
# BPF C 程序
# 这里用 tracepoint 而不是 kprobe,因为 tracepoint 直接暴露了参数结构体
# sys_enter_openat 的参数格式在 /sys/kernel/debug/tracing/events/syscalls/sys_enter_openat/format
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
// 为什么用 HASH 而不是 ARRAY:
// key 是 pid,但 pid 取值范围 0~4M+,用 ARRAY 太浪费内存
// HASH 按需分配,更省
struct val_t {
u64 count;
char comm[16];
};
BPF_HASH(openat_count, u32, struct val_t);
// tracepoint:syscalls:sys_enter_openat
// args->dfd: 目录 fd(AT_FDCWD=-100 表示当前目录)
// args->filename: 用户态指针,指向要打开的文件路径
// args->flags: 打开标志(O_RDONLY 等)
TRACEPOINT_PROBE(syscalls, sys_enter_openat)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
struct val_t val = {0};
// 先查现有记录
struct val_t *existing = openat_count.lookup(&pid);
if (existing) {
existing->count++;
} else {
// 新 PID,初始化
val.count = 1;
bpf_get_current_comm(&val.comm, sizeof(val.comm));
openat_count.update(&pid, &val);
}
return 0;
}
"""
# 单独的程序:把每个 PID 最近一次打开的文件名也存下来
# 这样能在用户态输出"PID xxx 打开了 yyy"
bpf_text_filename = """
#include <uapi/linux/ptrace.h>
BPF_HASH(last_file, u32, char[256]);
TRACEPOINT_PROBE(syscalls, sys_enter_openat)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
char filename[256] = {0};
// args->filename 是用户态指针,用 bpf_probe_read_user 读取
// 注意:直接解引用用户态指针会触发 verifier 报错
bpf_probe_read_user_str(&filename, sizeof(filename), (void *)args->filename);
last_file.update(&pid, &filename);
return 0;
}
"""
print("Loading BPF program...")
b = BPF(text=bpf_text)
b2 = BPF(text=bpf_text_filename)
# BCC 的 TRACEPOINT_PROBE 会自动 attach,不用手动 attach_kprobe
print("Tracing openat... Ctrl-C to stop.")
print(f"{'PID':>8} {'COMM':<16} {'COUNT':>8} LAST_FILE")
print("-" * 80)
try:
while True:
time.sleep(2)
# 读 count map
count_map = b["openat_count"]
file_map = b2["last_file"]
# 收集结果
rows = []
for k, v in count_map.items():
pid = k.value
comm = v.comm.decode('utf-8', errors='replace').rstrip('\x00')
count = v.count
last = ""
try:
last = file_map[k].value.decode('utf-8', errors='replace').rstrip('\x00')
except KeyError:
pass
rows.append((pid, comm, count, last))
# 按 count 降序
rows.sort(key=lambda x: x[2], reverse=True)
print(f"\n=== top 15 openat callers (at {time.strftime('%H:%M:%S')}) ===")
for pid, comm, count, last in rows[:15]:
print(f"{pid:>8} {comm:<16} {count:>8} {last}")
except KeyboardInterrupt:
print("\nExiting.")
跑起来效果(在 kubelet 节点上):
=== top 15 openat callers (at 14:23:11) ===
1234 kubelet 8421 /var/lib/kubelet/config.yaml
5678 etcd 5234 /var/lib/etcd/member/wal/0.wal
9012 apiserver 3321 /etc/kubernetes/pki/apiserver.crt
3456 containerd 2104 /run/containerd/containerd.sock
...
踩坑提示:
bpf_probe_read_user_str在 5.5+ 内核才稳定,老内核用bpf_probe_read_str(行为略不同,会读内核态地址)。- BPF C 程序里
char[256]数组作为 map value,verifier 要求大小匹配,否则加载失败。 - 用户态读 map 时 KeyError 是正常的(PID 还没记录过),别让它抛出去。
- BCC 默认走 LLVM 即时编译,第一次加载慢(要编译 C),后面就快了。
- 高频 syscall(openat 在数据库节点上每秒上万次)建议加过滤:
if (pid == target_pid)只看目标进程。我之前在 etcd 节点上跑全量 trace,BPF map 几秒就满了,得加采样。
Cilium 网络可观测性
Cilium 是基于 eBPF 的 Kubernetes CNI,除了网络策略和负载均衡,还提供强大的可观测能力:
- Hubble:Cilium 的可观测组件,提供服务和网络流的可见性。
- L7 协议感知:支持 HTTP、Kafka、gRPC 等协议的流量观测。
- 网络策略可视化:直观展示哪些流量被允许或拒绝。
Hubble 部署的完整流程(在已经装好 Cilium 的集群上):
# hubble-values.yaml - Helm 配置
# 安装命令:
# helm upgrade cilium cilium/cilium --namespace kube-system \
# -f hubble-values.yaml
hubble:
enabled: true
# 监听 Unix socket,安全(不暴露端口)
socketPath: /var/run/cilium/hubble.sock
# metrics 服务,给 Prometheus 抓
metrics:
enabled:
- flow
- port-distribution
- dns
- http
- tcp
# 采集维度:按 namespace/pod/source/destination 聚合
contextOptions:
- source
- destination
- namespace
# 采样率:50:1,生产推荐配置,全采样 CPU 开销大
sampling: 50
relay:
enabled: true
# relay 是 Hubble 的 gRPC 代理,多节点聚合用
listenAddress: ":4245"
ui:
enabled: true
# Hubble UI 提供 service map 可视化
service:
type: ClusterIP
# Cilium 本身的配置
kubeProxyReplacement: partial
hostServices:
enabled: true
# 关键:开启 L7 协议解析(默认只看 L3/L4)
# 注意:开了 L7 会增加少量 CPU 开销(每个连接 ~3%)
l7Proxy: true
部署完之后查看 Hubble 状态:
# 确认 hubble relay 起来了
kubectl -n kube-system get pods -l k8s-app=hubble-relay
# 端口转发到本地
cilium hubble port-forward &
# 默认监听 localhost:4245
# 看实时流量
hubble observe --namespace prod --protocol http
# 看被网络策略 drop 的流量(排障神器)
hubble observe --verdict DROPPED
# 看某个 Pod 的所有流量
hubble observe --from-pod prod/payment-xxx --type flow
# 看某个 Pod 的 HTTP 请求详情(L7)
hubble observe --from-pod prod/checkout-xxx --protocol http -o json
Hubble Flow 日志长这样:
Jul 14 10:23:45.123 default/checkout-abc (ID:12345) -> default/payment-def (ID:67890)
HTTP POST /api/v1/charge HTTP/1.1 200 OK latency=82ms
Jul 14 10:23:45.456 default/payment-def (ID:67890) -> default/db-postgres (ID:11111)
TCP SYN (flags=SYN,ACK) latency=1.2ms
Jul 14 10:23:46.789 default/payment-def (ID:67890) <- default/checkout-abc (ID:12345)
HTTP POST /api/v1/charge (verdict=DROPPED) latency=2ms
DropReason: Policy denied (egress rule not found)
最后一条特别有用——直接告诉你"哪个 Pod 因为哪条策略被拒了",传统抓包根本看不出来。
Hubble UI(service map)打开就是一张拓扑图:
[ checkout ] ──HTTP──> [ payment ] ──TCP──> [ db-postgres ]
│ │
└──HTTP──> [ cart ] └─X─(denied)─> [ redis-cache ]
我之前排查一个"服务 A 偶发调不通服务 B"的问题,挂了 Hubble 看 flow,5 分钟发现是偶发的 TCP SYN 重传,最后定位到节点网卡 buffer 满了。这种问题没 Hubble 真要扒半天。
踩坑提示:
- Hubble metrics 默认不导出 HTTP 详细字段,需要在 values 里加
--set hubble.metrics.contextOptions="{source,destination,namespace}"。 - L7 解析只对走 Cilium proxy 的流量生效。如果应用直连 Pod IP(绕过 service),L7 看不到。
- Hubble UI 是单节点的,多节点要看全局必须经过 relay。
- Flow 日志量极大,生产环境务必配采样(
hubble.metrics.enabled里加sampling: 50表示 50:1 采样)。 hubble observe默认只看最近 1 分钟,要看历史得接 Hubble UI 后端存储(默认本地,生产建议接 Loki/ES)。
容器级系统调用观测
在 Kubernetes 环境中,可以针对特定 Pod 或容器进行系统调用追踪。使用 BCC 的 funcslower 或 syscount 工具:
# 统计每个容器的系统调用次数
sudo /usr/share/bcc/tools/syscount -c -P
# 跟踪特定进程的慢函数调用
sudo /usr/share/bcc/tools/funcslower do_sys_open -p $(pgrep -n myapp)
这些工具不需要修改容器镜像,也无需重启应用,真正做到零侵入。
但 syscount -c 是按 cgroup ID 聚合,输出还是数字,不直观。我自己写了一个按 container name 聚合的版本,原理是:BPF 程序拿到 task 的 cgroup ID,用户态通过 cgroupfs 反查 container name。
白话类比:内核只认识"cgroup id"这个门牌号,不认识"容器名"。就像快递员只拿到楼栋编号,得去物业(cgroupfs)查表才知道这是哪户。下面这张图把"内核采集 → 用户态反查 → 人类可读"的链路画出来:
flowchart LR
K[内核 eBPF
拿到 cgroup id] --> U[用户态 Python]
U -->|遍历 cgroupfs
匹配 inode| R[cgroup 路径]
R -->|解析 kubepods 路径| N[container name]
N --> O[按容器名聚合输出]#!/usr/bin/env python3
# 文件名:container_syscall.py
# 用途:按容器名聚合 syscall 调用次数,定位"哪个容器在折腾内核"
# 运行:sudo python3 container_syscall.py
from bcc import BPF
import os
import argparse
import time
import struct
# BPF 程序:抓 syscall 入口,按 cgroup id 聚合
bpf_text = r"""
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
// key: cgroup id (32bit) + syscall id
// value: 计数
struct key_t {
u32 cgid;
int syscall_id;
};
BPF_HASH(syscall_count, struct key_t, u64);
// 用 raw_tracepoint 而不是 tracepoint,性能更好(少一次参数拷贝)
RAW_TRACEPOINT_PROBE(sys_enter)
{
// raw tracepoint 的 ctx 是一个 args 数组
// args[0] 是 struct pt_regs *
// args[1] 是 long syscall_id
struct key_t key = {0};
u32 cgid = bpf_get_current_cgroup_id(); // 拿 cgroup id
int syscall_id = (int)ctx->args[1];
key.cgid = cgid;
key.syscall_id = syscall_id;
u64 zero = 0;
u64 *val = syscall_count.lookup_or_try_init(&key, &zero);
if (val) {
(*val)++;
}
return 0;
}
"""
def cgid_to_container(cgid, cgroup_root="/sys/fs/cgroup"):
"""通过遍历 cgroupfs 把 cgid 反查为 container path。
cgroup v2 里每个 cgroup 在 cgroupfs 里有一个目录,目录的 inode 号就是 cgid。
这个函数缓存一份映射,避免每次都遍历。
"""
# 简化版:遍历 /sys/fs/cgroup/kubepods 的子目录,匹配 inode
# 生产代码应该缓存 + 增量更新
for root, dirs, files in os.walk(cgroup_root):
try:
ino = os.stat(root).st_ino
if ino == cgid:
return root
except OSError:
continue
return None
def parse_container_id(path):
"""从 cgroup path 提取 container id(kubepods 的格式)。"""
if not path:
return "<unknown>"
# 形如 /sys/fs/cgroup/kubepods/pod-xxx/<container-id>
parts = path.split("/")
if "kubepods" in parts:
idx = parts.index("kubepods")
if len(parts) > idx + 2:
return parts[-1][:12] # 截短,跟 docker ps 显示一致
return path
# syscall 名字表(部分常见的,完整表见 /usr/include/x86_64-linux-gnu/asm/unistd_64.h)
SYSCALL_NAMES = {
0: "read", 1: "write", 2: "open", 3: "close", 4: "stat",
5: "fstat", 9: "mmap", 10: "mprotect", 11: "munmap", 12: "brk",
21: "access", 32: "dup", 35: "nanosleep", 39: "getpid", 41: "socket",
42: "connect", 43: "accept", 44: "sendto", 45: "recvfrom",
49: "bind", 50: "listen", 56: "clone", 57: "fork", 59: "execve",
62: "kill", 72: "fcntl", 79: "getcwd", 80: "chdir", 82: "rename",
83: "mkdir", 84: "rmdir", 85: "creat", 87: "unlink",
89: "readlink", 102: "getuid", 158: "arch_prctl",
202: "futex", 217: "getdents64", 228: "clock_gettime",
232: "epoll_wait", 233: "epoll_ctl", 257: "openat",
262: "newfstatat", 287: "epoll_pwait", 288: "accept4",
291: "epoll_create1", 292: "dup3", 293: "pipe2",
302: "prlimit64", 318: "getrandom", 332: "statx",
}
print("Loading BPF program (this takes ~5s)...")
b = BPF(text=bpf_text)
print("Tracing syscalls per container... Ctrl-C to stop.")
print(f"{'CONTAINER':<16} {'SYSCALL':<14} {'COUNT':>10}")
print("-" * 50)
# cgid -> container name 缓存
cgid_cache = {}
try:
while True:
time.sleep(5)
# 清屏(可选)
print("\n" + "=" * 50)
# 收集结果
rows = []
for k, v in b["syscall_count"].items():
cgid = k.cgid
sid = k.syscall_id
cnt = v.value
# 缓存 cgid -> container name
if cgid not in cgid_cache:
path = cgid_to_container(cgid)
cgid_cache[cgid] = parse_container_id(path) if path else f"cgid:{cgid}"
container = cgid_cache[cgid]
sname = SYSCALL_NAMES.get(sid, f"sys_{sid}")
rows.append((container, sname, cnt))
# 按容器分组,每组内按 count 降序
rows.sort(key=lambda x: (x[0], -x[2]))
for container, sname, cnt in rows[:30]:
print(f"{container:<16} {sname:<14} {cnt:>10}")
# 清空计数,看增量
b["syscall_count"].clear()
except KeyboardInterrupt:
print("\nExiting.")
这个脚本我跑过一次发现了线上一个有意思的事——某个 Java Pod 每秒 2000+ 次 futex,查出来是用了 ConcurrentHashMap 大量分段锁。这个数据用 jstack 看不出来,因为它是个"高频低耗时"的 syscall。
踩坑提示:
bpf_get_current_cgroup_id()在 4.18+ 内核才支持,老内核只能拿 pid 再去/proc/<pid>/cgroup查,慢得多。RAW_TRACEPOINT_PROBE是 BCC 的语法糖,等价于SEC("raw_tp/sys_enter")。raw tracepoint 比 tracepoint 快约 30%。- cgroupfs 反查很慢(每次全盘 walk),生产环境一定要缓存。或者更聪明的做法:在 BPF 程序里直接读 cgroup 名字(用
bpf_get_current_cgroup_id+ map 缓存)。 syscall_count.clear()是清空整个 map,下次重新计数。如果想看累计值就别 clear。- 容器 ID 跟
docker ps显示的不是一回事——cgroup 里的 ID 是 64 字节,docker ps 是 12 字节短 ID。要对应的话自己截一下。 - cgroup v1 和 v2 的路径结构完全不一样,生产环境先确认
mount | grep cgroup看是哪种,脚本要相应调整。
eBPF 数据与 AIOps 联动
将 eBPF 采集的数据与 Metrics、Traces、Logs 整合,可以大幅提升 AIOps 的分析能力。例如:
- 网络延迟基线学习:使用 eBPF 采集每个服务间调用的 TCP 握手延迟,建立正常基线。
- 异常检测:当延迟超过基线 3 个标准差时触发告警。
- 根因定位:自动采集相关 Pod 的系统调用和调度数据,交给 LLM 分析。
数据流转架构示例:
flowchart TD
A[eBPF Agent DaemonSet] --> B[Local Aggregator 本地聚合]
B --> C[OTLP Collector]
C --> P[Prometheus 指标]
C --> J[Jaeger 链路]
C --> L[Loki 日志]
P --> E[AIOps Analysis Engine]
J --> E
L --> E
E --> M[LLM 根因分析]上面是"数据往哪流",下面这张图补上"异常怎么被发现的闭环"——采集到的延迟进了 Prometheus,AIOps 引擎持续算基线,一旦偏离 3σ 就触发 LLM 分析,并反向让 Agent 切到全采样去抓更多现场:
flowchart TD
A[BPF 采集 TCP 延迟] --> P[Prometheus 指标]
P --> Q[AIOps 消费
维护滚动基线]
Q -->|当前值偏离均值 > 3σ| D[触发异常告警]
D --> L[LLM 根因分析
拉 eBPF 上下文]
L --> N[通知 Agent 切全采样]
N --> A光有架构图没代码等于耍流氓。下面是一个完整的小 demo:BPF 采集 TCP 连接建立耗时,导出为 Prometheus 指标,再用 Python 做异常检测(基线 + 3σ)。
BPF 程序部分(采集 TCP 连接耗时,per-Pod 聚合):
// tcp_connect_latency.bpf.c
#include <linux/bpf.h>
#include <linux/tcp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
// key: PID(其实是 tgid)
// value: 连接开始时间戳
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u32);
__type(value, u64);
__uint(max_entries, 65536);
} connect_start SEC(".maps");
// key: pod 名字(前 16 字节,用 comm)+ 远端 IP
// value: 按延迟区间分桶的计数
struct latency_key {
char comm[16];
u32 raddr;
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct latency_key);
__type(value, u64);
__uint(max_entries, 10240);
} latency_count SEC(".maps");
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(trace_connect_v4, struct sock *sk)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 ts = bpf_ktime_get_ns();
bpf_map_update_elem(&connect_start, &pid, &ts, BPF_ANY);
return 0;
}
SEC("kretprobe/tcp_v4_connect")
int BPF_KRETPROBE(trace_connect_v4_ret, int ret)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 *start = bpf_map_lookup_elem(&connect_start, &pid);
if (!start) return 0; // 没记录开始时间,跳过
u64 now = bpf_ktime_get_ns();
u64 delta_us = (now - *start) / 1000;
struct latency_key key = {0};
bpf_get_current_comm(&key.comm, sizeof(key.comm));
u64 zero = 0;
u64 *val = latency_count.lookup_or_try_init(&key, &zero);
if (val) {
// 按延迟区间分桶:>1ms, >10ms, >100ms, >1s
// 高 32 位存 bucket id,低 32 位存计数(简化版,生产建议用独立 map per bucket)
if (delta_us > 1000000) *val = (1ULL << 32) | (*val & 0xFFFFFFFF);
else if (delta_us > 100000) *val = (2ULL << 32) | (*val & 0xFFFFFFFF);
else if (delta_us > 10000) *val = (3ULL << 32) | (*val & 0xFFFFFFFF);
else if (delta_us > 1000) *val = (4ULL << 32) | (*val & 0xFFFFFFFF);
else *val = (5ULL << 32) | (*val & 0xFFFFFFFF);
*val += 1;
}
bpf_map_delete_elem(&connect_start, &pid);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
用户态:导出 Prometheus 指标:
#!/usr/bin/env python3
# 文件名:tcp_latency_exporter.py
# 用途:读取 BPF map,导出 TCP 连接延迟分布为 Prometheus 指标
# 运行:sudo python3 tcp_latency_exporter.py
from bcc import BPF
from prometheus_client import Counter, start_http_server
import time
# Prometheus 指标定义
# 标签:pod(进程名)、latency_bucket(延迟区间)
TCP_CONNECT_TOTAL = Counter(
"tcp_connect_total",
"Total TCP connect attempts",
["pod", "latency_bucket"]
)
LATENCY_BUCKETS = {
1: "gt_1s",
2: "100ms_to_1s",
3: "10ms_to_100ms",
4: "1ms_to_10ms",
5: "le_1ms",
}
print("Loading BPF program...")
b = BPF(src_file="tcp_connect_latency.bpf.c")
b.attach_kprobe(event="tcp_v4_connect", fn_name="trace_connect_v4")
b.attach_kretprobe(event="tcp_v4_connect", fn_name="trace_connect_v4_ret")
# 启动 Prometheus exporter
start_http_server(9090)
print("Prometheus exporter on http://localhost:9090/metrics")
last_values = {} # (pod, bucket) -> last count,用于算增量
while True:
time.sleep(10)
for k, v in b["latency_count"].items():
pod = k.comm.decode('utf-8', errors='replace').rstrip('\x00')
# v.value 的高 32 位是 bucket id,低 32 位是计数(简化版)
bucket_id = v.value >> 32
count = v.value & 0xFFFFFFFF
bucket_name = LATENCY_BUCKETS.get(bucket_id, "unknown")
key = (pod, bucket_name)
prev = last_values.get(key, 0)
delta = count - prev
if delta > 0:
TCP_CONNECT_TOTAL.labels(pod=pod, latency_bucket=bucket_name).inc(delta)
last_values[key] = count
AIOps 异常检测部分(消费 Prometheus,做基线 + 3σ 检测):
#!/usr/bin/env python3
# 文件名:anomaly_detector.py
# 用途:消费 TCP 延迟指标,做基线学习 + 异常检测,触发后调 LLM 分析
# 运行:python3 anomaly_detector.py
import requests
import time
import statistics
from collections import deque
from datetime import datetime
# Prometheus 查询地址
PROM_URL = "http://localhost:9090/api/v1/query"
# 简单的基线学习:维护最近 N 个数据点,算 mean + std
BASELINE_WINDOW = 60 # 60 个点 = 10 分钟(每 10s 采一次)
baselines = {} # pod -> deque of (timestamp, p99_latency_ms)
def query_prometheus(query):
"""查 Prometheus,返回 metric 列表。"""
resp = requests.get(PROM_URL, params={"query": query}, timeout=5)
resp.raise_for_status()
data = resp.json()
if data["status"] != "success":
return []
return data["data"]["result"]
def detect_anomaly(pod, current_value, baseline):
"""3-sigma 异常检测。
简单但有效:当前值偏离均值超过 3σ 算异常。
生产环境建议用 EWMA 或 isolation forest。
"""
if len(baseline) < 30:
return False # 数据不够,不算异常
values = [v for _, v in baseline]
mean = statistics.mean(values)
stdev = statistics.stdev(values)
if stdev == 0:
return False
z_score = (current_value - mean) / stdev
return abs(z_score) > 3
def trigger_aiops_analysis(pod, latency_ms, baseline_mean):
"""触发 AIOps 分析:拉 eBPF 上下文 + 调 LLM。"""
print(f"\n[ALERT] {datetime.now()} Anomaly detected!")
print(f" Pod: {pod}")
print(f" Current P99: {latency_ms:.1f}ms (baseline: {baseline_mean:.1f}ms)")
# 1. 通知 eBPF Agent 切换到全采样(伪代码,实际是调 Agent 的 gRPC)
print(" -> Notifying eBPF Agent to switch to full-sampling mode...")
# 2. 拉 eBPF 上下文(这里简化为打印,实际是从 Agent 拉数据)
context = {
"pod": pod,
"p99_latency_ms": latency_ms,
"baseline_mean_ms": baseline_mean,
"timestamp": datetime.now().isoformat(),
# 实际还会拉:
# - 该 Pod 最近 5 分钟的 syscall top 10
# - 该 Pod 的 TCP 重传统计
# - 该 Pod 所在节点的 CPU/内存压力
# - 下游依赖服务的延迟
}
# 3. 调 LLM 分析(这里只打印 prompt,实际是调 OpenAI/Claude API)
prompt = f"""你是一个 AIOps 分析助手。检测到以下异常:
{context}
基于这些 eBPF 采集的数据,请给出:
1. 最可能的根因(不超过 3 个假设,按可能性排序)
2. 每个假设的验证方法
3. 推荐的处置操作
"""
print(" -> LLM Prompt:")
print(prompt)
# 实际调用:
# resp = requests.post("https://api.openai.com/v1/chat/completions", ...)
# 分析结果发到告警群
print("Starting AIOps anomaly detector...")
print(f"Sampling every 10s, baseline window: {BASELINE_WINDOW} samples")
while True:
time.sleep(10)
# 查询每个 pod 的 P99 延迟(过去 1 分钟)
query = 'histogram_quantile(0.99, sum(rate(tcp_connect_total[1m])) by (le, pod))'
results = query_prometheus(query)
for r in results:
pod = r["metric"].get("pod", "unknown")
try:
p99 = float(r["value"][1])
except (ValueError, IndexError):
continue
# 更新基线
if pod not in baselines:
baselines[pod] = deque(maxlen=BASELINE_WINDOW)
baselines[pod].append((time.time(), p99))
# 异常检测
baseline_values = [v for _, v in baselines[pod]]
baseline_mean = statistics.mean(baseline_values) if baseline_values else 0
if detect_anomaly(pod, p99, baselines[pod]):
trigger_aiops_analysis(pod, p99, baseline_mean)
# 触发后清空基线,避免连续告警
baselines[pod].clear()
跑起来之后的效果:某 Pod TCP P99 突然从 5ms 飙到 80ms,3σ 检测命中,触发 LLM 分析,LLM 拿到 eBPF 上下文给出根因假设。整个流程从异常发生到拿到根因假设,5 分钟内能搞定——传统方案下你光是登机器抓包都不止这个时间。
踩坑提示:
- BPF 程序里用
BPF_KPROBE宏需要 5.5+ 内核(依赖 BTF)。老内核用bpf_get_current_pid_tgid()+ 手动 PT_REGS 参数获取。 lookup_or_try_init在 BCC 0.18+ 才有,老版本用lookup+update。- Prometheus 指标用 Counter 类型(累加值),不要用 Gauge——Counter 配合
rate()函数能算每秒速率,Gauge 不行。 - 异常检测的 3σ 是最简单的方案,生产建议用 EWMA 或 isolation forest。3σ 对突发型异常灵敏,但对漂移型异常(缓慢上升)不敏感。
- LLM 调用的 prompt 要尽量结构化,不要堆砌原始数据。eBPF 数据量极大,全塞进去既贵又混乱。
- 异常触发后清空基线是避免"告警风暴"的简单做法,但代价是会丢失后续数据。生产建议改成"冷却 5 分钟内不重复告警"。
实战注意事项
最后一节讲讲实战踩坑,这些是我在生产环境真踩过的。
内核版本要求
eBPF 特性跟内核版本强绑定,下面这张表是我总结的最低内核版本要求:
| 特性 | 最低内核版本 | 说明 |
|---|---|---|
| 基础 kprobe/uprobe/tracepoint | 4.4+ | 但很多 helper 受限 |
bpf_get_current_cgroup_id | 4.18+ | 容器观测必备 |
| BTF(BPF Type Format) | 5.2+ | CO-RE 依赖 |
| bounded loop | 5.3+ | 之前完全不允许循环 |
bpf_probe_read_user_str | 5.5+ | 读用户态字符串 |
| ringbuf | 5.8+ | 替代 perf_event_array |
bpf_sk_assign(Cilium 用) | 5.7+ | socket redirect |
| LSM BPF | 5.7+ | 安全策略 |
我们生产用 Ubuntu 22.04(内核 5.15),绝大部分特性都能用。CentOS 7(内核 3.10)基本告别 eBPF,要么升级内核要么换系统。
查看当前内核能力:
# 看内核版本
uname -r
# 看 BTF 是否启用(CO-RE 必备)
ls -la /sys/kernel/btf/vmlinux
# 看支持哪些 program type
bpftool feature probe
# 看支持哪些 map type
bpftool feature probe | grep map_type
CAP_BPF 与权限
加载 BPF 程序需要权限。有几种方式:
# 方式一:root 用户(最简单,最不安全)
sudo bpftrace ...
# 方式二:CAP_BPF + CAP_PERFMON(5.8+ 推荐)
sudo setcap cap_bpf,cap_perfmon,cap_sys_admin+ep /usr/bin/bpftrace
# 方式三:user namespace + unshare(K8s 推荐)
# 在 Pod securityContext 里加 capabilities
K8s 里跑 BPF Agent 的 Pod securityContext:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: ebpf-agent
spec:
template:
spec:
hostNetwork: true # 要访问内核网络栈
hostPID: false # 不要 host PID,按需开
serviceAccount: ebpf-agent
containers:
- name: agent
image: ebpf-agent:latest
securityContext:
# 关键:必须特权或者具体 capabilities
privileged: true # 简单粗暴,但很多公司禁止
# 或者用具体 capabilities(更安全):
# capabilities:
# add:
# - BPF
# - PERFMON
# - SYS_ADMIN # 部分场景需要
# - NET_ADMIN # 网络观测需要
# - SYS_RESOURCE # RLIMIT_MEMLOCK
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
volumeMounts:
- name: cgroup
mountPath: /sys/fs/cgroup
readOnly: true
- name: tracing
mountPath: /sys/kernel/debug
readOnly: true
- name: bpf
mountPath: /sys/fs/bpf
volumes:
- name: cgroup
hostPath:
path: /sys/fs/cgroup
- name: tracing
hostPath:
path: /sys/kernel/debug
- name: bpf
hostPath:
path: /sys/fs/bpf
踩坑提示:privileged: true 是最省心的方式,但很多公司安全合规不允许。用具体 capabilities 时要注意:CAP_BPF 在 5.8+ 才有,老内核只能 CAP_SYS_ADMIN,等于还是特权。CAP_PERFMON 在 5.8+ 也才完整支持。另外 RLIMIT_MEMLOCK 必须调大,否则 BPF map 分配会失败,错误信息特别迷惑(Unable to load program 看起来像程序写错了)。
生产环境性能开销
eBPF 本身开销低,但不等于零开销。几个关键数据点(我自己测的,仅供参考):
| 场景 | CPU 开销 | 内存开销 | 备注 |
|---|---|---|---|
| bpftrace 单 kprobe 计数 | <0.1% | <10MB | 几乎无感 |
| BCC 全量 syscall trace | 3-5% | 50-100MB | 高频 syscall 是大头 |
| Cilium 全功能 | 1-3% | 100-200MB | 替代 kube-proxy |
| Hubble L7 全采样 | 5-8% | 200MB | L7 解析很重 |
| Hubble L7 50:1 采样 | 0.5-1% | 100MB | 推荐生产配置 |
生产环境必须做的优化:
- 采样:高频事件(syscall、网络包)必采样,1:50 到 1:100 都可以。
- 过滤:BPF 程序里早早过滤掉无关事件,别全量传用户态。
- per-cpu map:计数器用
PERCPU_HASH,避免跨 CPU 原子操作。 - ringbuf 替代 perf_event_array:5.8+ 优先 ringbuf,性能更好且内存可预测。
- 限制 map 大小:
max_entries不要无脑拉大,受RLIMIT_MEMLOCK限制。
调 RLIMIT_MEMLOCK:
# 临时调大
ulimit -l 8192 # 8MB,默认才 64KB
# 永久生效(/etc/security/limits.conf)
* soft memlock 8192
* hard memlock 8192
# 或者 systemd unit 里加
[Service]
LimitMEMLOCK=infinity
性能调优还有一个容易忽略的点:BPF 程序本身的复杂度。verifier 限制指令数上限是 100 万条,但你的程序越复杂,每次触发的开销也越大。能用 tracepoint 就别用 kprobe(tracepoint 参数解析更快),能用 raw_tracepoint 就别用 tracepoint(少一次参数拷贝)。这些细节单看每条省不了多少,但高频事件下累加起来差别很大。
CO-RE 与内核头依赖
传统 BPF 开发(BCC)有个大坑:编译时依赖目标机器的内核头。你在 Ubuntu 22.04 上写的 BPF 程序,跑到 CentOS 8 上可能就编不过——因为内核结构体布局不一样。
CO-RE(Compile Once - Run Everywhere)解决这个问题。原理是:
- 内核启用 BTF(
CONFIG_DEBUG_INFO_BTF=y),导出结构体布局信息。 - BPF 程序编译时记录"我访问的字段偏移"。
- 加载时 libbpf 根据当前内核的 BTF 信息,重定位字段偏移。
这样一份 .o 文件能跑在不同内核版本上,前提是目标内核启用了 BTF。
检查内核是否启用 BTF:
# 方法一:看文件
ls -la /sys/kernel/btf/vmlinux
# 存在即启用
# 方法二:看配置
zcat /proc/config.gz | grep CONFIG_DEBUG_INFO_BTF
# CONFIG_DEBUG_INFO_BTF=y
BCC vs libbpf+CO-RE 对比:
| 维度 | BCC | libbpf + CO-RE |
|---|---|---|
| 运行时依赖 | 需要内核头 + LLVM | 无(只要 BTF) |
| 部署体积 | 大(~500MB) | 小(~10MB) |
| 启动速度 | 慢(运行时编译) | 快(直接加载 .o) |
| 开发体验 | 简单(Python+C 混编) | 复杂(要写 skeleton) |
| 跨内核兼容 | 差 | 好 |
| 生产推荐 | 不推荐 | 推荐 |
我个人的建议:开发用 BCC 快速迭代,生产用 libbpf + CO-RE 交付。BCC 适合写脚本验证想法,但部署到生产的话每次都要带内核头太重了。libbpf 的开发体验确实不如 BCC,但一次编译到处跑的体验是 BCC 给不了的。
最后一个坑:CO-RE 不是万能的。如果你访问的字段在目标内核里根本不存在(比如新内核才有的字段),CO-RE 也救不了,运行时会拿到 0 或者直接 bpf_probe_read 失败。这种情况要写 bpf_core_field_exists() 做条件判断:
// 兼容不同内核版本的字段访问
struct task_struct *task = (void *)bpf_get_current_task();
// 5.x 才有字段,老内核没有
if (bpf_core_field_exists(task->mm->arg_start)) {
// 安全访问
} else {
// 走 fallback 逻辑
}
总结
eBPF 实战的核心就三件事:选对工具、写对程序、调好权限。bpftrace 适合排障脚本,BCC 适合快速开发,libbpf+CO-RE 适合生产交付。Cilium+Hubble 是 K8s 网络观测的事实标准,能不自己写就别自己写。
把 eBPF 数据接进 AIOps 是真的能提升根因定位效率——但前提是你得在 Agent 侧做好聚合和过滤,别想着把原始数据全喂给 LLM,那样既贵又乱。下一篇我会专门讲 AIOps 的数据预处理和 LLM 推理工程化,那里才是真正难的地方。
自测题与动手练习
自测题(合上书能答出来,才算懂):
tracepoint和kprobe的本质区别是什么?为什么本文的脚本反复优先用tracepoint?(提示:稳定 ABI、参数结构体、少一次拷贝)- BPF map 在内核态和用户态之间扮演什么角色?为什么读用户态字符串要用
bpf_probe_read_user_str,而不能直接解引用用户态指针? - Cilium Hubble 的 service map 能看见什么?
hubble observe --verdict DROPPED解决了什么传统抓包看不到的排障痛点? - 按容器聚合 syscall 时为什么要在用户态反查 cgroupfs?
bpf_get_current_cgroup_id()对内核版本有什么最低要求? - 把 eBPF 数据接进 AIOps 做异常检测,为什么必须"在 Agent 侧做好聚合和过滤"、不能把原始数据全喂给 LLM?
- CO-RE 解决了 BCC 的什么痛点?它依赖内核的哪个特性(文件/配置)?CO-RE 是万能的吗——什么情况下它也救不了?
动手练习(建议真做一遍):
- 跑
syscall_count.bt,观察 kubelet / etcd 等系统组件的 syscall 排行;试着把聚合 key 从comm换成pid,体会"长进程名被截断"的问题。 - 跑
trace_openat.py,在测试环境定位"谁在反复读某个文件",验证KeyError的正常性与 map 溢出的采样必要性。 - 在已装 Cilium 的集群部署 Hubble,用
hubble observe --verdict DROPPED抓一条被网络策略拒绝的流量,理解"哪个 Pod 因哪条策略被拒"。
本章小结
- 选对工具:bpftrace 写一次性排障脚本最快,BCC 适合快速开发 Python+C 程序,libbpf + CO-RE 才适合生产交付,Cilium+Hubble 是 K8s 网络观测的事实标准。
- 零侵入本质:eBPF 不碰应用一行代码,靠
tracepoint/kprobe扎进内核看现场;内核只认 cgroup id,要"人类可读"得在用户态反查 cgroupfs。 - 性能与权限是生产两道坎:高频事件必采样、BPF 程序早过滤、
per-cpumap、ringbuf;加载要CAP_BPF/CAP_PERFMON或特权,且RLIMIT_MEMLOCK必须调大。 - 接 AIOps 的前提是聚合过滤:原始 eBPF 数据量极大,Agent 侧先聚合、采样、结构化,再喂给 Prometheus + 3σ + LLM,否则既贵又乱。
- 下一篇会专门讲 AIOps 的数据预处理和 LLM 推理工程化——那是真正难的地方,也是本文埋下的伏笔。