一次内存泄漏排查复盘

描述

一、概述

1.1 背景介绍

故障发生在一套图片缩略图服务的晚高峰时段。版本发布后不到 90 分钟,业务侧先出现 P99 RT 抖动,随后节点 MemAvailable 持续下滑,容器开始间歇性被 OOMKilled,同节点上的日志采集和边车也跟着抖。现场最容易犯的错是看到内存高就先重启,或者只看 free -h 就把锅甩给 page cache。这样做能把告警按掉,但根因还在,第二波流量一来还会复发。

这次复盘的价值不在于“某个命令怎么用”,而在于把四个工具的职责拆清楚:free 负责判断整机是否真的在失血,smem 负责把共享页和私有页拆开,pmap 负责把可疑进程的映射类型摊平,perf 负责把“谁在不停分配”收敛到调用栈。四个工具单独用都不难,难的是现场顺序。顺序一乱,结论就会飘。

1.2 现场影响与入口信号

项目 现场表现 值班影响
业务接口 缩略图生成接口 P99 从 320ms 升到 2.4s 上游图片服务重试放大
节点资源 MemAvailable  从 18GiB 降到 2GiB 触发全节点内存回收
容器事件 20 分钟内出现 7 次 OOMKilled Pod 重启,连接池反复建立
系统日志 journalctl -k  出现 Out of memory 和 Killed process 说明不止容器内 RSS 高
业务日志 个别请求耗时与大图解码强相关 指向 native 库和缓冲区

1.3 工具分工表

工具 回答的核心问题 第一眼先看什么 适用阶段
free 是整机匿名内存上涨,还是 page cache 波动 available 、buff/cache、swap 第一分钟
vmstat  / sar -r 回收、换页、内存压力是否同步出现 si/so 、pgscan、kbmemfree 第一轮判断
smem 哪个进程真正吃掉了物理内存 PSS 、USS、进程组分布 进程定位
pmap -x  / smaps 涨的是匿名段、文件映射还是共享页 anonymous 、private dirty 第二轮下钻
perf 谁在持续分配、拷贝、解码 热点符号、调用链 根因确认

1.4 故障判断链路

先证明问题是整机持续失血,而不是 page cache 的正常波动。

再证明是单类业务进程的 USS/PSS 异常,而不是共享库映射带来的假大户。

再证明涨的是匿名私有映射,而不是文件映射、tmpfs 或 JIT 缓冲。

最后用调用栈把分配热点收敛到具体 native 解码库的异常分支。

这条链路看起来长,实际比“看到 RSS 大就重启”更省时间。因为只要证据链建立完整,后面的止血、回滚、回归验证都会跟着变简单。

1.5 环境与证据保全策略

项目 实际值 说明
宿主机 32C / 64GiB 混部 10 个业务 Pod
容器限制 requests.memory=4Gi ,limits.memory=6Gi 超限先杀容器,再拖低节点
内核版本 5.15.x 支持 perf 与现代 cgroup 观测
运行时 containerd 1.7 通过 crictl 可关联容器与 PID
发布变更 引入新版图片解码 .so 故障与版本变更时间高度重合

现场纪律只有三条:

没做快照前不要重启可疑进程;

没拆清共享页前不要直接按 RSS 排名甩锅;

没把 OOM、smaps、perf 串起来前,不要轻易下“代码泄漏”结论。

1.6 先把现场归到哪一类

类别 典型信号 第一反应
真泄漏 USS 、Anonymous、Private_Dirty 单向上涨 重点抓 smaps 与 perf
page cache 幻觉 used  高但 available 稳 不要急着重启
共享页放大 RSS  高、PSS/USS 不高 先拆共享页
cgroup 过小 Pod OOM 明显早于节点压力 先核对 limit 与工作集
tmpfs / shm 膨胀 /dev/shm  或挂载目录飙高 先查共享内存

二、详细步骤

2.1 故障背景与时间线

这次事故不是“机器突然不够用”,而是一次上线引入的 native 图像解码路径在异常图像输入下没有及时释放中间缓冲,导致匿名私有页持续上涨。由于服务本身吞吐不低,单次泄漏不大,但在持续流量下累积明显,最终表现成整机内存缓慢坠落。

时间 事件 证据
19:05 新版本灰度 20% 发布记录与镜像 digest
19:22 MemAvailable  开始持续下降 节点监控
19:34 图片接口 P99 明显抬高 APM 与 ingress 指标
19:41 首个 Pod 被 OOMKilled kubectl describe pod
19:48 节点开始出现回收和 direct reclaim vmstat 、journalctl -k
19:56 灰度回滚后曲线放缓 发布记录与监控对照
20:20 现场采样完成,锁定 native 解码库 smaps  + perf

