Linux 内存暴涨排查实录:从 free 到 OOM Killer 的六步定位法
一、问题长什么样
上周一台 2 核 2G 的 Flexus L 服务器(Ubuntu 22.04)突然告警:内存使用率 96%,SSH 登录卡顿,docker ps 都要等七八秒。重启后半小时又涨回 90%。很多人第一反应是"加内存"或者"清缓存",但内存问题九成不是内存不够,而是某个进程在稳定地漏。
这篇文章记录一次完整的排查过程,从 free 一路定位到代码里的一个未关闭的 Response 对象,全程只用系统自带命令,不装任何第三方工具。
二、第一步:分清"真用了"还是"被缓存吃了"
free -h
# total used free shared buff/cache available
# Mem: 1.9Gi 1.7Gi 95Mi 21Mi 214Mi 116Mi
# Swap: 0B 0B 0B
关键看三列,不是看 free:
used高、buff/cache也高:大概率是页缓存,内核会自动回收,不用管。available才是"还能给新进程多少",它已经扣掉了不可回收部分。这台机器available只剩 116Mi,说明是真紧张。Swap是 0:内存再涨就只能触发 OOM Killer,必须处理。
顺手确认一下有没有被 tmpfs 占掉:
df -h | grep -E "tmpfs|/dev/shm"
# tmpfs 198M 1.2M 197M 1% /run
# tmpfs 951M 8.0M 943M 1% /dev/shm
/dev/shm 只有 8M,排除共享内存泄漏。
三、第二步:按进程排序,找到嫌疑人
ps aux --sort=-rss | head -10
# USER PID %CPU %MEM RSS COMMAND
# app 1842 3.1 41.2 812340 /usr/bin/python3 /srv/app/main.py
# dockerd 913 1.2 6.4 126300 /usr/bin/dockerd
# node 1520 0.8 4.9 96400 /usr/bin/node /srv/web/server.js
一个 python 进程吃了 812MB,占了 41%。到这里还不能下结论,因为"RSS 大"不等于"泄漏",也可能是正常的业务缓存。看它的趋势才有意义:
# 每 10 秒采一次,连续 1 小时写日志
pidstat -r -p 1842 10 360 >> /tmp/app-mem.log 2>&1
# 观察 RSS 是在锯齿波动(正常,有 GC)还是单调上升(泄漏)
awk 'NR%30==1 {print $1,$2,$6}' /tmp/app-mem.log | tail -20
日志显示 RSS 从 210MB 一路爬到 812MB,中间从没回落 —— 教科书式的泄漏。
四、第三步:看清它到底把内存花在哪
Python 进程可以现场问它,不用重启:
# 确认有没有装 tracemalloc / pyrasite 之类的钩子;没有就用 pmap 看内存段
pmap -x 1842 | sort -k3 -n -r | head -8
# Address Kbytes RSS Dirty Mode Mapping
# 000055d2c1a00000 524288 524288 524288 rw--- [ anon ]
# 00007f3a9c000000 98304 98012 0 rw-s- /dev/zero (deleted)
[ anon ] 段 512MB,说明是堆上的 Python 对象,不是 mmap 文件缓存。再看对象分布(需要进程开了 PYTHONMALLOC 或装了 gdb):
gdb -p 1842 -batch -ex "call (void)PyRun_SimpleString(\"import gc;print(len(gc.get_objects()))\")" 2>/dev/null
线上更稳妥的做法是提前埋点,用标准库 tracemalloc 打快照对比:
import tracemalloc, linecache
tracemalloc.start(10) # 保留 10 层调用栈
snap1 = tracemalloc.take_snapshot()
# ...跑 10 分钟业务...
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:10]:
print(stat)
输出里排第一的是 requests/adapters.py:412,增长 480MiB。到这个粒度,问题基本就锁定了:HTTP 客户端在积压响应对象。
顺带说一句,如果嫌疑人不是 Python 而是 Node,思路完全一样,只是工具换成它自己的堆快照:
# 拿到堆快照,再用 Chrome DevTools 打开对比两份 .heapsnapshot
kill -USR2 1520 # 需先设置 NODE_OPTIONS="--heapsnapshot-signal=SIGUSR2"
# 或者用内置 inspector
node --inspect=127.0.0.1:9229 /srv/web/server.js
Node 常见泄漏点比 Python 集中得多:模块级 Map 当缓存、EventEmitter 监听器只增不减(用 process.on('warning') 能看到 MaxListenersExceededWarning)、定时器没 clearInterval。看到 MaxListeners 警告时,八成就是泄漏的开始。
五、第四步:确认根因——连接池没关,响应体没读
翻代码,写的是这样:
import requests
sess = requests.Session()
def fetch(url):
return sess.get(url, timeout=5) # 只取了对象,没读 body,也没 close
requests 默认 stream=False,看起来 body 应该已经读完了。但这里有三个叠加的问题:
Session的HTTPAdapter默认pool_maxsize=10,代码里并发 50 个线程共用它,连接被迫排队;- 每次
get后没有close()也没用with,异常路径上连接不会归还; - 上层把返回的
Response对象存进了一个全局dict做"缓存",键还是 URL 带时间戳,永不淘汰 —— 这才是 RSS 单调上升的真凶。
修法很直接:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
sess = requests.Session()
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=50, max_retries=Retry(total=3))
sess.mount("http://", adapter)
sess.mount("https://", adapter)
def fetch(url):
with sess.get(url, timeout=5, stream=True) as r:
r.raise_for_status()
return r.json() # 读完 body,退出 with 时连接归还
全局缓存换成带过期的 cachetools.TTLCache(maxsize=2000, ttl=300),并显式限制对象大小:
from cachetools import TTLCache
_cache = TTLCache(maxsize=2000, ttl=300) # 最多 2000 条、5 分钟过期
改完重跑,RSS 稳定在 260MB ± 20MB,两小时不涨。
六、第五步:给系统加一道保险
代码修好了,但内存类故障永远会有下一次。两件小事成本很低:
# 1. 加 2G swap,避免瞬时峰值直接 OOM(Flexus L 默认没有 swap)
fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
# 略微降低 swap 倾向,只在真紧张时用
sysctl -w vm.swappiness=10
# 2. 让 OOM 时有据可查:确认内核日志保留 OOM 记录
journalctl -k | grep -i "killed process" | tail -5
# 若为空,检查是否被 logrotate 截断过快
另外给容器加 mem_limit,防止单个容器把宿主机拖死:
docker run -d --name app --memory=768m --memory-swap=1g myapp:1.0
如果服务是 systemd 托管的(非容器),用 cgroup 直接限死,超了只杀这一个服务,不会连累整机:
# /etc/systemd/system/app.service.d/limits.conf
# drop-in 覆盖即可,不用改原 unit
[Service]
MemoryMax=768M
MemoryHigh=640M # 到 High 先被限速回收,到 Max 才 OOM
OOMScoreAdjust=-200 # 让 OOM Killer 优先挑别的进程;sshd 可以设 -900 保命
systemctl daemon-reload && systemctl restart app
systemctl show app -p MemoryCurrent # 实时看当前用量
这里有个容易踩的坑:MemoryMax 设得太小会变成"定时重启",日志里表现为 Main process exited, code=killed, signal=KILL,看起来像程序自己崩了,其实是 cgroup 干的。限流前先按 P99 用量乘 1.5 留余量。
七、第六步:留下可持续的观测
排查一次很累,所以最后把"下次能更快"的东西留下:
# 一行命令看趋势:内存 Top5 + 总量
ps -eo rss,comm --sort=-rss | head -6 | awk '{printf "%-8d %s\n", $1/1024"MB", $2}'
再在监控里加两条告警,比堆十几个指标有用:
available < 10%持续 5 分钟告警(不是used > 90%,因为缓存会让used虚高);oom_kill计数 > 0 立即告警。
八、踩坑与经验
- 别急着清缓存:
echo 3 > /proc/sys/vm/drop_caches只是把页缓存扔了,泄漏的匿名内存一分不会少,还会让磁盘 IO 变差。 used高不等于是问题,available才是决策依据。- RSS 要看的趋势不是绝对值:单点采样极易误判,至少看 10 分钟斜率。
requests一定要with或close():异常路径漏掉的连接会一直占着 pool,表现出来是"内存涨 + 请求变慢"双重症状。- 全局缓存必须有上限:
TTLCache(maxsize=...)比手写dict少写代码还更安全。 - 没有 swap 不代表更安全:没有 swap 只是让 OOM 更早、更突然。
- cgroup 的
MemoryMax会造成"假崩溃":被 cgroup 杀掉的服务日志里只有 SIGKILL,看不出原因,排查时先systemctl show <svc> -p MemoryMax MemoryPeak对一下。 - 别用
%MEM排序下结论:%MEM是按总内存算的百分比,小内存机器上一个 20% 的进程可能只是正常业务,而一个 8% 的进程可能已经泄漏了 200MB。
附:三分钟应急处置清单
线上正在告警、来不及定位根因时,先保住可用性,再谈根治。顺序很重要,别一上来就重启:
# 1. 先留证据:内存地图 + 线程栈 + 内核日志,重启后这些全没了
pmap -x $(pgrep -f main.py | head -1) > /tmp/evidence-pmap.txt
cat /proc/$(pgrep -f main.py | head -1)/status >> /tmp/evidence-pmap.txt
dmesg -T | tail -50 >> /tmp/evidence-pmap.txt
# 2. 再降风险:把流量摘掉,别让新请求继续堆对象
# (nginx 侧标记 down,或直接从 SLB 后端移除)
# 3. 最后才重启,并且只重启出问题的那个服务单元
systemctl restart app
顺序反了最常见的后果是:重启之后内存曲线漂亮了,以为"修好了",第二天再涨一遍,而现场证据已经丢了。留一份 pmap 输出的成本只有几秒钟,价值却很高。
另外一个判断小技巧:如果 RSS 高但 /proc/<pid>/smaps 里 Anonymous 不大、Private_Dirty 也不大,反而 Pss 分散在很多共享段上,那通常不是泄漏,而是内存碎片或者 mmap 文件太多;这种情况调 vm.max_map_count 或改用 jemalloc/tcmalloc 往往比改代码更有效。
整套流程走下来大约 40 分钟,核心就是"先分清缓存与真占用 → 找趋势 → 定位到代码行 → 修根因 → 加保险"。内存问题几乎都能用这套路径收敛,不需要靠猜。
- 点赞
- 收藏
- 关注作者
评论(0)