eBPF issue 排查-Kretprobe 引起的死锁

91 阅读13分钟

现象

1. 系统卡死现象

kreprobe-1.png 启动 soft_lock 组件之后,运行一段时间,ssh 将会断开,虚拟机 VNC console 按键没有响应。这个时间不定,有时候一启动就卡住,有时候正常运行几分钟后才出现问题。

2. 组件运行状态

kreprobe-4.png 在 soft_lock 组件和内核都在正常运行的时候,trace 日志还是能够正常打印,说明 eBPF 程序本身功能正常,问题出现在特定的执行路径上。

3. 性能分析工具异常

kreprobe-3.png 使用 perf top 抓取内核事件的时候,内核卡住时会增加 perf 进程,如图所示,这表明性能分析工具也受到了死锁的影响。

4. 调试难点

由于内核卡住时,perf 也无法采集内核火焰图,这对故障的原因无法深入排查,增加了问题定位的难度。同时,dmesg 以及 /var/log/messages 等日志文件也无法正常打印,无法通过日志分析问题。

环境

内核版本

# nkvers 
############## Kylin Linux Version #################
Release:
Kylin Linux Advanced Server release V10 (Lance)

Kernel:
4.19.90-52.22.v2207.ky10.aarch64

Build: 
Kylin Linux Advanced Server
release V10 (SP3) /(Lance)-aarch64-Build23/20230324 
#################################################

eBPF 代码

内核态代码

SEC("kprobe/watchdog_timer_fn")
int trace_watchdog_timer(struct pt_regs *regs)
{
    // ...
    return 0;
}

SEC("kprobe/queued_spin_lock_slowpath")
int trace_native_queued_spin_lock_slowpath(struct pt_regs *regs)
{
    // ...
    return 0;
}
SEC("kretprobe/queued_spin_lock_slowpath")
int trace_native_queued_spin_lock_slowpath_ret(struct pt_regs *regs)
{
    // ...
    return 0;
}

具体代码参考 beepfd/core 项目的 soft_lock.c,当前内核态代码主要用于软锁死检测和内核调度性能监控,具体功能包括:

  1. 看门狗定时器监控 (trace_watchdog_timer)

    • 监控内核看门狗定时器函数 watchdog_timer_fn 的调用
    • 用于检测系统是否出现软锁死(soft lockup)情况
    • 当CPU长时间无法响应中断或调度时,看门狗会触发告警
  2. 自旋锁慢路径跟踪 (trace_native_queued_spin_lock_slowpath)

    • 监控自旋锁的慢路径获取过程 queued_spin_lock_slowpath
    • 跟踪锁竞争激烈导致进入慢路径的情况
    • 收集锁等待时间、竞争频率等性能指标
  3. 自旋锁慢路径返回跟踪 (trace_native_queued_spin_lock_slowpath_ret)

    • 监控自旋锁慢路径函数的返回
    • 计算锁持有时间和等待时间
    • 分析锁的获取效率和系统瓶颈
自旋锁慢路径挂载点说明

在 Linux 内核中,根据不同的编译配置和运行环境,queued_spin_lock_slowpath 会有不同的实现:

  1. queued_spin_lock_slowpath

    • 通用的排队自旋锁慢路径入口函数
    • 作为统一的函数名称,在不同配置下会指向不同的具体实现
    • 是 eBPF kprobe 最常用的挂载点
  2. native_queued_spin_lock_slowpath

    • 原生(非虚拟化)环境下的排队自旋锁慢路径实现
    • 在物理机或者支持硬件加速的虚拟化环境中使用
    • 直接使用硬件的原子操作和内存屏障
    • 性能最优,但不适用于半虚拟化环境
  3. __pv_queued_spin_lock_slowpath

    • 半虚拟化(Paravirtualized)环境下的排队自旋锁慢路径实现
    • 在 Xen、KVM 等虚拟化环境中使用,通过 hypercall 与虚拟化层交互
    • 可以避免在虚拟化环境中无效的自旋等待,提高整体性能
    • 当内核配置 CONFIG_PARAVIRT_SPINLOCKS=y 时启用

复现

不同内核版本和模块配置测试结果

内核版本模块是否hang
6.8kprobe/watchdog_timer_fn、kprobe/__pv_queued_spin_lock_slowpath、kretprobe/__pv_queued_spin_lock_slowpath未hang
4.19kprobe/watchdog_timer_fn、kprobe/queued_spin_lock_slowpath、kretprobe/queued_spin_lock_slowpathhang
4.19kprobe/watchdog_timer_fnhang
4.19kprobe/watchdog_timer_fn、kprobe/queued_spin_lock_slowpathhang

