Linux 内存暴涨排查实录:从 free 到 OOM Killer 的六步定位法

举报
yd_238266496 发表于 2026/11/01 10:57:24 2026/11/01
【摘要】 一、问题长什么样上周一台 2 核 2G 的 Flexus L 服务器(Ubuntu 22.04)突然告警:内存使用率 96%,SSH 登录卡顿,docker ps 都要等七八秒。重启后半小时又涨回 90%。很多人第一反应是"加内存"或者"清缓存",但内存问题九成不是内存不够,而是某个进程在稳定地漏。这篇文章记录一次完整的排查过程,从 free 一路定位到代码里的一个未关闭的 Respons...

一、问题长什么样

上周一台 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 应该已经读完了。但这里有三个叠加的问题:

  1. Session 的 HTTPAdapter 默认 pool_maxsize=10,代码里并发 50 个线程共用它,连接被迫排队;
  2. 每次 get 后没有 close() 也没用 with,异常路径上连接不会归还;
  3. 上层把返回的 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 立即告警。

八、踩坑与经验

  1. 别急着清缓存:echo 3 > /proc/sys/vm/drop_caches 只是把页缓存扔了,泄漏的匿名内存一分不会少,还会让磁盘 IO 变差。
  2. used 高不等于是问题,available 才是决策依据。
  3. RSS 要看的趋势不是绝对值:单点采样极易误判,至少看 10 分钟斜率。
  4. requests 一定要 with 或 close():异常路径漏掉的连接会一直占着 pool,表现出来是"内存涨 + 请求变慢"双重症状。
  5. 全局缓存必须有上限:TTLCache(maxsize=...) 比手写 dict 少写代码还更安全。
  6. 没有 swap 不代表更安全:没有 swap 只是让 OOM 更早、更突然。
  7. cgroup 的 MemoryMax 会造成"假崩溃":被 cgroup 杀掉的服务日志里只有 SIGKILL,看不出原因,排查时先 systemctl show <svc> -p MemoryMax MemoryPeak 对一下。
  8. 别用 %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 分钟,核心就是"先分清缓存与真占用 → 找趋势 → 定位到代码行 → 修根因 → 加保险"。内存问题几乎都能用这套路径收敛,不需要靠猜。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。