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 生产级观测送出的礼物,而我们工程师要做的,只是正确地打开它。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部