纯Shell脚本实现Linux CPU监控与告警
干运维这些年最怕的不是服务器突然挂掉而是它一点一点变慢你还找不到原因。CPU 使用率是系统健康状态最直接的晴雨表但很多人只会打开 top 看一眼然后就没有然后了——没有历史数据没有告警记录出问题全靠事后诸葛亮。我在某公司的服务器集群日常维护中踩了不少坑后来干脆用 Shell 脚本做了一套 CPU 监控方案不依赖任何第三方 Agent也不装重型的监控平台纯 Shell crontab/systemd timer 就能跑起来。这篇文章就把这套方案从思路到落地完整拆开讲包含 /proc/stat 解析、负载与使用率的区别、多核场景处理、阈值告警以及历史数据留存这些关键内容。这套东西适合谁自己手上管着几台到几十台 Linux 服务器、又不想为了监控去搭一套复杂系统的运维人员或者是刚开始学系统脚本编程、想通过一个真实场景练手的开发者。读完之后你不仅能抄走一套能直接用的监控脚本更重要的是搞清楚 CPU 使用率到底是怎么算出来的以及为什么有时候 top 里看到的数字和脚本算出来的对不上。1. 整体设计思路监控脚本要解决的根本问题1.1 先搞清楚你要监控的是什么很多人一上来就写脚本张口就是监控 CPU 使用率但 CPU 使用率从来不是一个单一的数字。它至少分成几个维度整体使用率、单核使用率、用户态/内核态占比、负载均值load average、以及 I/O 等待占比。在系统调优的时候用户态占用高说明是计算型任务在跑内核态占用高可能意味着系统调用频繁或者中断太多iowait 居高不下则说明磁盘拖了后腿。一个只输出CPU 79%的脚本实用价值极低。我最初做的第一版脚本就犯了这个问题只采集整体使用率一个值。后来一次线上排查 Java 应用 CPU 飙高的问题发现单纯的整体使用率掩盖了单核打满、其他核空闲的真相这才意识到维度拆解的重要性。一套合格的 CPU 监控脚本至少应该覆盖整体使用率、单核使用率、负载均值、以及用户态/系统态/iowait 这几个关键指标。另一个容易混淆的概念是 CPU 使用率和负载均值。load average 是运行队列中处于可运行状态和不可中断状态的进程平均数它受 CPU 核心数和 I/O 等待的双重影响。四核机器负载 4.0 和八核机器负载 4.0 完全是两回事。脚本里必须把核数带上否则光看负载数字就是耍流氓。1.2 为什么选择 Shell 而不是 Python/Go市面上监控 CPU 用 Python 写也挺常见psutil 库几行代码就能拿到全部指标。但我最终选纯 Shell 的原因很朴素目标机器上不一定有 Python 环境尤其是那些精简安装的 CentOS 或者嵌入式 Linux 设备而 Shell 是 POSIX 标准里必然存在的东西。另外 Shell 脚本不依赖第三方库分发部署就是复制一个文件改权限加定时任务就完事在批量管理几十台机器的时候这个优势会被放大得很明显。当然 Shell 也有短板比如浮点运算不方便、处理复杂的 JSON 结构费劲。我的处理思路是能用 awk 解决的计算绝不用纯 bash该调用的系统命令就老老实实调用。awk 本身就是为文本处理和浮点运算设计的用它来计算 CPU 百分比比 bash 内置运算可靠得多。注意如果你的监控需求已经复杂到需要做多维度的关联分析、图表展示、告警收敛那确实该上 Prometheus node_exporter 这类的专业方案。Shell 脚本的价值区间在于轻量、快速、可定制它是监控体系的第一道防线而不是全部。1.3 脚本需要解决的实际问题清单在设计阶段我把需求拆成了六个点后面所有代码都是围绕这六点展开的需求说明指标采集整体使用率、单核使用率、load、iowait趋势判断连续多次采样区分瞬时抖动和持续飙高阈值告警可配置的 warning/critical 两级阈值通知渠道日志记录 邮件/Webhook 告警可开关历史留存按天归档采集数据方便事后回溯自恢复检查告警后自动重查避免误报这个清单里最容易忽略的是趋势判断。单次采样 CPU 80% 可能是正常的业务波动连续五分钟都 80% 才是真问题。脚本如果只看单次快照就告警半夜被误报吵醒的经历绝对不会少。所以我在后面的实现里专门加了连续采样和重试确认的逻辑。2. 核心知识拆解CPU 使用率到底怎么算出来的2.1 /proc/stat 是 CPU 指标的祖宗所有命令行的 CPU 统计工具top、mpstat、sar它们的底层数据来源都是 /proc/stat 这个虚拟文件。第一次打开这个文件的时候你可能会愣一下因为里面就是几行数字。cpu 123456 789 234567 9876543 45678 0 12345 0 0 0 cpu0 23456 123 45678 765432 1234 0 2345 0 0 0 cpu1 ...CPU 那一行的 10 个数字分别代表从系统启动到现在累计的 jiffies节拍数按顺序对应 user、nice、system、idle、iowait、irq、softirq、steal、guest、guest_nice 这十种状态。jiffies 是内核的时间单位频率由 HZ 决定常见的 CentOS 7 是 1000也就是一秒有 1000 个节拍。你只需要知道两点这个数字是累计值、它会一直增长。要计算某段时间内的 CPU 使用率正确做法是间隔一段时间采样两次用两次之间的差值来计算而不是直接用累计值去除。这也是为什么网上很多简单脚本直接读一次 /proc/stat 算出来的结果有问题——那只是系统开机以来的平均使用率不是当前值。2.2 两次采样差值计算的完整推导假设第一次采样到的是 total1 和 idle1第二次是 total2 和 idle2其中 total 是那一行所有数字之和idle 是第 4 个字段iowait 不算空闲这一点特别容易搞错。CPU 空闲率 (idle2 - idle1) / (total2 - total1) × 100% CPU 使用率 100% - 空闲率这里有个关键细节iowait 虽然本质上是 CPU 在等待 I/O 完成不算在做有用功但在分类上不能归入 idle。你把 iowait 从 idle 里单独拎出来看能区分出系统是计算密集还是 I/O 密集。脚本里我会同时输出整体使用率和 iowait 占比就是为了方便做这个判断。top 命令其实也是这么算的。你可以做个实验两次执行 top -bn1把两次的 %Cpu(s) 行对比会发现数字略有不同因为 top 显示的是从上一次刷新到这一次刷新之间的变化率。理解了这个原理后面你看到脚本计算值和 top 显示值存在几个百分点的差异就不会慌了——采样窗口不同结果本来就不该完全一致。2.3 为什么我不用 sar 而用 /proc/statsysstat 包里的 sar -u 1 3 能直接输出使用率而且带有时间戳看起来省事。但在实际批量部署中遇到两个问题一是很多精简系统默认没装 sysstat要么找运维装包要么自己编译二是 sar 依赖系统的 cron 任务在后台收集数据如果你在容器环境里跑这玩意经常不可用。所以最终方案还是直接怼 /proc/stat零依赖任何发行版都能跑。读取 /proc/stat 还有一个好处是能顺带拿到 per-CPU 的数据。cpu0、cpu1、cpu2…… 每行都按同样格式排列循环读取就能算出每个核心的使用率。这对排查某个线程把单个核打满的场景至关重要。下面这一节我们就动手把脚本写出来。3. 实操过程从单核监控脚本到多核版本3.1 先写一个能用的单核监控函数我把监控逻辑封装成了一个函数输入采样间隔和采样次数返回整体 CPU 使用率。脚本的核心就是下面的逻辑#!/usr/bin/env bash # 两次采样计算 CPU 使用率间隔 $1 秒输出整体使用率 get_cpu_usage() { local interval${1:-1} # 第一次采样取 cpu 行求 total 和 idle local cpu1 idle1 total1 cpu1$(grep ^cpu /proc/stat) idle1$(echo $cpu1 | awk {print $5}) total1$(echo $cpu1 | awk {for(i2;iNF;i) s$i} END {print s}) sleep $interval # 第二次采样 local cpu2 idle2 total2 cpu2$(grep ^cpu /proc/stat) idle2$(echo $cpu2 | awk {print $5}) total2$(echo $cpu2 | awk {for(i2;iNF;i) s$i} END {print s}) # 计算差值 local idle_diff total_diff idle_diff$((idle2 - idle1)) total_diff$((total2 - total1)) # 用 awk 处理浮点运算避免 bash 整数除法丢精度 awk -v idle$idle_diff -v total$total_diff \ BEGIN { if (total 0) { print 0 } else { printf %.2f, (1 - idle/total) * 100 } } }这段代码有几个点需要说明。第一grep ^cpu 注意后面的空格这样能精准匹配整机的那一行而不是把 cpu0、cpu1 这些也匹配进去否则 total 会算成所有核的总和再加一个整机值数字直接翻倍。第二awk 的 printf 指定了两位小数方便后续做阈值比较。第三total_diff 等于 0 的极端情况做了保护不过正常采样间隔不会触发。3.2 扩展成多核版本逐核计算单核版本能跑之后我马上遇到了第一个真实问题某次线上排查中发现整体 CPU 只有 30%但有个别核心已经跑到 90% 以上。这种情况整体监控完全看不出来必须把 per-CPU 的采样也做进去。多核版本的核心差异在于采样时不再只取 cpu 那一行而是把每行 cpuN 都读出来存成临时文件两次采样后按对应核心做差值计算。因为逻辑稍复杂我用数组来存储中间结果# 采样到临时文件格式: cpuN idle total按核分开 get_per_cpu_snapshot() { local out_file$1 : $out_file while read -r line; do local name idle total name$(echo $line | awk {print $1}) idle$(echo $line | awk {print $5}) total$(echo $line | awk {for(i2;iNF;i) s$i} END {print s}) echo $name $idle $total $out_file done (grep ^cpu[0-9]\ /proc/stat) } # 计算所有核心使用率 calc_per_cpu() { local snap1$1 snap2$2 paste $snap1 $snap2 | while read -r name1 idle1 total1 name2 idle2 total2; do local idle_diff total_diff idle_diff$((idle2 - idle1)) total_diff$((total2 - total1)) awk -v name$name1 -v idle$idle_diff -v total$total_diff \ BEGIN { if (total 0) { printf %s 0.00\n, name } else { printf %s %.2f\n, name, (1 - idle/total) * 100 } } done }这里用 paste 把两次快照按行拼接一对一算差值逻辑上很清晰。如果你不想引入临时文件也可以用命名管道或者直接把两次采样结果存在变量里但实际测试下来临时文件在中低负载的监控频率下完全没性能压力代码反而更容易读懂。3.3 load average 和 iowait 的采集除了 CPU 使用率我把 load average 和 iowait 一起采了。load 的获取极简单读 /proc/loadavg 的第一段就是 1 分钟平均负载load1$(cut -d -f1 /proc/loadavg)配合核数做判断负载除以核数大于 1 说明队列可能堆积大于 2 就该重点关注了。iowait 的占比在前面计算 total 和 idle 的时候顺手就能拿到只需要把第 6 个字段单独存出来做差值iowait1$(echo $cpu1 | awk {print $6}) iowait2$(echo $cpu2 | awk {print $6}) iowait_diff$((iowait2 - iowait1)) iowait_pct$(awk -v io$iowait_diff -v total$total_diff \ BEGIN { if (total 0) { print 0 } else { printf %.2f, io/total * 100 } })提示iowait 占比超过 30% 且持续较长时间基本可以判断瓶颈在磁盘而不是 CPU。这时候去看 CPU 本身意义不大应该配合 iostat 查具体是哪块盘、什么进程在写。这类问题的排查思路我后面会展开。4. 完整脚本落地阈值告警、日志留存与定时调度4.1 完整监控脚本实例把上面各个函数拼装起来再加上阈值判断和告警逻辑就是一套能直接上生产用的脚本。我贴一个精简但完整的版本你根据自己的环境改一下告警方式就能用。#!/usr/bin/env bash # # CPU 监控脚本 # 功能: 采集 CPU 使用率/负载/iowait, 超阈值告警 # 依赖: 仅 bash awk grep cut # INTERVAL5 # 采样间隔单位秒 SAMPLE_TIMES2 # 采样次数用于趋势判断 WARN_CPU70 # 整体使用率警告阈值 (%) CRIT_CPU90 # 整体使用率严重阈值 (%) WARN_LOAD2.0 # 负载/核数 的警告系数 LOG_DIR/var/log/cpu_monitor # 通知函数: 这里以写日志 调用 webhook 为例 notify() { local level$1 msg$2 local ts ts$(date %Y-%m-%d %H:%M:%S) echo [$ts] [$level] $msg $LOG_DIR/alerts.log # 有 webhook 地址就取消下行注释 # curl -fsS -X POST -H Content-Type: application/json \ # -d {\msgtype\:\text\,\text\:{\content\:\[$ts] [$level] $msg\}} \ # https://your-webhook.example.com/send /dev/null 21 } # 检查目录 mkdir -p $LOG_DIR # 获取整体使用率 get_total_usage() { get_cpu_usage $INTERVAL } # 采样主流程 main() { local usage iowait load1 cores load_per_core local crit_count0 warn_count0 for ((i0; iSAMPLE_TIMES; i)); do usage$(get_total_usage) iowait$(get_iowait $INTERVAL) load1$(cut -d -f1 /proc/loadavg) cores$(nproc) load_per_core$(awk -v l$load1 -v c$cores BEGIN { printf %.2f, l/c }) echo $(date %F %T) usage${usage}% iowait${iowait}% load1${load1} load_per_core${load_per_core} \ $LOG_DIR/cpu_history.log # 阈值判断 if awk -v u$usage BEGIN { exit !(u 90) }; then crit_count$((crit_count 1)) elif awk -v u$usage BEGIN { exit !(u 70) }; then warn_count$((warn_count 1)) fi sleep 1 done # 连续多次触发才告警 if [ $crit_count -ge 1 ]; then notify CRITICAL CPU usage${usage}%, load${load1}, iowait${iowait}%, 请立即检查! elif [ $warn_count -ge 2 ]; then notify WARNING CPU usage持续高于${WARN_CPU}%, 当前${usage}% fi } # 需要自行实现 get_cpu_usage 和 get_iowait 的完整逻辑 # 参考前面小节的函数体合理拼装即可 main这里为了控制篇幅get_cpu_usage 和 get_iowait 函数体没有完整重复写实际使用时把前面两小节里的逻辑填进去就行。生产环境里的脚本我习惯把所有函数完整写在同一个文件里方便拷贝到新机器直接执行。4.2 阈值怎么定才合理连续触发 分级告警告警阈值我建议不要一刀切先观察你的业务基线。纯计算型业务在业务高峰期 CPU 跑到 80% 可能是常态而一个平时只有 10% 的数据库服务器突然飙到 50% 反而可能是慢查询爆发的信号。所以我在脚本里把 70% 和 90% 作为默认的 warning/critical 阈值你拿到手后一定要根据自身业务调整。另一个容易被忽视的点是连续触发机制。上面脚本里用了循环采样多次、统计超阈值次数的方式critical 级别只要一次达标就立刻告警warning 级别要求连续两次以上才触发。这个设计是有讲究的——系统 CPU 瞬时冲到 90% 可能只是一个定时任务在跑备份压缩几秒就回落了如果每次都告警你的告警渠道会迅速被免疫。实际经验告诉我告警疲劳比监控缺失更可怕。没有人看的告警形同虚设。所以我建议你在脚本里把 SAMPLE_TIMES 调到 3 到 5 次每次间隔 5 秒相当于持续 15 秒以上 CPU 高于阈值才判为真告警。4.3 crontab 和 systemd timer 怎么选写好的脚本需要周期运行。传统做法是 crontab简单直接*/5 * * * * root /usr/local/bin/cpu_monitor.sh /var/log/cpu_monitor/cron.log 21crontab 的缺点在于它不会关心上一次任务是否还在跑。假如脚本执行时长超过 5 分钟极端情况下采样间隔设得很大或者系统卡顿下一个周期的任务又会启动导致脚本并发执行采集数据互相干扰。虽然我们脚本的执行时长通常是秒级但严谨起见还是建议加个文件锁。我在生产上更推荐 systemd timer它对任务的运行时机、失败重试、日志输出都有更完善的控制。新增 service 和 timer 两个文件# /etc/systemd/system/cpu-monitor.service [Unit] DescriptionCPU Monitor Script [Service] Typeoneshot ExecStart/usr/local/bin/cpu_monitor.sh StandardOutputjournal StandardErrorjournal# /etc/systemd/system/cpu-monitor.timer [Unit] DescriptionRun CPU Monitor every 5 minutes [Timer] OnCalendar*:0/5 Persistenttrue [Install] WantedBytimers.target启用命令systemctl daemon-reload systemctl enable --now cpu-monitor.timer。用 systemd timer 还有个附带好处Persistenttrue 会在机器开机后补跑错过的任务对于笔记本电脑或者经常关机的机器很友好。5. 常见问题与排查技巧实录5.1 CPU 使用率算出来超过 100% 是怎么回事多核机器上如果你不加处理地把所有核的 CPU 时间加在一起做分母再把单核的使用率也加总就会出现总使用率超过 100% 的怪异结果。最常见的错误是读取 /proc/stat 时没有加空格匹配把整机行和各核心行的数据混在一起。另一个常见错误是拿所有核的使用率做简单平均时精度丢失得到的结果和 top 显示不一致。解决办法上面已经提到采集整机使用率用 ^cpu 带空格采集单核用 ^cpu[0-9]两者严格分开不要让正则把 cpu 和 cpu0 同时命中。5.2 和 top 显示的数字对不上怎么办这是新手最容易困惑的地方。同一时刻运行脚本和 top 命令两者显示的 CPU 使用率可能差三五个百分点有时候甚至差得更多。原因在于采样窗口不一样top 默认 3 秒刷新一次你脚本采样的间隔如果是 1 秒两者覆盖的时间段不同CPU 又是在不断波动的结果自然对不上。另外 top 的显示还受进程优先级、nice 值的影响它的 %Cpu(s) 行里 us 和 sy 分别显示用户态和内核态。如果你真想和 top 做对比最好把脚本采样间隔调到和 top 的刷新间隔一致比如都用 3 秒然后把波动剧烈的时刻多测几组取平均。5.3 脚本检测到 CPU 高却不知道是哪个进程造成的监控脚本只负责发现异常定位进程是另一件事。我遇到最多的场景是脚本告警 CPU 90%SSH 上去一看系统负载很高但 top 进程列表里翻了几页也找不到明显的罪魁祸首。这种时候不要急按顺序排查先用 top -bn1 按 CPU 排序看是不是某个进程瞬时抽风多刷几次。再用 pidstat -p ALL 5 2 查看每个进程的 CPU 使用率趋势抓持续高占用的。如果 CPU 主要是 sys 占比高用 perf top 看内核热点很可能是有频繁的系统调用或者中断风暴。如果 iowait 高而进程 CPU 占用低用 iostat -x 1 看磁盘 util再排查是哪些进程在读盘。这里最想强调的是Shell 监控脚本是哨兵不是医生。它帮你划定问题时间段、确认异常范围但最终的根因定位往往要配合其他工具。所以脚本里输出的日志一定要带上精确的时间和指标组合事后回溯才有价值。5.4 脚本在容器里运行的特殊注意点在容器环境里跑这套脚本有几个坑是物理机上不会遇到的。第一容器如果没有 --privileged 或者没有挂载宿主机的 /proc你读到的 /proc/stat 可能是容器自身的反映的是宿主机整机 CPU 或者 namespace 隔离后的视图并不代表容器真实分到的 CPU 资源。第二如果你用 Docker 的 --cpus 限制了容器 CPU 额度光看使用率没有意义还要结合 CPU 限额看。判断方法其实简单在容器里执行 nproc 查看核数然后和你在宿主机上看到的核数对比。如果不一致说明你正在读的 /proc/stat 未必是你直觉中那台机器的数据。容器场景我一般建议改用 cgroup 的 cpuacct 统计数据或者直接依赖编排平台自带的监控不要用本文这套物理机思路硬套。5.5 历史数据积累从监控脚本到简易报表脚本运行一段时间后/var/log/cpu_monitor/cpu_history.log 里会积累大量数据。这些数据的价值不亚于实时告警。我会定期用 awk 对日志做聚合统计比如按天算平均使用率、峰值使用率、高负载持续时间。这能帮你回答服务器够不够用、要不要扩容这类容量规划问题。一个简单实用的按天聚合命令awk {print $1, $2} cpu_history.log | cut -d -f2 | \ awk {if($1 ~ /usage/) { }}更推荐的思路是直接在上面的采集脚本里每行输出固定字段分隔符的纯文本格式比如用 | 分隔时间、使用率、iowait、load这样后面做 awk 切分就非常方便。我实际用的格式是2025-01-15 10:30:01|usage23.45|iowait1.20|load11.85|load_per_core0.46有了这个格式按天统计高峰期平均值就是一条 awk 的事。如果你有 Grafana 或者类 Grafana 的工具也可以把这份日志用 filebeat 之类的采集器喂进去享受一下可视化图表——不过那就是另一个话题了Shell 脚本到这一步已经完成了它最核心的值守任务。6. 进阶扩展这些方向让脚本更有实用价值6.1 增加进程级 Top 采集基础版脚本只监控了整机维度。我有一次在排查性能问题时特别需要知道过去五分钟哪个进程 CPU 最高而基础版的日志里根本没有这个信息。后来我在脚本里加了一个可选的进程 Top 采集告警触发时自动执行 top -bn1 | head -20把进程快照追加到告警日志里。这个改进看着不起眼但实际排查问题时帮了大忙。告警消息不再只是一句CPU 高了而是附带了可疑进程列表很多时候一眼就能看出是哪个 Java 进程或者某个异常脚本在搞事。实现方式就是在 notify 函数里加一段top -bn1 | head -20 $LOG_DIR/alerts.log6.2 对接企业 IM 告警推送邮件告警现在已经显得有点迟钝我建议直接把告警推到企业 IM 的机器人 Webhook。脚本里只要用 curl 发一个 POST 请求就行我常见的信息格式是简单的 JSON包含级别、时间、IP、CPU 数值。要注意的是 curl 命令必须加超时参数 --max-time 5防止 DNS 解析或网络异常时告警脚本自己卡死。另外告警推送本身要有失败重试或者说静默策略不然短暂网络抖动会导致告警丢失或者重复轰炸。6.3 把脚本做成标准运维套件如果你在维护多台机器不建议每台上手敲一遍脚本。我这边最终是把脚本整理进了一个内部运维仓库通过配置分发系统下发到各台机器并用 hostname 作为标识写入日志文件名。这样所有机器的告警日志可以统一收集到一个日志服务器用一套简单的脚本或者日志平台做聚合展示。在做成套件的时候要注意脚本的版本号和配置分离阈值、间隔、Webhook 地址都应该抽成配置文件脚本本体保持只读部署。这样后续调整阈值不需要重新下发脚本改配置文件就行也方便做统一变更管理。6.4 避免监控脚本成为新的故障源监控脚本本身也是程序写得不好也会拖累业务。我在早期版本里踩过一个教训脚本里用了复杂的 awk 循环在 CPU 已经很高的情况下脚本自身反而占用了额外的 CPU形成恶性循环。后来我把采样逻辑精简到最低开销整个脚本一轮执行下来 CPU 时间基本在毫秒级内存占用可以忽略。另外给脚本执行加上超时保护也是必要的一步在脚本开头加一句timeout 120 $0 $ || true这样即使某次执行异常挂死也不会出现僵尸进程占着不释放的情况。对于长驻的监控脚本这一点尤其要紧。做运维监控这些年我最大的感受是不要迷信复杂的监控平台也不要轻视简单的 Shell 脚本。监控本质上是采集、判断、通知、回溯四件事脚本也好、平台也好都只是完成这四件事的工具。这套 CPU 监控脚本从最初的十几行慢慢加到上百行经历了多次线上问题的检验最终成了我手上一台台服务器的标准配置。如果你按照这篇文章的思路把脚本架起来建议先放在非核心机器上跑一周观察它采集的数据是否和 top/mpstat 大致吻合阈值设定是否贴合业务曲线再逐步推广到生产环境。用一句话总结我的实操体会监控脚本写出来只是开始真正有价值的是后续持续根据告警反馈调整阈值的迭代过程。