从测试结果可以看出:

  1. 内核版本差异:6.8 内核未出现 hang 问题,说明新版本内核可能已经修复了相关的死锁问题
  2. 模块影响:在 4.19 内核中,只要包含 kretprobe/queued_spin_lock_slowpath,就会导致系统 hang
  3. 关键模块定位:kretprobe/queued_spin_lock_slowpath 是导致死锁的关键模块
  4. 安全模块:kprobe/watchdog_timer_fn 单独运行时不会引起问题

这进一步确认了问题的根源在于 kretprobe 对 queued_spin_lock_slowpath 的探测机制存在缺陷。

分析

工具选择

kdump

kdump 是 Linux 内核的崩溃转储机制,通过配置适当的 sysctl 参数可以在系统出现死锁、软锁定、硬锁定等问题时自动生成 crash dump 文件。

关键 sysctl 参数配置
参数作用推荐值说明
kernel.sysrq启用魔术系统请求键1允许通过 Alt+SysRq+键 执行紧急操作
kernel.watchdog_thresh看门狗超时阈值(秒)10检测系统无响应的时间限制
kernel.hung_task_panic挂起任务时是否 panic1检测到任务长时间挂起时触发 panic
kernel.perf_event_paranoidperf 事件权限级别-1允许非特权用户访问所有 perf 功能
kernel.kptr_restrict内核指针访问限制0允许读取内核指针,便于调试
kernel.softlockup_panic软锁定时是否 panic1检测到 CPU 长时间无法调度时触发
kernel.panic_on_oops内核错误时是否 panic1任何内核 oops 都触发 panic
kernel.panicpanic 后重启延迟10panic 后等待时间(秒)
kernel.hardlockup_panic硬锁定时是否 panic1检测到硬件级锁定时触发
针对 kretprobe 死锁问题的特殊配置

对于当前的 kretprobe 死锁问题,建议额外配置:

# 降低软锁定检测阈值,更快触发 dump
kernel.watchdog_thresh=5

# 启用更详细的调试信息
kernel.printk=8 4 1 7

kreprobe-kdump-1.png

kreprobe-kdump-2.png

基本信息
  • 故障类型:软锁定(soft lockup)
  • 受影响 CPU:CPU#0 卡死 11 秒
  • 故障进程:sshd (PID: 18041)
  • 内核版本:4.19.90-52.42.v2207.ky10.aarch64
故障调用链分析
sys_prctl                     # 系统调用入口
├── prctl_set_seccomp        # 设置 seccomp 模式
    ├── do_seccomp           # 执行 seccomp 操作
        ├── seccomp_set_mode_filter    # 设置 seccomp 过滤器
            ├── bpf_prog_create_from_user    # 从用户空间创建 BPF 程序
                ├── bpf_prepare_filter       # 准备 BPF 过滤器
                    ├── bpf_prog_select_runtime   # 选择 BPF 运行时
                        ├── bpf_int_jit_compile   # BPF JIT 编译
                            ├── kick_all_cpus_sync    # 同步所有 CPU
                                └── smp_call_function_many ← **卡死位置**
卡死位置详细分析
pc : smp_call_function_many+0x348/0x3a0
lr : smp_call_function_many+0x308/0x3a0

程序计数器和链接寄存器都指向 smp_call_function_many 函数,说明 CPU 在这个函数内部陷入循环等待状态。

lockdep

kreprobe-2.png

lockdep 递归锁死检测分析
  • 警告类型:WARNING: possible recursive locking detected
  • 内核版本:4.19.90-52.48.v2207.ky10.aarch64+debug #1
  • 故障进程:swapper/1/0 (CPU1 的空闲进程)
锁冲突详细分析

冲突的锁对象:

锁地址:00000000056253b5 (&rp->lock){-.-.}
当前尝试获取位置:pre_handler_kretprobe+0xe4/0x530
已持有锁的位置:recycle_rp_inst+0xf8/0x278

死锁场景:

CPU0
----
lock(&rp->lock);     ← recycle_rp_inst 中已经持有
lock(&rp->lock);     ← pre_handler_kretprobe 中试图再次获取
*** DEADLOCK ***
锁持有状态分析

系统同时持有 3 个锁

#0: 00000000d4eaacda (rcu_node_0){..-.}
    位置:rcu_process_callbacks+0x7ec/0x1868
    
#1: 00000000d386099e (&(kretprobe_table_locks[i].lock)){-.-.}
    位置:kretprobe_hash_lock+0x80/0xb8
    
#2: 00000000056253b5 (&rp->lock){-.-.}
    位置:recycle_rp_inst+0xf8/0x278
完整调用链分析

死锁触发路径:

