Bug:容器监控脚本lsof导致cpu打满
现象
背景
集群中有一个 进程资源监控脚本 tk_cpu_mem_monitor.sh,由 cron 每分钟定时执行一次。它的作用是采集指定进程的 CPU、内存、RSS 以及文件描述符数量,输出一条监控记录:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
# 通过进程名匹配 PID(匹配命令行末尾)
PID=`ps aux|grep -E "$1$"|grep -v grep|grep -v "\.sh"|awk '{print $2}'`
# 进程不存在则退出
if [ ! $PID ];then
echo -n `date +'%Y-%m-%d %H:%M:%S'`
echo -e "\t$1 's PID is NIL"
exit
fi
if echo $PID | grep -q '[^0-9]';then
echo -n `date +'%Y-%m-%d %H:%M:%S'`
echo -e "\t$1 's PID is $PID"
fi
# 采集四项指标
cpu=`ps --no-heading --pid=$PID -o pcpu`
mem=`ps --no-heading --pid=$PID -o pmem`
rss=`ps --no-heading --pid=$PID -o rss`
lsof=`lsof -p $PID | wc -l` # ← 问题就出在这一行
#echo -n `date +'%Y-%m-%d %H:%M:%S'`
printf "\t%6s\t%35s\t%s\t%s\t%6s\t%6s\n" $PID $1 $cpu $mem $rss $lsof
问题语句
1
lsof=`lsof -p $PID | wc -l`
这行命令是为了统计目标进程打开了多少个文件描述符。
现象
docker 集群从 30 机房迁移至 11 机房(宿主机从 CentOS 7 换成 Rocky Linux 9)后,脚本执行耗时从正常范围急剧恶化。cron 每分钟触发一次,但单次执行已远超 60 秒,前一个实例还没结束,下一个实例又启动,lsof 进程持续堆积,最终将 CPU 打满。
将问题行替换为直接读 /proc 后恢复立即正常:
1
lsof=`ls /proc/$PID/fd | wc -l`
新旧环境对比:
| 项目 | 旧环境 | 新环境 |
|---|---|---|
| 宿主机 OS | CentOS 7 | Rocky Linux 9 |
| 宿主机内核版本 | 3.10 | 5.14+ |
| 容器镜像 | CentOS 7 | CentOS 7(未变更) |
| 容器内 lsof | 4.87 | 4.87(未变更) |
| 脚本表现 | 正常,秒级完成 | CPU 打满,严重超时 |
原因
根本原因
lsof 4.87 为了实现 “fd 关联信息解析” 这个功能,设计上必须全量遍历系统所有进程的 /proc 目录;内核从 3.10 升级到 5.14 后,单个进程的 /proc 条目数量暴涨百倍,导致 lsof 单次遍历的系统调用开销呈数量级上升,再叠加 cron 定时触发的进程堆积,最终 CPU 被打满。
直接原因
lsof -p $PID 的扫描范围远不止目标进程:
- 遍历
/proc/*/fd/中全部进程的文件描述符(用于关联 pipe/socket 对端) - 读取
/proc/net/tcp、/proc/net/udp等网络状态文件 - 对每个 fd 条目执行
stat()和readlink()
内核 3.10 的 /proc 条目较少,lsof 一次调用几十毫秒完成。内核 5.x 新增了大量 /proc 条目,如 fdinfo/、map_files/、cgroup v2 层级等,文件数量增加了百倍。
旧版 lsof 会遍历并尝试解析这些不认识的条目,部分路径处理不当导致耗时从毫秒级恶化到秒级。在高连接数的消息推送 broker 场景下问题被进一步放大。
监控脚本由 cron 定时触发。当单次执行时间超过调度间隔时,前一个实例未结束、下一个实例已启动,形成进程堆积,CPU 迅速打满。
排查过程
以下是还原后的排查过程,由外到内逐层定位根因。
第一步:定位消耗 CPU 的进程
1
2
top -c
# 或使用 htop
进入 top 后按 P(大写)按 CPU 使用率降序排列,观察到大量 lsof 进程或监控脚本的多个实例占据 CPU 前列。
这一步定位到了命令,但还不确定是哪个语句导致的。
第二步:确认脚本内的阻塞命令
1
2
3
4
5
# 查看监控脚本进程的子进程树
pstree -p <脚本PID>
# 或使用 ps 查看父子进程关系
ps --forest -eo pid,ppid,cmd | grep -A 10 <脚本PID>
输出显示脚本进程下面挂着一排 lsof -p <目标PID> 子进程,且多个实例同时存在(进程堆积),直接指向脚本中 lsof 那一行是阻塞点。
第三步:手动执行并计时对比
让系统部提供两个干净的内网测试环境机器,进行测试对比:
1
2
3
4
5
# 先计时跑 lsof 方式
time lsof -p <目标PID> | wc -l
# 再计时跑 /proc 方式
time ls /proc/<目标PID>/fd | wc -l
典型输出对比:
1
2
3
4
5
# lsof 方式
real 0m4.523s ← 数秒级别
# /proc 方式
real 0m0.003s ← 毫秒级别
两者差距达到三个数量级(秒 vs 毫秒)。这一步锁定了 lsof 是瓶颈,并且确认了替代方案可行。
第四步:新旧环境对比,排除变量
在旧环境(CentOS 7 内核 3.10)上同样执行:
1
2
time lsof -p <目标PID> | wc -l
# 旧环境输出: real 0m0.045s ← 几十毫秒,正常
对比排除表:
| 变量 | 是否变化 | 排除依据 |
|---|---|---|
| 脚本代码 | 未变 | 新旧环境相同脚本 |
| 容器镜像 | 未变 | 均为 CentOS 7 |
| 目标进程(同一应用) | 未变 | fd 数量相近 |
/proc 方式 |
正常 | 新旧环境都快 |
lsof 方式 |
新环境慢,旧环境快 | 唯一差异 |
排除代码、镜像、数据量三个变量后,问题指向 “同样的二进制在不同的运行环境行为不同”,也就是容器下方的宿主机内核。
第五步:strace 追踪系统调用(关键步骤)
这是整个排查中最关键的一步,把 lsof 内部到底在做什么完全暴露出来。
1
2
# 统计系统调用次数与耗时分布
strace -c lsof -p <目标PID>
典型输出示意:
1
2
3
4
5
6
7
8
% time seconds usecs/call calls syscall
------ --------- ---------- -------- ----------------
99.50 4.520000 45200 100 read
0.25 0.011500 11 1050 openat
0.15 0.006800 34 200 stat
0.05 0.002300 4 550 readlink
0.05 0.002300 23 100 close
...
关注点:
calls列的总数:如果累计达到数千甚至数万次系统调用,说明lsof不是在读一个进程的 fd(一个进程通常只有几百个 fd),而是在遍历全系统的进程read耗时占比 > 99%:说明阻塞在读取数据上,与反复读取大量/proc条目的行为一致openat和stat调用量异常高:说明lsof在扫描/proc/*/fd/下的每个条目,包括新内核才有的fdinfo/、map_files/等
进一步精确定位读的是哪些文件:
1
2
# 追踪具体的文件访问
strace -e trace=openat,stat,read lsof -p <目标PID> 2>&1 | head -200
输出会暴露 lsof 的遍历路径:
1
2
3
4
5
6
7
8
openat(AT_FDCWD, "/proc/1/fd/0", O_RDONLY) = ...
openat(AT_FDCWD, "/proc/1/fd/1", O_RDONLY) = ...
...
openat(AT_FDCWD, "/proc/2/fd/0", O_RDONLY) = ... ← 进程2
...
openat(AT_FDCWD, "/proc/100/fd/0", O_RDONLY) = ... ← 进程100
openat(AT_FDCWD, "/proc/100/fdinfo/0", O_RDONLY) = ... ← 新内核才有的 fdinfo!
...
可以清晰看到:
lsof在遍历全部进程的 fd 目录,而非仅目标进程- 尝试访问
/proc/X/fdinfo/等新内核专有目录(这里的文件量非常多) - 对每个不理解的条目反复
stat和read
第六步:确认内核差异
1
2
3
4
# 宿主机内核版本
uname -r
# 新环境: 5.14.0-xxx(Rocky 9)
# 旧环境: 3.10.0-xxx(CentOS 7)
确认新内核 5.x 的 /proc 结构变化:
1
2
3
4
5
6
7
8
# 查看单个进程的 /proc 条目数量
ls /proc/<PID>/ | wc -l
# 新环境: 条目更多(有 fdinfo、map_files 等)
# 旧环境: 条目更少
# 检查新内核特有的目录
ls /proc/<PID>/fdinfo/ 2>/dev/null && echo "5.x 内核特有" || echo "3.10 内核无此项"
ls /proc/<PID>/map_files/ 2>/dev/null && echo "5.x 内核特有" || echo "3.10 内核无此项"
第七步:确认修复有效
1
2
3
4
5
6
7
# 将脚本中的 lsof 替换为 /proc 方式后
# 重新跑 time 确认耗时恢复正常
time ls /proc/<目标PID>/fd | wc -l
# 预期: < 10ms
# 持续观察 top,确认 CPU 不再被打满
top -c -d 5
排查链路图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
top -c 发现 CPU 打满
│
▼
pstree 确认是脚本内的 lsof 子进程堆积
│
▼
time 手动跑 lsof vs /proc,锁定 lsof 是瓶颈
│
▼
新旧环境对比 time lsof,排除代码/镜像/数据量
│
▼
strace -c 发现 lsof 做了数万次系统调用(在扫全系统,不是读单个进程)
│
▼
strace -e trace=openat 确认遍历路径涉及新内核专有目录
│
▼
uname -r 确认内核从 3.10 → 5.14,结构变化致旧 lsof 性能退化
│
▼
替换为 /proc 方式,验证修复有效
其中 strace -c 是最关键的一步:它将 lsof 的系统调用次数和耗时分布完全透明化,直接暴露了 “扫描全量 /proc 而非仅读目标进程” 这一核心事实。
排查用到的工具速查
| 工具 | 用途 | 关键参数 |
|---|---|---|
top -c |
查看进程 CPU/内存占用,定位高消耗进程 | -c 显示完整命令行 |
pstree |
查看进程父子关系树 | -p 显示 PID |
ps --forest |
树状显示进程关系 | --forest -eo pid,ppid,cmd |
time |
测量命令执行耗时 | 直接前置即可 |
strace -c |
统计系统调用的次数和耗时分布 | -c 汇总模式 |
strace -e |
追踪指定类型的系统调用 | -e trace=openat,stat,read |
uname -r |
查看内核版本号 | -r 仅显示内核 release |
ls /proc/<PID>/fd |
列出进程的文件描述符 | 内核直接返回,O(1) 操作 |
修复方案
将脚本中的文件描述符统计方式从 lsof 改为直接读取 /proc/$PID/fd:
| 项目 | 修复前 | 修复后 |
|---|---|---|
| 命令 | lsof -p $PID \| wc -l |
ls /proc/$PID/fd \| wc -l |
| 扫描范围 | 全部进程 fd + 网络状态 | 仅目标进程 fd 目录 |
| 外部依赖 | 需要安装 lsof |
无,纯内核接口 |
| 执行时间 | 秒级(新内核上退化) | < 10ms |
| 跨发行版兼容性 | Alpine / Distroless 不可用 | 所有 Linux 通用 |
修复后的代码行:
1
lsof_count=`ls /proc/$PID/fd | wc -l`
思考分析
为什么镜像没变但行为变了
容器镜像自包含用户态的所有内容(二进制、库、配置),但 Linux 容器没有自己的内核,所有容器共享宿主机内核。/proc 是内核直接暴露的虚拟文件系统,其结构和内容由宿主机内核版本决定,与容器内安装了什么无关。
本次迁移只换了物理机(内核随之升级),镜像未重新构建,因此出现了 “镜像不变、行为剧变” 的现象。
为什么 lsof 要在新内核上扫描更多内容
lsof 的设计目标是列出与进程关联的 “所有打开的文件”,这包括:
- 普通文件
- 管道(pipe),需要找到对端进程
- Unix domain socket,需要找到对端进程
- 网络 socket,需要解析协议状态
其中管道和 Unix socket 的关联查找迫使 lsof 必须遍历全部进程的 fd 目录。内核 5.x 下进程数量未变,但每个进程的 /proc 条目显著增多,扫描总量成倍增长。
为什么不用 lsof 的其他选项
lsof 提供了一些限定选项(如 -a、-d),但都无法从根本上避免跨进程扫描,因为 socket/pipe 对端关联是其核心功能的一部分,无法通过参数关闭。
教训
- 容器中应优先使用
/proc和/sys直接读取,避免依赖重型诊断工具(lsof、strace、tcpdump等),这些工具在精简镜像中可能不可用,在不同内核版本上可能有性能差异 - 编写容器监控脚本时,遵循 “内核接口优先” 原则:能读
/proc就不调外部命令 - 宿主机内核升级时应关注容器内关键工具的行为变化,尤其是直接与内核接口交互的工具
知识拓展
容器与宿主机的边界
1
2
3
4
5
6
7
8
9
10
11
12
┌──────────────────────────────────────────┐
│ 宿主机内核 (Rocky 9 → 5.14) │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ 容器 A │ │ 容器 B │ │
│ │ 镜像: CentOS 7 │ │ 镜像: Alpine │ │
│ │ glibc 2.17 │ │ musl 1.2 │ │
│ │ lsof 4.87 │ │ 无 lsof │ │
│ └────────────────┘ └────────────────┘ │
│ ┌────────────────────────────────────┐ │
│ │ /proc (由内核提供,不属于任何镜像) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
| 来源 | 示例 | 特性 |
|---|---|---|
| 镜像自含 | 二进制文件、C 库、Shell、配置文件 | 容器间隔离,不受宿主机影响 |
| 宿主机内核 | /proc、/sys、系统调用、网络协议栈 |
所有容器共享,版本由宿主机决定 |
常见基础镜像对比
| 镜像 | C 库 | 核心工具 | 大小 | 适用场景 |
|---|---|---|---|---|
| Alpine | musl | BusyBox | ~5 MB | 生产服务,追求体积小 |
| CentOS 7 | glibc 2.17 | GNU 全套 | ~200 MB | 传统企业应用 |
| Debian slim | glibc | GNU 精简 | ~80 MB | 通用,与 Ubuntu 生态兼容 |
| Distroless | glibc | 无 shell | ~2-20 MB | 最高安全性,只包含应用自身 |
/proc 常用监控项速查
| 路径 | 用途 |
|---|---|
/proc/$PID/fd/ |
进程打开的文件描述符数量 |
/proc/$PID/status |
进程状态、内存、线程数 |
/proc/$PID/stat |
CPU 时间、优先级 |
/proc/$PID/io |
磁盘 I/O 统计 |
/proc/$PID/net/dev |
网络流量统计 |
/proc/meminfo |
系统内存概况 |
/proc/loadavg |
系统负载 |
专业词解释
| 术语 | 解释 |
|---|---|
| 内核(Kernel) | 操作系统的核心,管理硬件、进程、内存、文件系统。Linux 容器没有独立内核,共享宿主机内核 |
| /proc | Linux 内核暴露的虚拟文件系统,提供进程和系统信息的运行时接口。内容是动态生成的,不占用磁盘 |
| 文件描述符(fd) | 内核用于标识已打开文件的整数句柄。每个进程打开的文件、socket、管道都会分配一个 fd 编号 |
| lsof | LiSt Open Files,列出系统中已打开文件的诊断工具。需遍历 /proc 并解析多种内核数据结构 |
| 管道(pipe) | 进程间通信机制,一个进程写入、另一个进程读取,通过 fd 操作 |
| Unix Domain Socket | 本机进程间通信的 socket 类型,比 TCP 回环更高效 |
| cgroup | Linux 内核的资源限制与隔离机制,容器技术的基础组件。v1(CentOS 7 使用)和 v2(Rocky 9 使用)结构差异大 |
| glibc | GNU C Library,最主流的 Linux C 标准库实现,几乎全部商业 Linux 发行版使用 |
| musl | 轻量级 C 标准库实现,Alpine Linux 使用,体积小但部分行为与 glibc 不同 |
| PID namespace | Linux 命名空间的一种,使容器内进程看到独立的 PID 编号,是实现容器隔离的基础技术之一 |