2.1.1 快速确认整机内存是不是在持续失血

 

free -h
vmstat 1 10
sar -r 1 10
               total        used        free      shared  buff/cache   available
Mem:            62Gi        57Gi       1.8Gi       512Mi       3.4Gi       2.2Gi
Swap:             0B          0B          0B
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  1      0 1898040  48240 3521100 0    0     40   6120  812 2311 41 12 39  8  0
 6  1      0 1651120  48240 3498124 0    0     64   7104  901 2488 43 11 35 11  0

 

第一轮判断点只有两个:

available 在持续掉,不是上下抖几百 MiB;

buff/cache 没有同步膨胀,说明不是 page cache 在顶高“已用内存”。

如果 free 显示 used 高但 available 还稳,或者 buff/cache 高但可回收,那不是这篇文章讨论的泄漏现场,别把正常缓存当故障。

2.1.2 把内核回收信号一起看

 

grep -E 'pgscan|pgsteal|pswpin|pswpout' /proc/vmstat | head
cat /proc/pressure/memory
some avg10=12.54 avg60=8.31 avg300=2.20 total=145986126
full avg10=1.73 avg60=0.88 avg300=0.19 total=18201132

 

这里的价值在于区分两件事:

进程真的在涨内存:MemAvailable 持续降,memory pressure 升;

只是瞬时波动:available 会回升,psi 不会持续拉高。

2.1.3 用节点与 Pod 视角对齐时间

 

kubectl top pod -n media | sort -k4 -hr | head
kubectl get events -n media --sort-by=.lastTimestamp | tail -20
thumbnail-svc-7b6d4d7d5f-krp2m   120m   5820Mi
thumbnail-svc-7b6d4d7d5f-z9qwn   118m   5712Mi
log-agent-g8t6b                    80m    210Mi

 

节点和容器两个视角必须对齐。否则你会遇到一种常见误判:容器看着只用了 5.7Gi,却认为节点没理由掉得这么快。实际上,同节点多个副本同步上涨,再加上回收开销和边车缓存,就足够把整机拖进回收。

2.2 第一轮判断:先分整机问题还是 page cache 幻觉

2.2.1 不要只盯 used

指标 现场表现 正确解释 常见误判
used 只是总占用,不区分缓存 看见高就说泄漏
available 连续下降 真正可用内存在变少 被忽略
buff/cache 基本稳定 缓存不是主因 被误认为“都能回收”
swap 为 0 没有 swap 缓冲空间 内存压力更直接传导到 OOM

2.2.2 结合 slab 与 tmpfs 排除其他大户

 

slabtop -o | head -20
df -h /dev/shm
du -sh /run /tmp 2>$null | sort -hr | Select-Object -First 10
 Active / Total Objects (% used)    : 9382842 / 9553180 (98.2%)
 Active / Total Slabs (% used)      : 221439 / 221439 (100.0%)
  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME

 

这一步是为了排除三个容易把方向带偏的对象:

slab 异常增长,尤其是 dentry、inode、网络缓存;

tmpfs 或共享内存打满;

临时目录或运行时目录堆积,表现成文件页而不是匿名页。

如果这些项平稳,再继续往用户态进程下钻就更稳。

2.3 第二轮判断:用 smem 把共享页和私有页拆开

2.3.1 按 PSS/USS 找真大户

 

smem -tk
smem -rs pss -k | head -20
smem -P thumbnail-svc -c "pid user command pss uss rss" | head -20
  PID User     Command                             PSS      USS      RSS
23111 app      /usr/local/bin/thumbnail-svc     4312M    4098M    5220M
23198 app      /usr/local/bin/thumbnail-svc     4276M    4061M    5174M

 

为什么 smem 比 ps aux --sort -rss 更适合第一轮定位:

RSS 会把共享页全部算进去,多个副本看上去都很大;

PSS 会把共享页按比例摊分,更接近真实物理占用;

USS 基本可以看成进程私有不可共享的部分,涨得快时非常敏感。

这次现场两个 thumbnail 进程的 USS 一起涨,说明不是共享库映射大,也不是语言运行时把共享页放大,而是每个实例都在单独囤匿名私有内存。

2.3.2 按时间采样,而不是只看单点

 

for ($i=0; $i -lt 6; $i++) { smem -P thumbnail-svc -c "pid pss uss rss" | Select-Object -First 5; Start-Sleep 30 }
PID      PSS      USS      RSS
23111   4312M    4098M    5220M
23111   4470M    4254M    5381M
23111   4628M    4411M    5534M

 