arch_cpu_idle+0x3b4/0x730                    # CPU 空闲状态
├── 中断处理
│   ├── el1_irq+0xbc/0x140                   # ARM64 IRQ 处理入口
│   ├── gic_handle_irq+0x1ac/0x2e8           # GIC 中断控制器处理
│   ├── __handle_domain_irq+0x120/0x168      # 中断域处理
│   ├── irq_exit+0x2c4/0x528                 # 中断退出处理
│   └── __do_softirq+0x91c/0x1304            # 软中断处理
├── RCU 回调处理
│   └── rcu_process_callbacks+0x7ec/0x1868   # RCU 回调处理 (持有 rcu_node_0 锁)
├── kretprobe 返回处理
│   ├── kretprobe_trampoline+0x70/0xc4       # kretprobe 跳板函数
│   ├── trampoline_probe_handler+0x2f0/0x5d0 # 跳板处理函数
│   └── recycle_rp_inst+0xf8/0x278           # 回收 kretprobe 实例 (持有 rp->lock)
└── 锁函数触发 kprobe
    ├── _raw_spin_lock+0xf4/0x118             # 自旋锁函数
    ├── queued_spin_lock_slowpath+0x0/0x6b0   # 触发 kprobe 探测点
    ├── kprobe_breakpoint_handler+0x400/0x548 # kprobe 断点处理
    ├── aggr_pre_handler+0xf8/0x190           # 聚合前置处理器
    └── pre_handler_kretprobe+0xe4/0x530      # kretprobe 前置处理 (尝试获取 rp->lock)
递归锁死机制分析

问题根源:

  1. RCU 回调上下文:系统正在 RCU 软中断中处理回调
  2. kretprobe 实例回收:recycle_rp_inst 函数持有 &rp->lock
  3. 锁函数被探测:在持有锁的状态下,又调用了被 kretprobe 监控的锁函数
  4. 递归获锁:pre_handler_kretprobe 尝试获取同一个 &rp->lock

技术细节:

  • &rp->lock:kretprobe 实例的保护锁,用于同步对 kretprobe 实例的访问
  • recycle_rp_inst:负责回收已使用的 kretprobe 实例,需要持有 rp->lock
  • pre_handler_kretprobe:kretprobe 的前置处理函数,也需要获取 rp->lock
关键问题分析

设计缺陷:

  1. 重入问题:kretprobe 监控了可能在其自身执行过程中被调用的函数
  2. 锁层次问题:没有正确的锁获取顺序,导致递归获锁
  3. 上下文冲突:软中断上下文中的 kretprobe 处理与普通上下文产生竞争

BZ#1844522 启用一些 kretprobes 可以触发内核 panic 使用以下 功能的 kretprobe 可能会导致 CPU 硬锁定: _raw_spin_lock _raw_spin_lock_irqsave _raw_spin_unlock_irqrestore queued_spin_lock_slowpath 因此,启用这些 kprobe 事件可能会遇到系统响应失败。在这种情况下会触发内核 panic。要解决这个问题,请避免为上述功能配置 kretprobes,并防止系统响应失败。

解决方案

bpftrace

bpftrace 的预防性解决方案

PR #534: Ban kprobes that can cause soft lockups

发现时间:2019年4月11日
问题来源:iovisor/bcc#2300
解决策略:主动禁止危险的 kretprobe 挂载点

问题发现过程

触发现象:

watchdog: BUG: soft lockup - CPU#8 stuck for 23s! [IOThreadPool0:2190014]

影响程度:

  • 运行特定的 BPF 代码会导致系统完全不可用
  • 需要重启系统才能恢复
  • 在测试环境中多次复现(内核版本 4.16.18 和 5.0)
bpftrace 的解决方案

被禁止的 kretprobe 挂载点:

# bpftrace 测试结果
kretprobe:_raw_spin_lock is banned OK
kretprobe:queued_spin_lock_slowpath is banned OK  ← 我们遇到的问题
kretprobe:_raw_spin_unlock_irqrestore is banned OK
kretprobe:_raw_spin_lock_irqsave is banned OK

代码逻辑:

/*
 * Kernel functions that are unsafe to trace are excluded in the Kernel with
 * `notrace`. However, the ones below are not excluded.
 */
const std::set<std::string> banned_kretprobes = {
  "_raw_spin_lock", "_raw_spin_lock_irqsave", "_raw_spin_unlock_irqrestore",
  "queued_spin_lock_slowpath",
};

void check_banned_kretprobes(std::string const& kprobe_name) {
  if (banned_kretprobes.find(kprobe_name) != banned_kretprobes.end()) {
    std::cerr << "error: kretprobe:" << kprobe_name << " can't be used as it might lock up your system." << std::endl;
    exit(1);
  }
}

关联内核补丁

