eBPF 程序运行在内核中,它的内存使用直接影响系统稳定性——Map 太大可能耗尽内核内存,栈溢出会导致验证器拒绝,容器中的 eBPF 内存可能被 cgroup 误计费。理解 eBPF 的内存模型,是生产部署的必修课。
一、eBPF 内存模型
1.1 eBPF 的内存组成
1.2 内存限制
| 资源 | 限制 | 说明 |
|---|---|---|
| 栈空间 | 512 字节 | 每次函数调用 |
| 指令数 | 100 万 | 单个程序 |
| Map 条目 | max_entries | 创建时指定 |
| Map 内存 | 受 RLIMIT_MEMLOCK 限制 | 5.11+ 改为 cgroup |
| 尾调用深度 | 8 层 | 防止无限递归 |
二、Map 内存开销
2.1 内存计算公式
Hash Map 内存 ≈ max_entries × (key_size + value_size + sizeof(struct htab_elem))Array Map 内存 ≈ max_entries × value_sizePer-CPU Map 内存 ≈ CPU 数 × max_entries × value_sizeRing Buffer 内存 = max_entries(由用户指定)2.2 实际内存开销
| Map 配置 | 内存开销 |
|---|---|
| HASH, key=4B, value=64B, max=10000 | ~1 MB |
| HASH, key=4B, value=64B, max=100000 | ~10 MB |
| LRU_HASH, key=4B, value=64B, max=100000 | ~15 MB |
| PERCPU_HASH, key=4B, value=64B, max=10000, 64 CPUs | ~64 MB |
| ARRAY, value=64B, max=10000 | ~640 KB |
| RINGBUF, max=256KB | 256 KB |
2.3 内存优化策略
// 优化 1:使用 BPF_F_NO_PREALLOC 避免预分配struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1000000); __uint(map_flags, BPF_F_NO_PREALLOC); // 按需分配 __type(key, u32); __type(value, struct event);} large_map SEC(".maps");
// 优化 2:使用 LRU 替代无限增长struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __uint(max_entries, 100000); // 自动淘汰 __type(key, struct conn_key); __type(value, struct conn_value);} conn_track SEC(".maps");
// 优化 3:减小 value 大小// 浪费空间struct big_value { char filename[256]; // 大部分时候用不到 256 字节 u64 timestamp;};
// 紧凑存储struct compact_value { char filename[64]; // 按需截断 u64 timestamp;};BPF_F_NO_PREALLOC 虽然节省内存,但在内存紧张时 bpf_map_update_elem() 可能失败(返回 -ENOMEM)。对于关键路径(如 XDP),建议使用预分配模式。
2.4 栈空间与尾调用优化
eBPF 程序的栈空间只有 512 字节,这是验证器强制的安全限制。在复杂程序中,512 字节很容易不够用——几个局部数组或嵌套结构体就会耗尽栈空间,导致验证器拒绝加载。
常见的栈溢出模式:
// 栈溢出:两个数组就超过 512 字节SEC("kprobe/do_sys_open")int trace_open(struct pt_regs *ctx){ char filename[256]; // 256 字节 char comm[256]; // 256 字节 → 合计 512 字节,加上其他局部变量必溢出 // 验证器报错:combined stack size of 4 calls is 528. Too large}尾调用(tail call)是解决栈溢出的核心手段:通过 bpf_tail_call() 将当前栈帧替换为新程序的栈帧,实现”零栈增长”的程序串联:
// 使用尾调用拆分逻辑,每个子程序独立使用 512 字节栈struct { __uint(type, BPF_MAP_TYPE_PROG_ARRAY); __uint(max_entries, 4); __type(key, u32); __type(value, u32);} prog_array SEC(".maps");
// 阶段 1:解析数据包头SEC("xdp")int xdp_parse(struct xdp_md *ctx){ // 只做解析,栈占用少 struct hdr_info info = parse_header(ctx);
// 尾调用到阶段 2,当前栈帧被替换 bpf_tail_call(ctx, &prog_array, 1); return XDP_PASS;}
// 阶段 2:执行过滤逻辑(独立 512 字节栈)SEC("xdp")int xdp_filter(struct xdp_md *ctx){ // 可以使用新的 512 字节栈空间 char payload[256]; // ... return XDP_DROP;}当尾调用也不够时,Per-CPU Array Map 可以充当”临时暂存区”——将大型中间数据存入 Per-CPU Map,避免占用栈空间:
// 用 Per-CPU Array 作为 scratch 空间struct scratch_space { char buf[256]; u32 len; u32 flags;};
struct { __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); __uint(max_entries, 1); __type(key, u32); __type(value, struct scratch_space);} scratch SEC(".maps");
SEC("kprobe/do_sys_open")int trace_open(struct pt_regs *ctx){ u32 key = 0; struct scratch_space *sp = bpf_map_lookup_elem(&scratch, &key); if (!sp) return 0;
// 使用 sp->buf 代替栈上的局部数组 bpf_probe_read_user_str(sp->buf, sizeof(sp->buf), (void *)PT_REGS_PARM2(ctx)); sp->len = __builtin_strlen(sp->buf); // ... return 0;}| 栈溢出解决方案 | 原理 | 限制 |
|---|---|---|
| 尾调用 | 替换栈帧,每个子程序独立 512B | 最多 8 层嵌套 |
| Per-CPU Array | Map 充当 scratch 空间 | value 大小 ≤ 4KB(页面大小) |
| 减小局部变量 | 压缩结构体、缩短数组 | 可能影响可读性 |
| 拆分为多个程序 | 逻辑拆分到独立程序 | 程序间无法共享栈变量 |
三、容器内存与 eBPF
3.1 eBPF 内存的 cgroup 计费
在容器环境中,eBPF 内存的计费方式经历了重要变化:
| 内核版本 | 计费方式 | 影响 |
|---|---|---|
| < 5.11 | RLIMIT_MEMLOCK | 全局限额,不与 cgroup 关联 |
| 5.11+ | cgroup 内存控制器 | eBPF 内存计入 cgroup |
| 5.15+ | 独立 memcg 计费 | eBPF 内存独立于容器限额 |
3.2 容器中运行 eBPF 的内存问题
3.3 解决方案
- 使用 CAP_BPF 而非 root:5.8+ 内核支持 CAP_BPF,减少权限需求
- 在 init 容器中加载 eBPF:避免 eBPF 内存计入应用容器
- 使用独立 DaemonSet:eBPF 程序在独立 Pod 中运行
- 调整 memory.limit:为 eBPF 内存预留足够空间
# Cilium DaemonSet:独立 Pod 运行 eBPFapiVersion: apps/v1kind: DaemonSetmetadata: name: ciliumspec: template: spec: containers: - name: cilium-agent resources: requests: memory: "512Mi" limits: memory: "1Gi" # 预留 eBPF 内存四、eBPF 内存追踪
4.1 追踪内核内存分配
#include "vmlinux.h"#include <bpf/bpf_helpers.h>
struct alloc_event { u32 pid; u64 size; u64 addr; char comm[16];};
struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024);} events SEC(".maps");
// 追踪 kmallocSEC("kprobe/kmalloc")int trace_kmalloc(struct pt_regs *ctx){ struct alloc_event *e;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0); if (!e) return 0;
e->pid = bpf_get_current_pid_tgid() >> 32; e->size = PT_REGS_PARM1(ctx); // size 参数 bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0); return 0;}
char LICENSE[] SEC("license") = "GPL";4.2 追踪容器内存使用
# 使用 bpftrace 追踪容器内存分配sudo bpftrace -e 'kprobe:kmalloc /pid == $TARGET_PID/ { @alloc_size[comm] = hist(arg1);}'
# 追踪页面分配sudo bpftrace -e 'kprobe:alloc_pages { @alloc[comm] = count();}'
# 追踪 cgroup 内存事件sudo bpftrace -e 'kprobe:mem_cgroup_charge { printf("cgroup charge: %d bytes\n", arg1);}'五、eBPF-mm 子系统
5.1 eBPF-mm 的功能
eBPF-mm 是 Linux 内核中 eBPF 与内存管理子系统的集成,允许 eBPF 程序参与内存管理决策:
| 功能 | 内核版本 | 说明 |
|---|---|---|
| bpf_kptr | 5.16 | Map 中存储内核指针 |
| bpf_arena | 6.x | eBPF 程序的共享内存区域 |
| bpf_timer | 5.15 | Map 中的定时器 |
| bpf_mem_cache | 6.x | eBPF 专用内存缓存 |
5.2 bpf_kptr:Map 中的内核指针
// 在 Map value 中存储内核指针struct value_with_kptr { int data; struct task_struct __kptr *task; // 内核指针};
struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, u32); __type(value, struct value_with_kptr);} kptr_map SEC(".maps");5.3 bpf_arena:共享内存区域
bpf_arena 是 Linux 6.9+ 引入的共享内存机制,允许多个 eBPF 程序共享一块可读写的内存区域。与 Map 不同,arena 提供的是指针级访问——eBPF 程序可以直接通过指针读写 arena 中的数据,无需 bpf_map_lookup_elem() 的间接调用。
// 定义 arena(内核 6.9+)struct { __uint(type, BPF_MAP_TYPE_ARENA); __uint(map_flags, BPF_F_MMAPABLE); __uint(max_entries, 4096); // 4096 页 = 16 MB __uint(value_size, 4096); // 页大小} arena SEC(".maps");
// 在 eBPF 程序中通过指针访问 arenaSEC("kprobe/do_sys_open")int trace_open(struct pt_regs *ctx){ // arena 中的数据可跨程序共享 int *counter = (int *)arena + 0; // arena 基址偏移 __sync_fetch_and_add(counter, 1); return 0;}| 特性 | bpf_arena | 普通 Map |
|---|---|---|
| 访问方式 | 指针直接读写 | bpf_map_lookup_elem() |
| 共享范围 | 同一 arena 的所有程序 | 同一 Map 的所有程序 |
| 数据结构 | 自定义(链表/树等) | 固定键值对 |
| 内存开销 | 按页分配 | 按 entry 预分配 |
| 内核版本 | 6.9+ | 4.4+ |
bpf_arena 目前仍处于早期阶段,适用于需要在多个 eBPF 程序间共享复杂数据结构(如链表、红黑树)的场景。对于简单的键值存储,普通 Map 仍然是更好的选择。
5.4 bpf_timer 与 bpf_mem_cache
bpf_timer 允许 eBPF 程序设置定时器回调,在指定时间后执行内核态逻辑——无需用户态轮询。定时器回调中的内存分配由内核自动管理:
struct timer_value { struct bpf_timer timer; u32 callback_count;};
struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 100); __type(key, u32); __type(value, struct timer_value);} timer_map SEC(".maps);
// 定时器回调:定期清理过期条目static int timer_callback(void *map, u32 *key, struct timer_value *val){ val->callback_count++; // 执行清理逻辑...
// 重新设置定时器(周期执行) bpf_timer_start(&val->timer, timer_callback, 1000000000ULL); // 1 秒 return 0;}
SEC("tc")int setup_timer(struct __sk_buff *ctx){ u32 key = 1; struct timer_value *val = bpf_map_lookup_elem(&timer_map, &key); if (val && val->callback_count == 0) { bpf_timer_init(&val->timer, &timer_map, CLOCK_MONOTONIC); bpf_timer_start(&val->timer, timer_callback, 1000000000ULL); } return TC_ACT_OK;}bpf_mem_cache 为 eBPF 程序提供专用的内存缓存分配器,避免在热路径上触发内核通用分配器(slab allocator)的锁竞争:
// bpf_mem_cache 使用示例(内核 6.x+)// 在定时器回调或尾调用中分配临时内存struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, u32); __type(value, struct bpf_mem_cache_ptr);} mem_cache SEC(".maps");| 机制 | 用途 | 内存来源 | 内核版本 |
|---|---|---|---|
| bpf_timer | 延迟/周期执行 | Map value 内嵌 | 5.15+ |
| bpf_mem_cache | 热路径内存分配 | eBPF 专用 slab | 6.x+ |
| bpf_kptr | Map 中存内核对象 | 引用计数管理 | 5.16+ |
| bpf_arena | 共享内存区域 | 按页映射 | 6.9+ |
六、eBPF 内存生命周期
6.1 分配与释放时机
eBPF 内存的分配和释放遵循明确的生命周期规则,理解这些规则才能避免内存泄漏和悬垂引用:
关键规则:
- Map 生命周期:Map 的 fd 关闭后,如果没有被 pin 且没有程序引用它,内核自动释放
- 程序生命周期:程序 detach 后,如果没有被 pin 且没有 Map 引用,内核自动释放
- Pinning:通过
bpf_obj_pin()将 Map/程序钉在/sys/fs/bpf/下,即使创建进程退出也不会释放 - 引用计数:bpf_kptr、bpf_timer 等机制通过引用计数管理对象生命周期
6.2 引用计数与内存回收
| 对象 | 引用计数触发 | 释放条件 |
|---|---|---|
| Map | fd 引用 + pin | fd 关闭 + 未 pin + 无程序引用 |
| 程序 | fd 引用 + pin + attach | fd 关闭 + 未 pin + 未 attach |
| bpf_kptr | bpf_kptr_xchg() | 引用计数归零 |
| bpf_timer | Map value 内嵌 | 随 Map 释放 |
| Ring Buffer 事件 | bpf_ringbuf_reserve() | bpf_ringbuf_submit() 或 bpf_ringbuf_discard() |
七、RLIMIT_MEMLOCK 到 cgroup 迁移
7.1 传统 RLIMIT_MEMLOCK 的局限
5.11 之前的内核使用 RLIMIT_MEMLOCK 控制 eBPF 内存限额——这是一个进程级的全局限额,所有 eBPF Map 共享同一个配额:
# 查看当前 RLIMIT_MEMLOCKulimit -l# 64 ← 单位 KB,即 64KB(默认值太小)
# 临时调大(开发环境常用)ulimit -l unlimited
# 永久设置(/etc/security/limits.conf)# * hard memlock unlimited# * soft memlock unlimitedRLIMIT_MEMLOCK 的问题:
| 问题 | 说明 |
|---|---|
| 全局限额 | 所有 eBPF Map 共享,一个程序可能耗尽配额 |
| 不与 cgroup 关联 | 容器内存限额无法控制 eBPF 内存 |
| 难以调试 | 配额耗尽时错误信息不明确 |
| 不支持动态调整 | 需要重启进程才能修改 |
7.2 cgroup 内存计费(5.11+)
5.11 开始,eBPF 内存计入创建进程所属的 cgroup,实现了与容器限额的统一管理:
// libbpf 自动处理 RLIMIT_MEMLOCK → cgroup 的迁移// 用户态代码无需修改,libbpf 1.0+ 会自动尝试 cgroup 计费
// 手动控制(高级场景)struct bpf_object_open_opts opts = { .sz = sizeof(opts), // libbpf 1.0+ 默认使用 cgroup 计费 // 如需回退到 RLIMIT_MEMLOCK,设置: // .btf_custom_path = NULL,};# 检查内核是否支持 cgroup eBPF 计费cat /proc/config.gz | gunzip | grep CONFIG_MEMCG_KMEM# CONFIG_MEMCG_KMEM=y ← 需要开启
# 查看 cgroup 中的 eBPF 内存cat /sys/fs/cgroup/memory.stat | grep -i bpf升级到 5.11+ 内核后,如果 eBPF 程序加载失败,检查 cgroup 内存限额是否足够——之前 RLIMIT_MEMLOCK 设为 unlimited 时可能掩盖了真实的内存需求。
八、内存优化最佳实践
8.1 优化清单
| 优化 | 效果 | 适用场景 |
|---|---|---|
| 使用 LRU Hash | 限制 Map 大小 | 连接跟踪 |
| 使用 Per-CPU Map | 避免锁开销 | 高频计数器 |
| 使用 BPF_F_NO_PREALLOC | 按需分配 | 大 Map 低命中率 |
| 减小 value 大小 | 减少内存占用 | 所有场景 |
| 使用 Ring Buffer | 替代 Perf Buffer | 事件传输 |
| 合理设置 max_entries | 避免过度预分配 | 所有场景 |
8.2 监控 eBPF 内存使用
# 查看所有 Map 的内存使用sudo bpftool map show
# 查看特定 Map 的统计sudo bpftool map show id 123
# 查看系统 eBPF 内存总量cat /proc/meminfo | grep -i bpf
# 使用 bpftrace 追踪 Map 操作sudo bpftrace -e 'kprobe:bpf_map_update_elem { @map_ops[comm] = count();}'
# 追踪内存分配延迟sudo bpftrace -e 'kprobe:kmalloc /pid == $TARGET/ { @ts[tid] = nsecs;}kretprobe:kmalloc /@ts[tid]/ { @latency = hist(nsecs - @ts[tid]); delete(@ts[tid]);}'
# 追踪 slab 分配器行为sudo bpftrace -e 'kprobe:kmem_cache_alloc { @slab[comm, arg0] = count();}'九、生产案例:容器 OOM 排查
9.1 问题场景
某生产环境 K8s 集群中,运行可观测性 Agent 的 Pod 频繁被 OOM Kill。Pod 的内存限额为 512Mi,应用自身内存使用约 300Mi,但 cgroup 统计显示内存使用量持续增长至限额。
9.2 排查过程
# 第 1 步:查看 OOM 事件dmesg | grep -i oom# Out of memory: Killed process 12345 (agent) total-vm:800MB, anon-rss:500MB
# 第 2 步:检查 eBPF Map 内存sudo bpftool map show# 发现:一个 HASH Map 占用 200MB(max_entries=1000000, value_size=200)
# 第 3 步:确认 eBPF 内存计入容器 cgroupcat /sys/fs/cgroup/memory/kubepods/burstable/pod<id>/memory.usage_in_bytes# 536870912 ← 512MB,已到限额
# 第 4 步:查看 Map 实际使用率sudo bpftool map dump id 123 | wc -l# 实际只有 50000 条目,但预分配了 1000000 的空间9.3 根因与修复
根因:Agent 在容器内创建了一个 max_entries=1000000 的 Hash Map,预分配约 200MB 内存。由于 5.11+ 内核将 eBPF 内存计入 cgroup,这 200MB 被算入容器的 512MB 限额。
// 修复前:过度预分配struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1000000); // 预分配 ~200MB __type(key, struct conn_key); __type(value, struct conn_value);} conn_track SEC(".maps");
// 修复后:使用 LRU + 合理容量struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __uint(max_entries, 100000); // 预分配 ~20MB,自动淘汰 __type(key, struct conn_key); __type(value, struct conn_value);} conn_track SEC(".maps");在容器中运行 eBPF 程序时,务必将 eBPF Map 的预分配内存纳入容器内存限额的计算中。一个 max_entries=1000000 的 Hash Map 可能占用数百 MB,轻松耗尽容器的内存限额。
十、动手实践
10.1 计算 Map 内存开销
# 创建不同大小的 Map 并观察内存sudo bpftool map create /sys/fs/bpf/test_hash \ type hash key 4 value 64 entries 100000
# 查看 Map 内存sudo bpftool map show pinned /sys/fs/bpf/test_hash# 输出包含:bytes_used
# 清理sudo rm /sys/fs/bpf/test_hash10.2 追踪内存分配
# 使用 bpftrace 追踪 kmalloc 大小分布sudo bpftrace -e 'kprobe:kmalloc { @size[comm] = hist(arg0);}interval:s:10 { print(@size); clear(@size);}'10.3 监控容器中的 eBPF 内存
# 查看 cgroup 内存使用cat /sys/fs/cgroup/memory/docker/<container_id>/memory.usage_in_bytes
# 查看 eBPF 程序内存sudo bpftool prog show
# 查看 Map 内存sudo bpftool map show十一、本章小结
上一章理解了eBPF 在 Kubernetes 中的应用。
本章详解了 eBPF 的内存管理:
| 主题 | 关键要点 |
|---|---|
| 内存组成 | 程序代码 + Map 数据 + 512B 栈 + 上下文 |
| Map 内存 | Hash/Array/Per-CPU/Ring Buffer 的计算公式与优化策略 |
| 栈空间 | 512B 限制,尾调用和 Per-CPU Array 解决溢出 |
| 容器内存 | cgroup 计费(5.11+)、独立 DaemonSet、init 容器加载 |
| 内存追踪 | bpftrace 追踪 kmalloc/slab/延迟分布 |
| eBPF-mm | bpf_kptr(内核指针)、bpf_arena(共享内存)、bpf_timer(定时器)、bpf_mem_cache(专用缓存) |
| 内存生命周期 | 分配→活跃→pin→释放,引用计数管理 |
| RLIMIT→cgroup | 5.11+ 迁移到 cgroup 计费,libbpf 自动处理 |
| 生产案例 | 容器 OOM 因 eBPF Map 预分配,改用 LRU Hash 修复 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






