BPF OOM 内核补丁深度解析:用 eBPF 自定义 OOM 策略

前几篇文章展示了如何使用 eBPF 追踪 OOM 事件。但这还不够——我们只是在"看",不能"管"。内核的 OOM Killer 选谁杀谁,由 oom_badness() 算法决定,用户无法干预。

2025 年,Google 工程师 Roman Gushchin 提出了一套 BPF OOM 内核补丁,目标是让 eBPF 程序可以完全接管 OOM 处理策略。这是 Linux 内存管理子系统近二十年来在 OOM 领域最大的变化。

时效性说明:截至 2026 年 6 月,BPF OOM 补丁仍处于 RFC/审查阶段,尚未合入任何 Linux 内核主线版本。下面介绍的内容基于补丁集的技术方案——部分接口和设计可能在最终合入时发生变化。

传统 OOM Killer 的问题

Linux 的 OOM Killer 自 2001 年左右引入内核,核心逻辑基本没有大改过:

  1. 内存耗尽 → 触发 __alloc_pages_slowpath 失败
  2. 调用 out_of_memory() → 遍历所有进程
  3. oom_badness() 计算每个进程的"坏值"(基于 rss、swap、oom_score_adj
  4. 选"最坏"的进程杀死

问题在于这个"最坏"的判断是通用的,无法针对不同 workload 做优化:

  • 数据库节点:希望优先保护数据库进程,杀掉占用缓冲池的查询进程
  • AI 训练集群:希望优雅 checkpoint 而不是直接 kill -9
  • Kubernetes 节点:希望感知 cgroup 层级,优先杀超限的容器而不是 kubelet
  • 延迟敏感服务:希望在 OOM 之前就通过 PSI 信号做预防性处理

这些需求用传统方式实现非常困难——要么改内核代码重新编译,要么在用户态写 OOM daemon(如 oomd、systemd-oomd),但两者都有显著的维护成本和局限性。

BPF OOM 补丁系列概览

Roman Gushchin 的方案不是简单的"在现有框架上加几个 hook",而是重新设计了 OOM 处理流程:

mermaid
flowchart TD
    classDef new fill:#FFECB3,stroke:#E65100,color:#BF360C
    classDef existing fill:#E3F2FD,stroke:#1565C0,color:#1565C0
    classDef result fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20

    ps@{ shape: rounded, label: "PSI memory pressure\\(early trigger\\)" }
    alloc@{ shape: rounded, label: "`__alloc_pages` fails\\(direct reclaim\\)" }

    hook@{ shape: diamond, label: "BPF OOM hook (new hook point)" }

    bpf@{ shape: proc, label: "BPF 程序\n\\(自定义策略\\)" }
    default@{ shape: proc, label: "Default OOM Killer\n\\(oom_badness\\)" }

    kill@{ shape: stadium, label: "`bpf_oom_kill_process()`\\(用户自定义\\)" }
    ck@{ shape: stadium, label: "系统恢复" }

    ps --> hook
    alloc --> hook
    hook -->|"BPF 附接"| bpf
    hook -->|"无 BPF 处理"| default

    bpf -->|"策略决策"| kill
    kill --> ck
    default --> ck

    class ps,alloc existing
    class hook,bpf,kill new
    class default existing
    class ck result

关键设计思路:

  1. 内核插一个通用 hook 点:新增的 BPF OOM hook 在所有 OOM 路径上被调用
  2. 如果 BPF 程序附接了:完全交给 BPF 程序做决策
  3. 如果 BPF 程序没有附接或卸载了:回退到传统的 oom_badness() 逻辑
  4. PSI 驱动的主动触发:不需要等到内存彻底耗尽,PSI 压力超过阈值即可触发 BPF 程序

这个设计保证了安全性——BPF 程序出问题也不会让系统死锁。

补丁发展时间线

版本日期补丁数说明
RFC v12025-04-2812初始提案,LWN 报道(lwn.net/Articles/1019230)
v22025-10-2723大幅修订,增加 memcg kfuncs 和 selftests
v32026-01-2617进一步调整函数接口,基于 review 反馈改进

kfunc 接口详解

补丁集引入了多个新的 kfunc(BPF 可调用内核函数),让 eBPF 程序能和内核内存管理子系统交互:

bpf_oom_kill_process()

c
1
int bpf_oom_kill_process(struct oom_control *oc, struct task_struct *p);

在 BPF OOM 程序中调用,以与内核 OOM Killer 完全相同的方式杀死指定的进程。oc 是当前的 OOM 上下文,p 是选中的目标进程。这个 kfunc 被声明为 sleepable,因为在杀死进程的过程中可能需要等待锁或 I/O。

bpf_out_of_memory()

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int bpf_out_of_memory(struct mem_cgroup *memcg, int order);

 BPF 程序中显式声明 OOM 状态并触发 OOM 处理流程。可用于 PSI 事件触发后的主动处理——eBPF 程序决定"内存压力太大了,需要触发 OOM",然后调用这个 kfunc

### Memcg kfuncs

补丁还提供了多个用于内存 cgroup 操作的 kfunc,使 BPF 程序可以遍历 cgroup 层级、获取内存使用量:

```c
// 遍历 cgroup 内的所有进程
struct task_struct *bpf_get_memcg_tasks(struct mem_cgroup *memcg);

// 获取 cgroup 内存使用量
long bpf_memcg_usage(struct mem_cgroup *memcg);

// 获取 root memcg(系统级别的控制)
struct mem_cgroup *bpf_get_root_mem_cgroup(void);

关于 attachment 机制

如何让 BPF 程序与内核 OOM hook 绑定,是补丁集仍在讨论的设计问题。RFC v1 采用了 fmodret(function modifier return)方式——内核调用 OOM hook 时先执行 BPF 程序,BPF 程序可以决定是否接管处理。社区也在讨论是否有更好的方案(如引入新的 BPF 程序类型)。下面是一个概念性示例,展示自定义 OOM 策略的编程模型。

一个概念性的 BPF OOM 策略示例

以下代码是概念性的伪代码,展示 BPF OOM 策略的编程思路。实际 kfunc 名称和接口以最终合入版本为准。

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// SPDX-License-Identifier: GPL-2.0
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 在 BPF OOM hook 中实现自定义策略
// (fmodret 方式:BPF 程序在 hook 之前执行)
int BPF_PROG(custom_oom_policy, struct oom_control *oc)
{
    struct task_struct *victim = NULL;

    // 伪代码:按自定义策略选择 victim
    // 1. 受保护进程(数据库等)跳过
    // 2. 优先选 cgroup 内存超限的容器进程
    // 3. 否则选 oom_score 最低的

    if (victim) {
        bpf_printk("BPF OOM: killing pid=%d",
                   BPF_CORE_READ(victim, pid));
        bpf_oom_kill_process(oc, victim);
    }

    return 0;
}

PSI 驱动的主动 OOM

补丁集的第二部分是 PSI(Pressure Stall Information)驱动的 OOM 触发机制。传统 OOM Killer 是被动的——系统已经死锁了才出手。PSI 驱动的方案可以在内存压力达到某个阈值时就主动触发 BPF 程序做预防性处理:

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
SEC("kprobe/psi_group_poll")
int BPF_KPROBE(psi_handler, struct psi_group *group, u32 reason)
{
    u64 avg60 = BPF_CORE_READ(group, avg[PSI_MEM][2]);
    s64 full_avg = avg60 >> 12;  // 转换为微秒

    // 过去 60 秒的平均内存 stall 时间超过 500ms → 触发 BPF OOM
    if (full_avg > 500000) {
        bpf_printk("PSI memory pressure critical, triggering OOM\n");
        bpf_out_of_memory(NULL, 0);
    }
    return 0;
}

与用户态 OOM daemon 的对比

维度用户态 OOM daemon(oomd / systemd-oomd)BPF OOM
响应延迟秒级(PSI 通知 → 用户态处理 → kill)毫秒级(内核态直接处理)
可靠性内存压力下用户态进程可能被 OOM kill内核态执行,不受 OOM 影响
部署复杂度独立的 daemon,需要配置和维护加载 BPF 程序即可
数据可访问性只能通过 /proc 获取有限信息可直接访问内核数据结构
策略灵活性完全灵活(用户态任意逻辑)BPF verifier 限制(有限循环、有限栈)
可观测性日志 + 指标日志 + 指标 + 完整内核上下文

两类方案并非互斥。理想情况下,BPF OOM 处理大部分常用场景,用户态 daemon 处理复杂策略。

当前状态与未来展望

截至 2026 年 6 月的状态:

  • RFC v1(2025-04)→ v2(2025-10)→ v3(2026-01)持续迭代中
  • 补丁仍处于 bpf-next 分支中,社区 review 阶段
  • Roman Gushchin 在 Kernel Recipes 2025 上做了专题演讲
  • 尚无明确的合入时间表

这套补丁面临的核心挑战:

  1. 安全性:允许 BPF 程序杀进程是高风险操作,verifier 需要确保程序行为可预测
  2. 锁依赖:BPF 程序可能持有 oom_lock 时调用可能触发 I/O 的操作
  3. 分层策略:cgroup 层级中的 OOM 策略继承关系如何处理
  4. 回退机制:BPF 程序出问题时如何确保系统不会死锁

小结

BPF OOM 是 Linux 内存管理领域近年来最有意义的变化之一。它解决了 OOM Killer 二十多年来"一刀切"的痛点,让用户可以根据自己的 workload 定制 OOM 策略。虽然补丁还未合入主线,但其设计思路和技术方案已经足够有启发意义。

这个系列到这里就结束了。从 eBPF 基础概念开始,到 OOM 事件追踪的实现,再到容器级定位和内存分配分析,最后到 BPF OOM 内核补丁——希望能为你提供一个从"看"到"管"的完整 eBPF 内存可观测性学习路径。