[4.19,203/206] kretprobe: Prevent triggering kretprobe from within kprobe_flush_task

补丁信息:

问题复现情况

触发场景:在 _raw_spin_lock_irqsave 上添加 retprobe 时出现锁死

lockdep 输出(与我们分析的问题完全一致):

============================================
WARNING: possible recursive locking detected
5.6.0-rc6+ #6 Not tainted
--------------------------------------------
sched-messaging/2767 is trying to acquire lock:
ffffffff9a492798 (&(kretprobe_table_locks[i].lock)){-.-.}, at: kretprobe_hash_lock+0x52/0xa0

but task is already holding lock:
ffffffff9a491a18 (&(kretprobe_table_locks[i].lock)){-.-.}, at: kretprobe_trampoline+0x0/0x50

*** DEADLOCK ***

stack backtrace:
kretprobe_trampoline+0x25/0x50
? _raw_spin_lock_irqsave+0x50/0x70
问题根本原因

死锁路径分析:

kprobe_flush_task
├── kretprobe_table_lock
    ├── raw_spin_lock_irqsave
        └── _raw_spin_lock_irqsave  ← 触发 kretprobe,安装 kretprobe_trampoline

        → kretprobe_table_locks 锁已被持有

        kretprobe_trampoline
        └── trampoline_handler
            └── kretprobe_hash_lock(current, &head, &flags);  ← 死锁!

核心问题:

  1. 递归触发:kretprobe 在自身的锁管理代码中触发了被监控的锁函数
  2. 锁重入:kretprobe_table_locks 已被持有,但 trampoline 处理器试图再次获取
  3. 上下文混乱:中断上下文与普通上下文的锁竞争
修复方案

核心修复机制:引入 kprobe_busy 机制防止递归

新增函数:

struct kprobe kprobe_busy = {
    .addr = (void *) get_kprobe,
};

void kprobe_busy_begin(void)
{
    struct kprobe_ctlblk *kcb;
    
    preempt_disable();
    __this_cpu_write(current_kprobe, &kprobe_busy);
    kcb = get_kprobe_ctlblk();
    kcb->kprobe_status = KPROBE_HIT_ACTIVE;
}

void kprobe_busy_end(void)
{
    __this_cpu_write(current_kprobe, NULL);
    preempt_enable();
}

应用位置:

  1. kprobe_flush_task 中使用 kprobe_busy_begin/end 包围锁操作
  2. trampoline_handler 中使用 kprobe_busy 替代原有的递归保护机制

修复效果:

  • 防止在 kprobe 处理代码中触发新的 kprobe
  • 通过假的 probe 标记实现递归保护
  • 确保 lockdep 的递归检查生效,阻止死锁路径

bpf_lock:内核的根本性解决方案(合并中)

Commit 046bbea5: bpf: Introduce bpf_lock

作者:Alexei Starovoitov ast@kernel.org
时间:2024年4月4日
目标:引入 BPF 程序安全锁机制,从根本上解决锁死问题

问题背景

这个 commit 尝试解决的核心问题正是我们遇到的:BPF 程序中的锁安全问题,包括:

  1. 递归死锁:BPF 程序在监控锁函数时自身产生的递归锁死
  2. 内存损坏:verifier 或 BPF 程序 bug 导致的锁结构体损坏
  3. ABBA 死锁:不同锁获取顺序导致的经典死锁问题
  4. 无限等待:缺乏超时机制导致的无限期等待
技术方案

该 commit 通过引入多层次安全机制来根本性解决 BPF 程序中的死锁问题:首先采用间接保护层设计,BPF 程序不再直接操作内核锁结构,而是通过 28 位 CPU ID + 4 位 per-CPU 锁 ID 的编码方式访问锁,避免了像 queued_spin_lock_slowpath 这样的递归锁死;其次使用MCS 锁算法替代传统自旋锁,该算法具有 NUMA 友好、FIFO 公平性和良好可扩展性的特点,天然避免了缓存行争用和递归获锁问题;同时内置完整的死锁检测机制,包括 AA 死锁检测(同一线程重复获取同一锁)和 ABBA 死锁检测(循环依赖检测),配合 1 秒超时机制防止无限等待;最后通过错误恢复和隔离机制,一旦检测到死锁就自动清理 bpf_lock_kern 并将 BPF 程序标记为 bad_lock,后续锁操作直接失败,从而将有问题的程序隔离,避免对整个系统造成进一步损害。这种设计从根本上解决了 kretprobe 监控锁函数时产生的递归死锁问题,为 BPF 程序提供了安全可靠的锁机制。

根据本内核 commit 的思路 bpftrace 也实现了类似的机制,具体实现可以参考 bpftrace/bpftrace#3206