security-collector-exporter v0.3.0:eBPF 加持的实时安全监控
从静态到实时
上一篇文章介绍了 security-collector-exporter v0.1.0——把 Linux 安全配置状态变成 Prometheus 指标。但 v0.1.0 本质上还是"快照式"的:定时读 /etc、/proc,抓的是某一时刻的静态配置。
安全运维里有一块是快照覆盖不了的:实时发生的安全事件。有人在跑反弹 shell、有进程在提权、有异常的网络连接、有人在加载内核模块——这些事发生了就过去了,你下次 scrape 的时候根本看不到。
eBPF 是解决这个问题的天然工具。它能挂载到内核的各种 hook 点上(tracepoint、kprobe),在内核态就完成数据聚合,零拷贝、低开销。从 v0.2.0 开始,security-collector-exporter 加入了完整的 eBPF 支持,经过 v0.3.0 的修 bug 和稳定性打磨,终于算是能用了。
eBPF 采集了什么
五个独立的 BPF 程序,分别挂载到不同的内核 hook 点,覆盖五类安全事件:
| BPF 程序 | 挂载点 | 监控内容 |
|---|---|---|
| process.c | sys_enter_execve, sched_process_exit | 进程执行、退出、活跃进程数 |
| network.c | inet_sock_set_state | TCP/UDP 连接建立、活跃连接、连接错误 |
| file.c | sys_enter_openat | 敏感文件访问操作(按严重级别分类) |
| privilege.c | sys_enter_setuid/setgid/capset | 提权行为(setuid/setgid/capset) |
| kernel.c | sys_enter_init_module/finit_module | 内核模块加载/卸载 |
暴露的 Prometheus 指标:
| |
其中进程分类挺有意思——不是简单地区分 system/user,而是四类:system(系统路径下的进程)、user(用户进程)、container(通过 cgroup ID 检测容器进程)、suspicious(可疑的解释器调用)。这样在 PromQL 里就能精准过滤:
| |
整体架构
架构分两大层:内核态负责采集和预聚合,用户态负责读取和暴露指标。
内核态的 5 个 BPF 程序挂载到 14 个 tracepoint 上,事件发生时直接在 percpu_array map 里递增计数器,零锁竞争:
flowchart LR
classDef tp fill:#E3F2FD,stroke:#1565C0,color:#1565C0
classDef bpf fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef map fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
subgraph tp["Tracepoints"]
t1@{ shape: rounded, label: "execve / exit" }
t2@{ shape: rounded, label: "tcp state change" }
t3@{ shape: rounded, label: "openat" }
t4@{ shape: rounded, label: "setuid / setgid / capset" }
t5@{ shape: rounded, label: "init_module" }
end
subgraph bpf["BPF 程序"]
p@{ shape: proc, label: "process.c\n进程追踪" }
n@{ shape: proc, label: "network.c\n网络连接" }
f@{ shape: proc, label: "file.c\n文件访问" }
v@{ shape: proc, label: "privilege.c\n提权检测" }
k@{ shape: proc, label: "kernel.c\n内核模块" }
end
subgraph maps["percpu_array Maps"]
m1@{ shape: cyl, label: "exec / exit / active" }
m2@{ shape: cyl, label: "connect / error" }
m3@{ shape: cyl, label: "access by severity" }
m4@{ shape: cyl, label: "escalation by type" }
m5@{ shape: cyl, label: "module by action" }
end
t1 --> p --> m1
t2 --> n --> m2
t3 --> f --> m3
t4 --> v --> m4
t5 --> k --> m5
class t1,t2,t3,t4,t5 tp
class p,n,f,v,k bpf
class m1,m2,m3,m4,m5 map用户态的 Manager 加载 BPF 程序并挂载 tracepoint,Aggregator 每 5 秒从 percpu_array 读取聚合数据(带 delta 追踪算 RPS),最终由 EbpfCollector 暴露为 Prometheus 指标:
flowchart LR
classDef datasource fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
classDef component fill:#EDE7F6,stroke:#4527A0,color:#311B92
classDef endpoint fill:#FCE4EC,stroke:#C62828,color:#B71C1C
maps@{ shape: cyl, label: "BPF percpu_array Maps" }
subgraph go["Go 用户态"]
mgr@{ shape: notch-rect, label: "Manager\n加载 & 挂载" }
agg@{ shape: proc, label: "Aggregator\n读取 + delta 追踪" }
smp@{ shape: notch-rect, label: "AdaptiveSampler\n自适应采样率" }
col@{ shape: proc, label: "EbpfCollector" }
end
prom@{ shape: stadium, label: "Prometheus :9102/metrics" }
maps -->|"每 5s 读取"| agg
mgr -.->|"生命周期"| maps
agg --> col
smp -.->|"采样率"| col
col -->|"/metrics"| prom
class maps datasource
class mgr,agg,smp,col component
class prom endpoint中间的自适应采样器(AdaptiveSampler)每 10 秒评估一次实际事件量,超过目标 RPS 的 2 倍就加倍采样率(采得更稀疏),低于一半就恢复(采得更密集),防止高负载下 eBPF 事件量压垮用户态。
v0.2.0 踩的坑
v0.2.0 是 eBPF 功能的首个版本,代码量不小(新增 ~4000 行),但发布后跑起来就发现了不少问题。
BPF 程序本身的 bug
网络 BPF 的结构体字段错误。inet_sock_set_state 这个 tracepoint 的内核结构体里没有 skbaddr 字段,但 v0.2.0 的 network.c 里引用了它。BPF verifier 直接拒绝加载,网络监控整个挂掉。
UDP kprobe 不存在。v0.2.0 尝试用 kprobe 来追踪 UDP 连接,但目标内核上根本没有对应的 kprobe tracer。加载失败但没有优雅处理。
进程退出分类不准。sched_process_exit 触发时,拿不到进程当初被 execve 分类时的类别。v0.2.0 没有做 PID 到类别的映射,导致 exit 事件的分类和 exec 不一致。
提权检测的 map 类型错误。privilege.c 用的是 PERCPU_ARRAY,但在多 CPU 并发写入同一个 key 时存在竞争条件。换成 PERCPU_HASH 才解决。
ARM 设备上的阻塞问题
这是一个比较隐蔽的 bug。在 ARM 设备(树莓派之类)上,端口版本探测(process_info.go 里的 Java 版本检测逻辑)会卡住 Collect() 周期。原因是某些进程(比如 mediamtx)的版本命令会 hang 住,没有超时机制,整个 Prometheus scrape 就超时了。
CI 构建问题
BPF 程序需要用 clang 编译 C 代码生成字节码(*_bpf.o),然后通过 go:embed 嵌入到 Go 二进制里。v0.2.0 把 .o 文件加到了 .gitignore,CI 环境(ubuntu-latest)没有 clang,构建直接失败。
v0.3.0 怎么修的
BPF 程序修复
| |
classify_by_comm() 是一个 fallback——对于 eBPF 加载之前就已经跑着的进程,拿不到 execve 事件,就通过进程名的前四个字节做粗粒度匹配(syst → systemd、sshd → sshd 等等)。BPF verifier 不允许循环,所以只能硬编码逐字节比较。
聚合器重写
v0.2.0 的 Aggregator 是用内存数组模拟 percpu 数据的(simulation 模式)。v0.3.0 彻底重写,直接读取真实的 BPF percpu_array maps,并加了 delta 追踪:
| |
ARM 阻塞修复
三个改动一起解决了这个问题:
executeVersionCommand加了 3 秒超时(context.WithTimeout)- 版本探测结果为空时缓存 5 分钟(避免反复探测已知不响应的进程)
getVersionFromBinary用io.LimitReader只读前 1MB(之前会读整个二进制文件,对大文件很慢)
CI 修复
把编译好的 *_bpf.o 文件提交到 git。这些是架构无关的 BPF ELF 字节码(不是 x86 或 ARM 机器码),所以不存在跨平台问题。CI 不再需要 clang 工具链。
两个有意思的设计
自适应采样
高负载场景下 eBPF 事件量可能非常大(几万到几十万 events/sec)。AdaptiveSampler 的思路是:设定一个目标 RPS(默认 5000),每 10 秒评估一次实际 RPS——超过目标的 2 倍就加倍采样率(采得更稀疏),低于目标的一半就减半采样率(采得更密集)。采样率范围限制在 1 ~ 10000 之间。
这个采样率会作为 security_ebpf_sample_rate 指标暴露出来,查询时可以据此还原真实值:
| |
Space-Saving Top-N
代码里还实现了一个 Space-Saving 算法,用于在高基数数据流中维护 Top-K 频繁项。固定数量的计数器(默认 100 个),新 key 来了如果还有空位就占一个,没空位就替换当前计数最小的那个并继承其计数。O(1) 更新,O(K log K) 查询 Top-N。这个组件为后续的"谁在频繁执行 / 谁在频繁连网"这类查询做了准备。
部署方式
启用 eBPF 需要额外参数:
| |
不传 --ebpf.enabled 的话就只跑 v0.1.0 的静态采集器,完全不影响。
内核要求 Linux 5.4+(需要 BTF 支持)。跑不了 eBPF 的环境会自动降级到 degraded 状态,security_ebpf_up{status="degraded"} 为 0,只跑传统采集器。
几个有用的 eBPF 告警规则:
| |
版本回顾
| |