文章

Bug:容器监控脚本lsof导致cpu打满

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 条目的行为一致
  • openatstat 调用量异常高:说明 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!
...

可以清晰看到:

  1. lsof 在遍历全部进程的 fd 目录,而非仅目标进程
  2. 尝试访问 /proc/X/fdinfo/ 等新内核专有目录(这里的文件量非常多)
  3. 对每个不理解的条目反复 statread

第六步:确认内核差异

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 对端关联是其核心功能的一部分,无法通过参数关闭。

教训

  1. 容器中应优先使用 /proc/sys 直接读取,避免依赖重型诊断工具(lsofstracetcpdump 等),这些工具在精简镜像中可能不可用,在不同内核版本上可能有性能差异
  2. 编写容器监控脚本时,遵循 “内核接口优先” 原则:能读 /proc 就不调外部命令
  3. 宿主机内核升级时应关注容器内关键工具的行为变化,尤其是直接与内核接口交互的工具

知识拓展

容器与宿主机的边界

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 编号,是实现容器隔离的基础技术之一
本文由作者按照 CC BY 4.0 进行授权