操作系统内存排障,证据要能复现而不是堆日志
操作系统内存排障,证据要能复现而不是堆日志
在线上高并发业务场景中,Linux 内核的内存异常定位属于复杂度较高的工程挑战。典型的故障场景表现为:服务器触发 OOM Killer,核心业务进程被终止,或者节点在内核态出现卡顿。排查时往往发现除了 dmesg 中记录的 Out of memory: Kill process 提示外,缺少可复现或定位根因的完整现场证据。
这一瓶颈产生的原因在于,习惯了应用层日志思维(如 log.Info(...))的分析方式在内核层面并不完全适用。Linux 内核的内存分配发生在微秒级,高频的日志打点要么会挤占磁盘空间,要么会被内核的 printk_ratelimit 限流机制丢弃。
本文围绕 Linux 内核源码分析与底层排障,探讨如何在内核内存异常发生时,利用日志、指标(Metrics)与动态追踪(Trace)构建完整的排障证据链。
1. 线上节点发生 OOM 时的现场日志分析
在某服务端物理节点(配置 32GB 内存)的故障排查中,服务在连续运行后突然终止。应用层日志未抛出任何 Runtime panic 异常。
执行 dmesg -T 获取到如下内核打印:
[Tue Aug 18 03:14:22 2026] myservice invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[Tue Aug 18 03:14:22 2026] CPU: 4 PID: 12891 Comm: myservice Not tainted 5.15.0-88-generic #98-Ubuntu
[Tue Aug 18 03:14:22 2026] Hardware name: QEMU Standard PC (Q35 + ICH9, 2009)
[Tue Aug 18 03:14:22 2026] Call Trace:
[Tue Aug 18 03:14:22 2026] <TASK>
[Tue Aug 18 03:14:22 2026] dump_stack_lvl+0x46/0x5e
[Tue Aug 18 03:14:22 2026] dump_header+0x4f/0x203
[Tue Aug 18 03:14:22 2026] oom_kill_process+0x80/0x110
[Tue Aug 18 03:14:22 2026] out_of_memory+0x231/0x4d0
...
[Tue Aug 18 03:14:22 2026] Out of memory: Killed process 12891 (myservice) total-vm:42189040kB, anon-rss:31451200kB, file-rss:0kB, shmem-rss:0kB
日志显示被杀进程当时有约 31GB 匿名页,但它不能单独证明内存泄漏,更不能直接归因到 Slab。还要结合 cgroup 限制、其他进程占用、/proc/meminfo、/proc/slabinfo 和时间序列判断。
若未开启底层 Trace 与 Slab 采样,往往只能重启服务等待故障再次发生。缺乏有效证据时,排障过程效率极低且带有盲目性。
2. 三大底层证据链:printk/dmesg 日志、/proc/meminfo 指标与 ftrace/eBPF 链路
在内核内存可观测性构建中,需要搭建“日志-指标-Trace”三位一体的排障链路:
1. 指标层(Metrics):关注 /proc/meminfo 的深层字段
除关注系统 MemFree 外,在内核层面以下字段是捕获泄漏的关键信号:
Slab / SUnreclaim:分别观察总 Slab 与不可回收 Slab。持续上升值得排查缓存回收、内核对象生命周期和驱动分配路径,但不等同于已经确认泄漏。PageTables:页表占用的内存。该数值异常飙升表明系统创建了大量线程或虚拟内存映射段(VMA)。Committed_AS:系统承诺分配的总内存量,反映了物理节点内存超卖的潜在风险。
2. 日志层(Logs):配置内核参数保留现场
通过调整 /etc/sysctl.conf 配置,可让内核在发生 OOM 时打印全量任务与内存映射信息:
# 发生 OOM 时输出全部 Task 的内存占用与 Slab 信息
vm.oom_dump_tasks = 1
# 记录物理页分配失败的具体错误信息
vm.warn_alloc_failed = 1
3. Trace 层:基于 eBPF 捕获内存分配栈
当观察到 SUnreclaim 持续上涨时,传统手段难以确定具体的内核代码分配路径。借助 eBPF 在内核 mm_page_alloc 或 kmem_cache_alloc 处打点,能够记录未被 kfree 的指针及其对应的内核调用栈。
3. 内存分配路径上的动态追踪:利用 eBPF 捕获 Slab 泄漏现场
可通过 eBPF 跟踪 kmalloc 与 kfree 的配对关系,汇总未释放分配的调用栈以定位泄漏源头。
动态追踪可以缩小排查范围,但“未匹配释放”只是观察窗口内仍存活的分配,不应直接判定为泄漏。应在压测或隔离环境中结合对象生命周期复核。
4. eBPF 内存泄漏与 Kernel Allocation 追踪器实现
下面是一段基于 Python BCC (BPF Compiler Collection) 框架编写的内核/内核模块 kmalloc 泄漏检测工具代码:
#!/usr/bin/env python3
from bcc import BPF
import time
import ctypes
# 定义 eBPF C 语言内核态代码
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <linux/mm.h>
struct alloc_info_t {
u64 size;
u64 timestamp_ns;
int stack_id;
};
// 哈希表保存分配的地址到分配信息的映射
BPF_HASH(allocs, u64, struct alloc_info_t);
// 堆栈跟踪表
BPF_STACK_TRACE(stack_traces, 4096);
// Hook 内存分配入口 (kmem:kmalloc Tracepoint)
TRACEPOINT_PROBE(kmem, kmalloc) {
u64 ptr = (u64)args->ptr;
if (ptr == 0) return 0;
struct alloc_info_t info = {};
info.size = args->bytes_alloc;
info.timestamp_ns = bpf_ktime_get_ns();
info.stack_id = stack_traces.get_stackid(args, BPF_F_FAST_STACK_CMP);
allocs.update(&ptr, &info);
return 0;
}
// Hook 内存释放入口 (kmem:kfree Tracepoint)
TRACEPOINT_PROBE(kmem, kfree) {
u64 ptr = (u64)args->ptr;
if (ptr == 0) return 0;
allocs.delete(&ptr);
return 0;
}
"""
def main():
print("正在载入 eBPF 内核内存分配追踪器... 按 Ctrl+C 停止收集并输出证据报告。")
b = BPF(text=bpf_text)
try:
time.sleep(10) # 收集 10 秒钟内的分配与释放轨迹
except KeyboardInterrupt:
pass
allocs = b.get_table("allocs")
stack_traces = b.get_table("stack_traces")
# 统计长时间未释放的内存块(泄漏候选)
stack_leak_counts = {}
total_leaked_bytes = 0
for ptr, info in allocs.items():
total_leaked_bytes += info.size
sid = info.stack_id
if sid not in stack_leak_counts:
stack_leak_counts[sid] = {"bytes": 0, "count": 0}
stack_leak_counts[sid]["bytes"] += info.size
stack_leak_counts[sid]["count"] += 1
print("\n================== 线上内存分配证据分析报告 ==================")
print(f"检测到未匹配释放的活动分配内存总计: {total_leaked_bytes / 1024.0:.2f} KB\n")
# 输出产生未释放内存最多的前 3 个内核调用栈
sorted_stacks = sorted(stack_leak_counts.items(), key=lambda x: x[1]["bytes"], reverse=True)[:3]
for rank, (sid, stat) in enumerate(sorted_stacks, 1):
print(f"--- 疑似泄漏源头 #{rank} (未释放字节数: {stat['bytes']} bytes, 次数: {stat['count']}) ---")
if sid >= 0:
for addr in stack_traces.walk(sid):
# 打印出内核符号名称与代码偏移
sym = b.ksym(addr).decode('utf-8', 'replace')
print(f" -> {sym}")
else:
print(" [无法提取 Callstack]")
print()
if __name__ == "__main__":
main()
将该脚本在测试节点上运行,分析哪些驱动函数只分配不释放,对应的函数栈(如 my_driver_ioctl + 0x4f)即可直观呈现。
5. 现场证据留存闭环:kdump 与崩溃转储配置
为了做到完整的可观测性闭环,除事中 Trace 和监控外,还需要在系统层配置 kdump / crash 崩溃转储:
- 预留 crashkernel 内存:在内核启动参数中添加
crashkernel=256M,专门为内核崩溃收集器预留一块隔离内存。 - 谨慎评估 Panic on OOM:该设置会主动让节点崩溃重启,只适合已经验证自动故障转移和转储流程的场景;多数业务应先考虑 cgroup 限额、告警和服务降载。
- 建立证据汇聚通道:将
dmesg、/proc/meminfo的定时 Snapshot 以及 eBPF 生成的 Stacktrace 自动上报归档,便于事后分析。
底层问题需要足够的现场证据。日志、指标和动态追踪各有盲区,最好用同一时间窗的数据交叉验证,再给出根因结论。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)