单点的大不一定有问题,持续上涨才有问题。线上值班经常踩的坑是采一张截图就下结论,结果只是刚好遇到一批大图请求。这里 3 轮采样都在涨,方向就足够清楚了。

2.3.3 把容器 PID 和宿主机 PID 对齐

 

crictl ps | grep thumbnail-svc
crictl inspect  | jq '.info.pid'
nsenter -t  -m -u -i -n -p -- ps -o pid,ppid,cmd -p 1

 

如果 PID 没对齐,后面的 pmap 和 perf 就可能采错对象。Kubernetes 现场里这一步经常被省略,结果抓了 pause 容器或 sidecar,后面排查全偏。

2.4 第三轮下钻:pmap 与 smaps 把映射类型摊平

2.4.1 用 pmap -x 看匿名段是不是主因

 

pmap -x 23111 | tail -20
pmap -x 23111 | grep -E 'anon|zero|rw---' | tail -20
Address           Kbytes     RSS   Dirty Mode  Mapping
00007f5de0000000  524288   520112 520112 rw---   [ anon ]
00007f5e00000000  262144   259800 259800 rw---   [ anon ]
total kB         5481120 5220188 5179948

 

现场最关键的信号是 [ anon ] 匿名私有段持续膨胀,且 Dirty 高。这说明内存不是文件映射进来的,也不是共享库只读段,而是真有人在申请并持有用户态堆或大块缓冲。

2.4.2 用 smaps 聚合 private dirty / anonymous

 

grep -E '^(Size|Rss|Pss|Private_Dirty|Anonymous):' /proc/23111/smaps | head -40
Size:             524288 kB
Rss:              520112 kB
Pss:              520112 kB
Private_Dirty:    520112 kB
Anonymous:        520112 kB

 

为了避免在几千行 smaps 里迷路,现场更实用的是做一次字段聚合。

 

