BPF Local Storage 系列 Maps 生产级实战:告别 per-CPU Map 的抢占竞争陷阱
在 eBPF 生产级追踪与观测系统的搭建中,"per-task request latency histogram" 是最经典的需求之一。你可能首先想到在 BPF hash map 中用 `pid->req_id->timestamp` 做键值对,或者用 per-CPU 数组暂存中间状态以避免多核写入竞争。但 hash map 在有 10 万级 task 的场景下经常成为 lock contention 的瓶颈,而 per-CPU 数组在抢占(preemption)和 migration 场景下是错误的——当 task 从 CPU-1 被迁移到 CPU-2 时,两份 per-CPU 数据就对不上了。从 Linux 5.17 开始,内核引入了 BPF Local Storage 系列 Maps(`BPF_MAP_TYPE_CGRP_STORAGE`、`BPF_MAP_TYPE_TASK_STORAGE`、`BPF_MAP_TYPE_INODE_STORAGE`),彻底解决了这一问题。本文将从内核实现机制出发,给出到生产部署的完整路径。
一、内核为什么需要 Local Storage
1.1 per-CPU Map 在抢占场景下的语义陷阱
考虑一个典型场景:用 eBPF 跟踪用户态进程的 syscall 延迟。在进入 read() 时记录时间戳,在返回时计算差值。
{
// 写法一:per-CPU map — 看似无锁,实则暗藏竞争
__u64 ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start, &cpu, &ts, BPF_ANY);
}
但内核调度完全可以在 kprobe:sys_read 记录 ts 之后、eprobe 返回之前把该 task 从 CPU-A 迁移到 CPU-B。返回路径读取 *bpf_map_lookup_elem(&start, &cpu) 拿到的是 CPU-B 上的旧数据(可能是其他 task 写入的),最终计算出一个负数或者荒谬值。
你也许会尝试用 bpf_get_cpu_id() 先记下 CPU 编号,在 improbe 时专门用已记录的 CPU 去读取——但这要求 improbe 时原子地持有 CPU 编号的寄存器根本没有被 epilogue 破坏,且 bpf_get_cpu_id 不保证在不同 probe 调用之间感知 migration,这条路是走不通的。
1.2 Hash Map 的扩展性问题
{
// 写法二:hash map — 正确但慢
__u64 ts = bpf_ktime_get_ns();
__u32 pid = bpf_get_current_pid_tgid() >> 32;
bpf_map_update_elem(&start, &pid, &ts, BPF_ANY); // 全局 hash bucket 竞争
}
在 80 核 NUMA 机器上跑 5 万 IOPS 的负载,这张 hash map 的全局 bucket 锁争抢会直接抵消 eBPF "零侵入" 的优势,bpf_map_update_elem 的开销从几十纳秒飙升到微秒级。
1.3 Local Storage 的解法
Local Storage 将数据存储在 kernel 内部 struct task\_struct、struct cgroup 或 struct inode 的扩展区域(bpf\_local\_storage 字段),索引键是内核对象本身而非 CPU。内核在 task migration 时自动把 storage 跟随 task 迁移,在 mprotect rseq 等场景也能安全访问。对 eBPF 程序而言就是一段私有、无锁、跟随对象生命周期的内存。
二、三种 Local Storage 的语义对比
| Map 类型 | 绑定内核对象 | 生命周期 | 典型场景 |
|---|---|---|---|
| BPF\_MAP\_TYPE\_TASK\_STORAGE | struct task_struct |
task\_struct 释放时自动回收 | 每请求延迟统计、per-task 自定义标签 |
| BPF\_MAP\_TYPE\_CGRP\_STORAGE | struct cgroup |
cgroup 释放时回收 | 容器级网络字节计数、group identity |
| BPF\_MAP\_TYPE\_INODE\_STORAGE | struct inode |
inode 释放时回收 | 文件审计日志、per-file 操作统计 |
还有一个历史遗留的 BPF_MAP_TYPE_SK_STORAGE(Linux 5.2 引入)专门用于 socket,本文不单独展开。
// task_storage 定义(libbpf 骨架代码)
struct bpf_map_def SEC("maps") task_storage = {
.type = BPF_MAP_TYPE_TASK_STORAGE,
.key_size = sizeof(int), // 固定为 bpf_task_storage_get 第一个参数
.value_size = sizeof(struct task_metrics),
.max_entries = 0, // Local Storage 不需要指定键数量
};
注意 .max_entries = 0 或 1(老内核)与 hash map 的显式容量限制完全不同——Local Storage 的条目数量由内核对象的数量自动决定。
三、生产级实战:per-request latency histogram
3.1 eBPF 核心探针:task\_storage 版
下面给出一个完整的 per-task Syscall Latency 统计方案,替换掉上面提到的 hash map 版本:
// task_latency.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#define MAX_SYSCALLS 512
struct syscall_stat {
__u64 enter_ns;
__u64 bytes; // for read/write
__u32 sys_nr;
};
// 关键:使用 task_storage 替代 percpu/hash
struct {
__uint(type, BPF_MAP_TYPE_TASK_STORAGE);
__uint(map_flags, BPF_F_NO_PREALLOC); // task storage 必填
__type(key, int);
__type(value, struct syscall_stat);
} task_storage SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32);
__type(value, struct syscall_latency_bucket);
} latency_histogram SEC(".maps");
SEC("tp/raw_syscalls/sys_enter")
int trace_enter(struct trace_event_raw_sys_enter *ctx)
{
struct syscall_stat *stat;
__u32 pid = bpf_get_current_pid_tgid() >> 32;
// 过滤目标容器(通过可选的 cgroup 过滤)
if (target_pid && pid != target_pid)
return 0;
// 核心:获取当前 task 的本地存储,不存在则自动分配并初始化为零
stat = bpf_task_storage_get(&task_storage, bpf_get_current_task_btf(),
NULL, BPF_LOCAL_STORAGE_GET_F_CREATE);
if (!stat)
return 0; // 超出最大 task 数或内存不足 — 生产里应告警
stat->enter_ns = bpf_ktime_get_ns();
stat->sys_nr = ctx->id;
return 0;
}
SEC("tp/raw_syscalls/sys_exit")
int trace_exit(struct trace_event_raw_sys_exit *ctx)
{
struct syscall_stat *stat;
__u64 delta;
stat = bpf_task_storage_get(&task_storage, bpf_get_current_task_btf(),
NULL, 0); // 只读,不创建
if (!stat)
return 0;
delta = bpf_ktime_get_ns() - stat->enter_ns;
if (delta > MAX_LATENCY_NS)
return 0; // 异常值过滤
// 这里是向 BPF_MAP_TYPE_HASH 写入聚合后的 bucket 统计
// 竞争极小,因为 only 在 syscall exit 上才做一次 update
__u32 bucket = log2l(delta);
struct syscall_latency_bucket *lb;
lb = bpf_map_lookup_elem(&latency_histogram, &bucket);
if (lb) {
__sync_fetch_and_add(&lb->count, 1);
__sync_fetch_and_add(&lb->total_ns, delta);
}
return 0;
}
3.2 关键 API 行为详解
| API | 说明 |
|---|---|
bpf_task_storage_get(map, task, flags, BPF_LOCAL_STORAGE_GET_F_CREATE) |
成功时返回指向 storage value 的指针;如果 F_CREATE 被设置则自动分配。返回的指针是 可以直接修改 的。 |
bpf_task_storage_delete(map, task) |
释放 storage entry,在 task\_exit 时自动调用。 |
bpf_get_current_task_btf() |
获取当前 task 的 struct task_struct *,必须有效(在中断上下文中返回当前被中断的 task)。 |
一个常见的生产级陷阱:不要在 task\_storage map 中使用 F\_NO\_PREALLOC 未定义的行为——从 5.17 开始 BPF_F_NO_PREALLOC 是强制的,但老版本 libbpf 的骨架生成需要手动指定。不设置会导致 bpf() 调用返回 EINVAL。
3.3 用户态采集器骨架 (Rust + libbpf-rs)
// collector.rs — 仅展示核心循环,省略信号处理和 graceful shutdown
use libbpf_rs::{Map, MapFlags};
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let skel = TaskLatencySkelBuilder::default().open()?;
let mut skel = skel.load()?;
skel.attach()?;
let histogram = skel.maps().latency_histogram();
loop {
let mut buckets: Vec<SyscallBucket> = Vec::new();
let mut key: u32 = 0;
while let Ok(Some(raw)) = histogram.lookup(&key.to_ne_bytes(), MapFlags::ANY) {
let lb: &SyscallLatencyBucket = plain::from_bytes(&raw)?;
buckets.push(SyscallBucket {
bucket_us: 1u64 << key, // 将 bucket 索引换算为微秒边界
count: lb.count,
avg_ns: if lb.count > 0 { lb.total_ns / lb.count } else { 0 },
});
key += 1;
}
// 此处推送至 Prometheus 远端写入器或打印结构化日志
log_histogram(&buckets);
std::thread::sleep(Duration::from_secs(5));
}
}
四、Container 级可观测:cgp\_storage 案例
BPF\_MAP\_TYPE\_CGRP\_STORAGE 与 task\_storage API 几乎平行:
static __always_inline struct cgroup_metrics *
get_cgroup_metrics(struct cgroup *cgrp)
{
return bpf_cgrp_storage_get(&cgrp_storage, cgrp,
NULL, BPF_LOCAL_STORAGE_GET_F_CREATE);
}
SEC("cgroup_skb/egress")
int count_egress_bytes(struct __sk_buff *skb)
{
struct cgroup *cgrp = bpf_cgroup_from_legacy_cgroup(NULL);
struct cgroup_metrics *m;
if (!cgrp)
return 1;
m = bpf_cgrp_storage_get(&cgrp_storage, cgrp, NULL,
BPF_LOCAL_STORAGE_GET_F_CREATE);
if (m)
__sync_fetch_and_add(&m->tx_bytes, skb->len);
bpf_cgroup_release(cgrp);
return 1;
}
这一方法的性能优势明显:每张网络 socket 上百万包的处理无需考虑 GC 和 RCU read lock,直接通过 cgroup → storage 指针拿计数器原子累加,latency 小于 50ns。
与 cgroup v2 的 bpf_cgroup_storage 老机制的关系:早期 cgroup-bpf 使用 static 声明的 BPF_MAP_TYPE_CGRP_STORAGE(早期叫 BPF_MAP_TYPE_CGROUP_STORAGE,wait choose 类型由 map 预定义 key 共享),新版 BPF_MAP_TYPE_CGRP_STORAGE(注意没有 B)采用更灵活的 local storage 语义——每 cgroup 独立一份 value,不同程序互不干扰。
五、生产调优:你需要关注的四个坑
5.1 Local Storage 的内存开销
每个 task\_struct 上的 local storage entry 是一块按 value\_size 对齐的内存。如果你的 value\_size 是 256 字节,在 10 万个活跃 task 的承载式服务器上,task\_storage 就会吞掉 25MB + 管理结构开销。这比全局 hash map 高出一倍——但换来的是零竞争和正确性。
建议开启 bpf_LSM 监控 oversized allocation,并使用 bpf_task_storage_get 失败的返回 code 作为 storage exhaustion 的告警信号。
5.2 Migration 与跨 NUMA 延迟
task\_storage 在 set_cpus_allowed_ptr 过程中会触发 migrate—storage 本身只是指针搬运,但如果调用方立即在目标 CPU 上 free() 掉旧 task\_struct,而正在执行的 eBPF 程序恰好还持有其 storage 指针,就会访问已释放内存。通过 spin\_lock\_irqsave 保护 rcu\_read\_lock grain 才能避免——libbpf 的 bpf\_task\_get \_storage 已经做了这段防护,请勿自行用 rcu\_read\_lock 替代。
5.3 与 cgroupns 的交互
如果 BPF 程序在不同 network namespace 中 attach,bpf\_cgrp\_storage\_get 返回的 cgrp 是你 attach 路径上的 cgroup(attach\_cgroup),而非 current task 的 cgroup。生产部署时明确读取 bpf_cgroup_ancestor() 确认你要统计的正确 cgroup 层,避免把子 cgroup 数据汇总到父 cgroup。
5.4 在 ARM64 + KASAN 环境下的对齐陷阱
task\_storage 在 ARM64 KASAN 模式下会使用 kmalloc_trace(),而非直接的 storage cache,导致每次 bpf_task_storage_get 的开销从 ~15ns 增长到 ~500ns。如果发现开启 KASAN 后 probe latency 大幅下降,请排查 mgslab debug 和 kasan\_shadow 是否被误打开。
六、总结
BPF Local Storage 三个变种(task / cgrp / inode)是目前为止 eBPF 生态中最接近 "per-object native 内存" 特性的抽象,它用正确的语义填补了介于 hash map 与 per-CPU map 之间的工程空白。在生产级观测系统中,凡是看到 NR_CPUS 个 map entry 并被 task\_migration 问题困扰的探测点,都是 Local Storage 当即生效的战场。
| 场景 | 推荐 API | 内核版本要求 |
|---|---|---|
| per-task 请求延迟 | bpf_task_storage_get |
≥ 5.17 |
| per-cgroup 流量统计 | bpf_cgrp_storage_get |
≥ 5.17 |
| per-inode 文件审计 | bpf_inode_storage_get |
≥ 5.17 |
| socket-level 观测 | bpf_sk_storage_get |
≥ 5.2 |
如果你的观测任务只需要 per-task 或 per-cgroup 级别的私有状态存储,忘掉 hash map 和 per-CPU map——直接使用 Local Storage 吧。这是 Linux 内核为 eBPF 生产级观测送出的礼物,而我们工程师要做的,只是正确地打开它。

发表评论 取消回复