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 TD
T@{ shape: rounded, label: "Tracepoints<br/>14 个 hook 点" }
B@{ shape: proc, label: "BPF 程序<br/>process / network<br/>file / privilege / kernel" }
M@{ shape: cyl, label: "percpu_array<br/>Maps" }
T -->|事件触发| B
B -->|零锁计数| M
classDef tp fill:#bbdefb,stroke:#2196F3,color:#1B5E20
classDef bpf fill:#fff3e0,stroke:#FF9800,color:#BF360C
classDef map fill:#c8e6c9,stroke:#4CAF50,color:#1B5E20
class T tp
class B bpf
class M map用户态的 Manager 加载 BPF 程序并挂载 tracepoint,Aggregator 每 5 秒从 percpu_array 读取聚合数据(带 delta 追踪算 RPS),最终由 EbpfCollector 暴露为 Prometheus 指标:
flowchart TD
M@{ shape: cyl, label: "BPF<br/>percpu_array" }
A@{ shape: proc, label: "Aggregator<br/>读取 + delta" }
S@{ shape: notch-rect, label: "AdaptiveSampler<br/>自适应采样" }
C@{ shape: proc, label: "EbpfCollector" }
P@{ shape: stadium, label: "Prometheus<br/>:9102/metrics" }
M -->|每 5s 读取| A
S -.->|采样率| C
A --> C
C -->|/metrics| P
classDef src fill:#bbdefb,stroke:#2196F3,color:#1B5E20
classDef proc fill:#fff3e0,stroke:#FF9800,color:#BF360C
classDef spec fill:#f3e5f5,stroke:#9C27B0,color:#4A148C
classDef out fill:#c8e6c9,stroke:#4CAF50,color:#1B5E20
class M src
class A,C proc
class S spec
class P out中间的自适应采样器(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 告警规则:
| |
版本回顾
| |