awk '
/^Size:/ {size+=$2}
/^Rss:/ {rss+=$2}
/^Pss:/ {pss+=$2}
/^Private_Dirty:/ {pd+=$2}
/^Anonymous:/ {anon+=$2}
END {printf("Size=%dKB Rss=%dKB Pss=%dKB Private_Dirty=%dKB Anonymous=%dKB
", size,rss,pss,pd,anon)}
' /proc/23111/smaps
Size=5481120KB Rss=5220188KB Pss=5219062KB Private_Dirty=5179948KB Anonymous=5182200KB

 

这组数字把根因方向收得很窄:高的是匿名私有脏页。如果这里看到的是文件映射大,就应该回头查内存映射文件、模型权重、共享内存、JIT cache;如果看到 Shared_Clean 高,就不该再往“每个实例都泄漏”上硬靠。

2.4.3 映射类型判断矩阵

映射信号 更像什么问题 下一步该看什么
Anonymous  持续升高 堆内存、native 缓冲、未释放对象 perf 、jemalloc/tcmalloc 统计
文件映射大 mmap 文件、模型、索引、缓存文件 lsof -p 、映射文件路径
Shared_Clean  大 共享库、共享页、只读映射 不要直接按 RSS 下结论
Private_Dirty  高 真实私有写脏内存 重点排查申请后未释放

2.4.4 把大图请求和内存增长对齐

 

grep 'decode image' /var/log/thumbnail/app.log | tail -20
grep 'decode image' /var/log/thumbnail/app.log | awk '{print $1" "$2" "$NF}' | tail -20
2026-03-07 1912 req_id=8f3a size=17834x12920 format=tiff decode image slow path
2026-03-07 1913 req_id=1c5e size=18400x13110 format=tiff decode image slow path

 

单看 smaps 只能说明“内存涨了”,还不能解释“为什么是这个时段”。业务日志和请求画像对齐后,可以看到大尺寸 TIFF 输入开始增多,而这批请求恰好走到新引入的 native 库慢路径。

2.5 第四轮确认:用 perf 把热点收敛到调用栈

2.5.1 先看热点符号

 

perf top -p 23111 -g --call-graph dwarf
  32.14%  thumbnail-svc  libimgdecode.so    [.] allocate_decode_buffer
  18.66%  thumbnail-svc  libimgdecode.so    [.] tiff_decode_tiles
  11.42%  thumbnail-svc  libc.so.6          [.] __memmove_avx_unaligned_erms

 

如果热点一直落在 malloc 或业务解码函数附近,就说明不是单纯回收慢,而是业务线程确实在持续申请和处理大块缓冲。接下来要做的是把这条栈采下来,给研发和回滚决策提供证据。

2.5.2 录一段能复现的调用栈

 

perf record -F 99 -g -p 23111 -- sleep 30
perf report --stdio | head -80
# Overhead  Command         Shared Object       Symbol
# ........  ..............  ..................  .............................
   31.22%   thumbnail-svc   libimgdecode.so    allocate_decode_buffer
    8.11%   thumbnail-svc   libimgdecode.so    expand_tile_cache
    7.98%   thumbnail-svc   libimgdecode.so    decode_tiff_frame

 

2.5.3 结合版本差异锁定回归点

 

strings /usr/lib/libimgdecode.so | grep -E 'version|commit' | head
rpm -qa | grep imgdecode
libimgdecode version 2.4.19 commit 9b7c1ea

 

这一步不是为了做研发调试,而是为了把“问题是不是跟发布强相关”固定住。现场回滚是否值得,取决于你能不能把版本、时间、栈热点、请求画像四条线对齐。

2.6 根因分析:为什么不是 page cache、不是共享页、不是语言运行时

2.6.1 根因矩阵

假设 证据支持情况 为什么排除 / 保留
page cache 挤占内存 buff/cache  稳定,available 持续降,不成立
slab 或 tmpfs 异常 slabtop  和 /dev/shm 无异常,不成立
共享库映射看起来大 USS/PSS  同步上涨,Shared_Clean 不是主因,不成立
Java/Go 运行时保留堆未归还 业务不是托管语言主进程,且匿名段与 native 解码栈一致,基本排除
native 图像解码库缓冲未释放 smaps  匿名私有页大、perf 热点在解码库、版本变更一致,成立

2.6.2 根因结论

最终根因是新版 libimgdecode.so 在 TIFF 分块解码的异常路径下,申请的中间缓冲在错误分支没有及时释放。正常流量下不明显,晚高峰大图请求密集时,每个 worker 都会把匿名私有页向上顶,容器先被打到 limits,节点再进入内存回收,导致整机业务 RT 抖动。

2.6.3 为什么 cgroup OOM 和节点压力会一起出现

现象 解释
先出现 OOMKilled 单 Pod 先撞上容器 limit
节点 MemAvailable 继续降 同节点多副本在一起涨
最后出现内核 OOM 迹象 节点总体回收空间也被吃掉

这类场景容易让人误判成“只是单 Pod limit 太小”。实际上 limit 只是第一道保险,真正的问题是多个实例在同一节点同步持有异常匿名页。只调大单 Pod limit,会把问题从容器层推到节点层。

2.7 修复动作与止血顺序

2.7.1 当班止血动作

顺序 动作 原因
1 暂停灰度并回滚到旧镜像 先止住新增泄漏源
2 降低大图接口并发阈值 避免回滚期间继续放大
3 对最重节点做有序摘流和滚动替换 避免整节点一起抖
4 保留至少一个故障实例做取证 保障根因能闭环

2.7.2 回滚与限流命令

 

kubectl rollout undo deployment/thumbnail-svc -n media
kubectl scale deployment/thumbnail-svc -n media --replicas=6
kubectl cordon gpu-node-03
kubectl drain gpu-node-03 --ignore-daemonsets --delete-emptydir-data

 

2.7.3 热修复前的风险边界

 

resources:
  requests:
    cpu:"2"
    memory:"4Gi"
limits:
    cpu:"4"
    memory:"6Gi"
env:
-name:MAX_TIFF_PIXELS
    value:"120000000"
-name:DECODE_WORKERS
    value:"4"

 

上面的配置不是最终修复,而是当班期间的风险边界:缩小可接受的大图尺寸,压低解码并发,先让业务稳定。

2.8 回归验证:验证要回到证据链,而不是只看服务恢复

2.8.1 回滚后重复同样的采样

 

free -h
smem -P thumbnail-svc -c "pid pss uss rss"
pmap -x $(pgrep -f thumbnail-svc | Select-Object -First 1) | tail -20

 

2.8.2 压测回放异常输入

 

curl -s -o /dev/null -w '%{http_code} %{time_total}
' 
  -X POST http://thumbnail.media.svc/resize 
  -H 'Content-Type: application/json' 
  -d '{"url":"http://sample/big.tiff","width":512,"height":512}'
200 0.842

 

2.8.3 验收标准

检查项 验收标准
MemAvailable 1 小时内无持续下降趋势
USS/PSS 在压力下有波动但不单向累积
OOMKilled 归零
业务 RT 恢复到发布前 10% 波动内
perf  热点 不再被解码库分配函数长期占据

2.8.4 回归验证周期不要只看 10 分钟

时间窗口 要看什么 目的
10-15 分钟 是否立刻再次 OOM 验证止血是否有效
1 小时 USS/PSS  是否继续累积 验证慢泄漏是否仍在
1 个高峰周期 大图和异常输入回来后是否复发 验证修复不是侥幸

很多泄漏问题在低流量窗口里看不出来。复盘里如果不把“高峰期再验证”写进去,值班手册就只有止血,没有闭环。

三、示例代码和配置

3.1 现场采样命令清单

 

date
hostname
uptime
free -h
vmstat 1 5
sar -r 1 5
smem -rs pss -k | head -20
journalctl -k --since '30 min ago' | tail -200

 

3.2 脚本一:mem_scene_collect.sh

 

#!/usr/bin/env bash

# 文件名:mem_scene_collect.sh
# 作用:一次性采集整机与进程内存现场信息
# 适用场景:节点内存告警、容器被 OOMKilled 前后的应急取证
# 使用方式:bash mem_scene_collect.sh  
# 输入参数:目标进程 PID、输出目录
# 输出结果:生成 free/vmstat/smaps/pmap/journalctl 等快照
# 风险提示:会读取 /proc 和系统日志,perf 未包含在内,默认低风险
# 结果解读:优先看 summary.txt 中的 available、uss、anonymous 汇总

set -euo pipefail

pid="${1:?pid required}"
out="${2:-/tmp/mem-scene-$(date +%F-%H%M%S)}"
mkdir -p "$out"

{
echo"time=$(date '+%F %T')"
echo"host=$(hostname)"
echo"pid=$pid"
} > "$out/summary.txt"

free -h > "$out/free.txt"
vmstat 1 5 > "$out/vmstat.txt"
cat /proc/pressure/memory > "$out/memory.psi.txt"
smem -P "$(tr '�' ' ' < /proc/$pid/cmdline | awk '{print $1}')" -c "pid pss uss rss command" > "$out/smem.txt"
pmap -x "$pid" > "$out/pmap.txt"
cp "/proc/$pid/smaps""$out/smaps.txt"
journalctl -k --since '30 min ago' > "$out/kernel.log"

 

3.3 脚本二:smaps_rollup.py

 

#!/usr/bin/env python3

# 文件名:smaps_rollup.py
# 作用:聚合 smaps 关键字段,快速判断匿名页和私有脏页占比
# 适用场景:pmap 看出匿名段异常后做二次汇总
# 使用方式:python smaps_rollup.py /proc//smaps
# 输入参数:smaps 文件路径
# 输出结果:JSON 汇总结果
# 风险提示:读取大进程 smaps 文件时会有少量 IO 开销
# 结果解读:Anonymous、Private_Dirty 高且持续上涨时优先看 native 分配路径

import json
import re
import sys

keys = {"Size": 0, "Rss": 0, "Pss": 0, "Private_Dirty": 0, "Anonymous": 0}
pattern = re.compile(r"^(Size|Rss|Pss|Private_Dirty|Anonymous):s+(d+)s+kB$")

with open(sys.argv[1], "r", encoding="utf-8") as f:
    for line in f:
        m = pattern.match(line.strip())
        if m:
            keys[m.group(1)] += int(m.group(2))

print(json.dumps(keys, ensure_ascii=False, indent=2))

 

3.4 脚本三:pmap_growth_diff.sh

 

#!/usr/bin/env bash

# 文件名:pmap_growth_diff.sh
# 作用:比较两次 pmap 快照的差异,定位增长最快的映射段
# 适用场景:进程内存持续上涨但不确定涨在哪类段
# 使用方式:bash pmap_growth_diff.sh before.txt after.txt
# 输入参数:两次 pmap -x 输出文件
# 输出结果:按增长量排序的映射段
# 风险提示:依赖 pmap 输出格式,系统版本差异需先验证
# 结果解读:若 [ anon ] 段增长显著,优先排查堆与 native buffer

set -euo pipefail

before="${1:?before required}"
after="${2:?after required}"

awk '
FNR==NR && $1 ~ /^0/ {a[$1]=$3" "$6; next}
FNR!=NR && $1 ~ /^0/ {
  split(a[$1], old, " ")
  diff=$3-old[1]
  if (diff != 0) printf "%10d KB  %-8s  %s
", diff, $1, $6
}
'"$before""$after" | sort -nr | head -30

 

3.5 脚本四:oom_evidence_pack.sh

 

#!/usr/bin/env bash

# 文件名:oom_evidence_pack.sh
# 作用:打包 OOM 相关系统日志、容器事件和 cgroup 指标
# 适用场景:容器或节点刚发生 OOM,需要保留证据
# 使用方式:bash oom_evidence_pack.sh   
# 输入参数:命名空间、Pod 名称、输出目录
# 输出结果:生成事件、describe、dmesg、memory.current 等文件
# 风险提示:依赖 kubectl 与节点权限;事件窗口过久时会丢失历史
# 结果解读:若 cgroup 接近 limit 且 kernel log 出现 killed process,说明限制已真正触发

set -euo pipefail

ns="${1:?namespace required}"
pod="${2:?pod required}"
out="${3:-/tmp/oom-pack-$(date +%F-%H%M%S)}"
mkdir -p "$out"

kubectl describe pod "$pod" -n "$ns" > "$out/pod.describe.txt"
kubectl get events -n "$ns" --sort-by=.lastTimestamp > "$out/events.txt"
journalctl -k --since '1 hour ago' > "$out/kernel.log"
dmesg -T | tail -300 > "$out/dmesg.tail.txt"

 

3.6 业务日志样例

 

2026-03-07 1912 level=warn req_id=8f3a path=/resize format=tiff width=17834 height=12920 msg="decode image slow path"
2026-03-07 1912 level=warn req_id=8f3a msg="native buffer growth" alloc_bytes=268435456
2026-03-07 1913 level=error req_id=8f3a msg="decoder fallback retry"

 

3.7 Prometheus 告警规则样例

 

groups:
  -name:node-memory-risk
    rules:
      -alert:NodeMemAvailableLow
        expr:node_memory_MemAvailable_bytes/node_memory_MemTotal_bytes<0.08
        for:10m
        labels:
          severity:critical
        annotations:
          summary:"节点可用内存低于 8%"

      -alert:PodOOMKilledBurst
        expr:increase(kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}[15m])>2
        for:5m
        labels:
          severity:warning

 

3.8 回归后的预期输出样例

 

MemAvailable: stable around 17.2GiB-18.1GiB for 60m
thumbnail-svc uss: fluctuate within 3.2GiB-3.4GiB, no monotonic growth
OOMKilled: 0

 

3.9 cgroup 快照脚本:cgroup_mem_snapshot.sh

 

#!/usr/bin/env bash

# 文件名:cgroup_mem_snapshot.sh
# 作用:采集容器 cgroup 内存限制、当前使用量和事件计数
# 适用场景:需要区分 Pod limit 命中还是节点整体内存失血
# 使用方式:bash cgroup_mem_snapshot.sh 
# 输入参数:容器主进程 PID
# 输出结果:输出 memory.current、memory.max、memory.events
# 风险提示:只读采集,低风险
# 结果解读:若 current 长期贴近 max,先从容器限制和业务峰值入手

set -euo pipefail
pid="${1:?pid required}"
root="/proc/$pid/root/sys/fs/cgroup"
find "$root" -maxdepth 2 -type f ( -name memory.current -o -name memory.max -o -name memory.events ) -print -exec cat {} ;

 

3.10 perf 火焰图准备脚本:perf_capture_prepare.sh

 

#!/usr/bin/env bash

# 文件名:perf_capture_prepare.sh
# 作用:短时间采集 perf record 数据,供后续火焰图或 report 分析
# 适用场景:已经锁定到单进程,需要留调用栈证据
# 使用方式:bash perf_capture_prepare.sh  
# 输入参数:目标 PID、采样时长
# 输出结果:生成 perf.data
# 风险提示:生产环境控制采样时长;高峰期避免长时间采样
# 结果解读:分配热点持续落在同一库和函数上时,更接近真泄漏

set -euo pipefail
pid="${1:?pid required}"
secs="${2:-20}"
perf record -F 99 -g -p "$pid" -- sleep "$secs"

 

四、最佳实践和注意事项

4.1 最佳实践

场景 建议做法 原因
节点内存告警 先做整机 + 进程双快照 先分系统级和进程级问题
混部节点 保留一个问题实例取证,其余实例先摘流 兼顾稳定与定位
容器环境 先对齐容器 PID 与宿主机 PID 避免抓错对象
引入 native 库 上线前做异常输入压测 很多泄漏只在错误路径触发
线上验证 压测回放要覆盖问题输入 只跑健康样本没有意义

4.2 注意事项

RSS 大不等于真实物理占用大,先看 PSS/USS。

看到 buff/cache 高不要立刻说“都能回收”,要看 available 和回收压力。

perf 在生产环境要控制采样时长和频率,避免长时间高开销。

如果节点启用了 cgroup v2,采集时记得同时看 memory.current、memory.events。

4.2.1 容器场景的几个放大器

放大器 表现 风险
limits.memory  贴边 容器先被杀 还没看清根因实例就没了
多副本同节点 内存曲线一起涨 节点比单 Pod 更早告警
sidecar 混部 Pod 内总内存被放大 容易误判为主容器单点问题
无 swap 回收缓冲空间小 OOM 更直接

4.3 实际应用案例

4.3.1 案例一:真泄漏与 page cache 误判

 

free -h
cat /proc/pressure/memory

 

如果 available 稳定、buff/cache 高,且 psi 不升,这更像缓存,不像泄漏。真正泄漏的现场往往是 available 单向下降,psi memory 也同步抬高。

4.3.2 案例二:共享页放大导致 RSS 看起来很吓人

 

smem -P thumbnail-svc -c "pid pss uss rss"

 

多个副本共享同一套库映射时,RSS 都会显得很大,但 USS 不一定大。这个案例是线上最常见的“看着像泄漏,实际不是”的误判源。

4.3.3 案例三:native 库慢路径造成匿名私有页持续增长

 

pmap -x  | grep anon | tail -20
perf top -p  -g

 

只要 [ anon ] 和 Private_Dirty 持续涨,再叠加 native 函数热点,方向就可以稳定收敛到用户态缓冲释放问题。

4.4 值班交接要点

 

1. 当前是否已回滚到旧镜像
2. 是否保留了一台问题实例做 perf/smaps 取证
3. 当前 MemAvailable 曲线是否稳定
4. 大图流量是否已限流
5. 开发侧是否已拿到热点栈和问题样本

 

4.5 容易误判的现场

误判 实际更像什么 正确动作
RSS  最大的进程就是泄漏点 可能是共享页放大 先看 PSS/USS
buff/cache  高就一定不是问题 还要看 available 和回收压力 补 psi 与 vmstat
容器先 OOM 就只是 limit 小 可能是多副本同向增长 同时看节点面
进程一重启内存降了就算修好了 只是掩盖现象 继续回归验证

五、故障排查和监控

5.1 快速排查清单

 

free -h
cat /proc/pressure/memory
smem -rs pss -k | head -20
pmap -x  | tail -20
journalctl -k --since '20 min ago' | tail -100

 

5.2 监控指标建议

层次 关键指标 告警建议
节点 MemAvailable 、psi memory、major page fault 连续 10 分钟恶化才升级
Pod working_set 、OOMKilled、restart count 15 分钟内连续重启即告警
进程 USS/PSS 、匿名私有页 建议做周期采样而不是只看容器内存
业务 P99 RT 、大图请求占比 与内存曲线做对齐

5.3 常见分支与排除方法

现象 第一怀疑对象 快速排除方法
used  高但 available 稳 page cache 看 buff/cache 与 psi
RSS  高但 USS 不高 共享页或映射文件 看 PSS/Shared_Clean
容器 OOM 但节点没压力 limit 太小 看 cgroup memory.current
节点和容器都抖 多副本同向增长 看节点上同类 Pod 分布

5.4 回归验证动作

 

kubectl top pod -n media
kubectl get pods -n media -o wide | grep thumbnail-svc
for pid in $(pgrep -f thumbnail-svc); do
  python smaps_rollup.py /proc/$pid/smaps
done

 

5.5 监控面板建议

 

面板一:节点 MemAvailable / PSI / OOM 数
面板二:Pod WorkingSet / RestartCount / OOMKilled
面板三:业务 RT / 请求量 / 大图比例
面板四:进程 USS/PSS 周期采样

 

5.6 阈值与告警建议

指标 建议阈值 说明
MemAvailable / MemTotal 持续低于 8%-10% 节点级风险
psi memory some/full 连续 5-10 分钟抬高 回收压力已影响调度
OOMKilled 15 分钟内 > 0 容器限制已经击穿
USS  增长斜率 连续多个采样窗口单向增长 慢泄漏高价值信号

六、总结

6.1 技术要点回顾

free 先判整机是不是持续失血,不负责给进程定责。

smem 负责把共享页和私有页拆开,避免被 RSS 误导。

pmap / smaps 负责回答“到底是哪类映射在涨”。

perf 负责把增长路径收敛到调用栈,给回滚和修复提供直接证据。

6.2 值班动作优先级

先留证据,再重启实例。

先分 page cache、共享页、匿名私有页,再谈泄漏。

先做止血回滚,再做代码级修复。

回归验证一定回到同一条证据链,不要只看告警消失。

6.3 参考资料

man free

man smem

man pmap

man perf-record

Linux /proc//smaps 字段说明

附录

A. 命令速查表

目标 命令
看整机内存 free -h
看内存压力 cat /proc/pressure/memory
看进程真实占用 smem -rs pss -k
看映射类型 pmap -x
看内核 OOM journalctl -k
看热点栈 perf top -p -g

B. 根因矩阵

根因类型 典型信号 推荐动作
page cache 偏高 available  稳、buff/cache 高 观察回收,不要急着重启
cgroup limit 太小 Pod OOM 明显早于节点 调整 limit 并复核应用峰值
native 泄漏 USS 、Anonymous、Private_Dirty 一起涨 回滚版本、抓栈、修代码
共享映射放大 RSS  高、PSS 不高 不要按 RSS 甩锅

C. 术语表

术语 含义
RSS Resident Set Size,进程驻留物理页总量
PSS Proportional Set Size,共享页按比例摊分后的实际占用
USS Unique Set Size,进程独占的物理页
Anonymous 匿名映射,常见于堆、栈、动态申请缓冲
Private_Dirty 私有且被写脏的页,常能反映真实持有的可疑内存

D. 值班复盘清单

 

1. 是否保留了问题实例和问题样本
2. 是否采集了 free/vmstat/smem/pmap/perf 五类证据
3. 是否将版本、时间线、监控、日志对齐
4. 是否验证回滚后 USS/PSS 不再单向上涨
5. 是否补充了大图异常输入的回归压测

 

E. 证据链顺序速查

顺序 工具 / 证据 目的
1 free  / vmstat / psi 判断是不是整机失血
2 smem 定位真大户
3 pmap  / smaps 判断是哪类映射在涨
4 业务日志 / 请求画像 对齐触发条件
5 perf  / 版本信息 把根因收敛到具体路径

F. 现场输出解读样例

 

场景一:available 下降 + uss 上升 + anonymous 上升
结论:更像真泄漏或 native 缓冲未释放

场景二:rss 高 + pss 不高 + shared_clean 高
结论:更像共享页放大,不宜直接按 RSS 定责

场景三:pod 先 OOM + 节点 available 也在掉
结论:不是单 Pod limit 问题,需同步看节点分布

 

G. 交接模板

 

故障开始时间:
影响的节点 / Pod:
当前是否已回滚:
当前是否保留了故障实例:
已采集证据:
当前未完成动作:
下个高峰前需要完成的验证:

 

H. 指标阈值速查

指标 观察方式 触发动作
MemAvailable 连续下滑且不回升 先抓整机快照
USS 多轮采样持续上涨 继续看 smaps
Private_Dirty 与 Anonymous 同向上涨 重点看 native 分配路径
OOMKilled 15 分钟内出现 先查 cgroup 与 Pod 分布

I. 输出样例库

 

free -h 正常样例:
Mem: 62Gi total, 24Gi used, 28Gi free, 9Gi buff/cache, 35Gi available
说明:used 看着不低,但 available 很充足,不像泄漏

free -h 可疑样例:
Mem: 62Gi total, 57Gi used, 1.8Gi free, 3.4Gi buff/cache, 2.2Gi available
说明:available 过低,且 cache 不高,更像真实失血
smem 正常样例:
PID   PSS   USS   RSS
2311  520M  410M  910M
说明:有共享页,但私有页不夸张

smem 可疑样例:
PID   PSS   USS   RSS
2311  4.3G  4.1G  5.2G
说明:私有页极高,更像真泄漏或 native buffer 问题

 

J. 信号与动作对照表

信号组合 更像什么 第一动作 第二动作
available  下降 + USS 上升 真泄漏 抓 smaps 抓 perf
RSS  高 + PSS 不高 共享页放大 不要按 RSS 定责 查共享映射
Pod 先 OOM + 节点也抖 多副本同向增长 看节点分布 做摘流与回滚
tmpfs 高 共享内存问题 查 /dev/shm 查业务使用模式

K. 取证顺序模板

 

1. 先抓整机快照
2. 再抓进程内存分布
3. 再抓映射类型
4. 再抓调用栈
5. 最后回到版本、日志和请求样本

 

L. smaps 字段解读速查

字段 代表什么 值班意义
Rss 驻留物理页 不能单独定责
Pss 按共享比例摊分后的实际占用 更适合做跨进程比较
Private_Dirty 私有脏页 真正可疑的私有写内存
Anonymous 匿名映射 常见于堆和大缓冲

M. 样本保留模板

 

问题图片 URL / 样本:
问题请求时间:
相关 req_id:
问题实例 PID:
是否已保留 perf.data:
是否已保留 smaps / pmap:
 

 

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分