在没有完善监控系统、或者监控刚起步的环境里,运维每天早上第一件事往往就是登几台机器敲一遍 df -h、free -h、systemctl status,看看有没有异常。这种做法在服务器只有两三台时还行,到十台以上就变成负担,而且容易漏看。更好的方式是写一个巡检脚本,让机器每天早上自动跑一遍,把结果整理成报告发过来,人只需要看报告就行。
这篇文章手把手写一个真实可用的巡检脚本,覆盖 CPU、内存、磁盘使用率、systemd 关键服务状态、证书到期、日志错误关键字这几类最常见的检查项,输出成报告并支持推送。写完再配 crontab 定时执行。脚本用 POSIX 兼容写法,能在各种 Linux 上跑,也考虑了错误处理——巡检脚本最忌讳的就是自身出错却静默通过,那样比不巡检还危险。
巡检脚本该检查什么
先把要检查的项目列清楚,不同环境的重点不一样,但下面这些是通用刚需:
| 检查项 | 判定依据 | 常见阈值 |
|---|---|---|
| CPU 使用率 | 采样两次 /proc/stat 算差值 |
高于 80% 关注 |
| 内存使用率 | available 占总量的比例 |
低于 15% 告警 |
| 磁盘使用率 | 各挂载点 df 结果 |
高于 85% 告警 |
| inode 使用率 | df -i |
高于 85% 告警 |
| 系统负载 | uptime 与核心数对比 |
高于核心数 2 倍告警 |
| 关键服务 | systemctl is-active |
非 active 即异常 |
| 证书到期 | openssl x509 读证书 |
剩余少于 30 天告警 |
| 日志错误 | grep 关键字统计 |
出现 ERROR/失败 计数 |
| 僵尸进程 | ps 统计 Z 状态 |
大于 0 关注 |
这些项目覆盖了"资源层面"和"服务层面"两大块。设计脚本时有个原则:能自己算的就别依赖外部命令的输出格式。比如 free 和 df 的输出在不同发行版、不同语言环境下列数可能不同,解析时要小心,或者用 -P 之类的 POSIX 兼容选项。
脚本整体结构
一个可维护的巡检脚本应该分成几块:配置变量、工具函数、各项检查、汇总输出。下面直接给完整脚本,逐段加注释。
#!/bin/sh
# check.sh - Linux 服务器日常巡检脚本(POSIX 兼容)
# 用法: sh check.sh [-o 报告文件] [-w webhook地址]
set -u
# ---------- 可配置项 ----------
HOSTNAME_S=$(hostname)
DATE_S=$(date '+%Y-%m-%d %H:%M:%S')
REPORT="/tmp/inspect_$(date +%Y%m%d).txt"
WEBHOOK=""
SERVICES="sshd crond nginx mysql" # 关键服务,按实际改
CERT_DIR="/etc/nginx/ssl" # 证书目录
LOG_DIR="/var/log" # 日志目录
LOG_ERR_KEY="ERROR|error|Failed|failed|panic|OOM"
CPU_WARN=80
MEM_AVAIL_WARN=15
DISK_WARN=85
CERT_DAYS=30
LOAD_FACTOR=2
while [ $# -gt 0 ]; do
case "$1" in
-o) REPORT="$2"; shift 2 ;;
-w) WEBHOOK="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 2 ;;
esac
done
# ---------- 收集告警 ----------
ALERTS=""
add_alert() { ALERTS="${ALERTS}[$1] $2\n"; }
# 初始化报告(先写文件头和主机信息)
{
echo "==================== 服务器巡检报告 ===================="
echo "主机名 : $HOSTNAME_S"
echo "巡检时间: $DATE_S"
echo "内核版本: $(uname -r)"
echo "运行时长: $(uptime -p 2>/dev/null || uptime)"
echo "--------------------------------------------------------"
} > "$REPORT"
这里用 set -u 让未定义变量报错,提前暴露笔误。add_alert 把告警收集到一个变量里,最后统一汇总,避免边检查边打印导致报告结构混乱。注意这里没有用 set -e,因为巡检脚本希望"一项失败不影响其他项",要自己判断每条命令的返回码。
资源检查:CPU、内存、磁盘
CPU 使用率不能靠 top 的第一帧,因为那是自开机以来的平均值,不准。正确做法是读两次 /proc/stat 求差值:
check_cpu() {
# 读两次数值,间隔 1 秒,用差值算真实使用率
read_cpu() {
awk '/^cpu / {print $2,$3,$4,$5,$6,$7,$8}' /proc/stat
}
set -- $(read_cpu); idle1=$4; total1=$((idle1=$4, $1+$2+$3+$4+$5+$6+$7))
sleep 1
set -- $(read_cpu); idle2=$4; total2=$(( $1+$2+$3+$4+$5+$6+$7 ))
# 用 awk 做浮点运算,避免 shell 只能算整数
CPU_USED=$(awk -v i1="$idle1" -v t1="$total1" -v i2="$idle2" -v t2="$total2" \
'BEGIN{dt=t2-t1; di=i2-i1; if(dt<=0){print 0}else{printf "%.1f",100-(di*100/dt)}}')
echo "CPU 使用率 : ${CPU_USED}%"
awk -v u="$CPU_USED" -v w="$CPU_WARN" 'BEGIN{exit !(u>w)}' && \
add_alert WARN "CPU 使用率偏高:${CPU_USED}%"
}
上面 set -- $(read_cpu) 会把七个字段依次赋给位置参数 $1..$7,分别对应用户态、nice、系统态、空闲、iowait、硬中断、软中断的时间。$1+$2+$3+$4+$5+$6+$7 就是总时间。用 awk 算浮点是因为 POSIX shell 的算术运算只支持整数,算百分比会丢失精度。awk 'BEGIN{exit !(...)}' 是一个常用技巧,把比较结果变成退出码,配合 && 实现条件判断。
内存使用率基于 available:
check_mem() {
# /proc/meminfo 单位是 kB,POSIX 环境通用
MEM_TOTAL=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
MEM_AVAIL=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
# 老内核可能没有 MemAvailable,退化用 MemFree+Buffers+Cached
if [ -z "$MEM_AVAIL" ]; then
MEM_AVAIL=$(awk '/^MemFree:|^Buffers:|^Cached:/{s+=$2}END{print s}' /proc/meminfo)
fi
MEM_PCT=$(awk -v a="$MEM_AVAIL" -v t="$MEM_TOTAL" \
'BEGIN{if(t<=0){print 0}else{printf "%.1f",(1-a/t)*100}}')
MEM_AVAIL_PCT=$(awk -v p="$MEM_PCT" 'BEGIN{printf "%.1f",100-p}')
SWAP_USED=$(awk '/^SwapTotal:/{t=$2}/^SwapFree:/{f=$2}END{if(t>0)printf "%.0f",(1-f/t)*100; else print 0}' /proc/meminfo)
echo "内存使用率 : ${MEM_PCT}% (可用 ${MEM_AVAIL_PCT}%)"
echo "Swap 使用率 : ${SWAP_USED}%"
awk -v p="$MEM_AVAIL_PCT" -v w="$MEM_AVAIL_WARN" 'BEGIN{exit !(p
磁盘和 inode:
check_disk() {
echo "--- 磁盘使用率 ---"
# -P 输出 POSIX 格式,列固定;NR>1 跳过表头
df -P | awk 'NR>1 {print $5,$6}' | while read -r use mnt; do
pct=${use%\%}
[ "$mnt" = "/dev/shm" ] && continue
echo " $mnt : $use"
# 子 shell 里 add_alert 变量无法回传,这里用文件暂存
if [ "$pct" -gt "$DISK_WARN" ] 2>/dev/null; then
echo "[WARN] 磁盘使用率过高:$mnt = $use" >> "${REPORT}.alerts"
fi
done
echo "--- inode 使用率 ---"
df -Pi | awk 'NR>1 {print $5,$6}' | while read -r use mnt; do
pct=${use%\%}
[ "$mnt" = "/dev/shm" ] && continue
if [ "$pct" -gt "$DISK_WARN" ] 2>/dev/null; then
echo "[WARN] inode 使用率过高:$mnt = $use" >> "${REPORT}.alerts"
fi
done
}
这里有个坑要特别注意:while read 配合管道会创建子 shell,在子 shell 里修改的变量(包括 ALERTS)不会影响父 shell。所以磁盘检查用了一个临时文件 ${REPORT}.alerts 收集告警,最后再合并。很多人在写这类脚本时都会踩这个坑,看到告警"丢失"却找不到原因。另一个替代方案是用重定向而非管道:while read; do ...; done < <(df -P),但进程替换是 bash 特性,POSIX sh 不支持,为了兼容性还是用临时文件稳妥。
服务状态与证书检查
systemd 服务状态:
check_services() {
echo "--- 关键服务状态 ---"
for svc in $SERVICES; do
state=$(systemctl is-active "$svc" 2>/dev/null)
echo " $svc : $state"
if [ "$state" != "active" ]; then
echo "[CRIT] 服务未运行:$svc = $state" >> "${REPORT}.alerts"
fi
done
}
systemctl is-active 的返回码和输出要一起看:active 返回 0,其他状态(inactive、failed、activating)返回非零。用 2>/dev/null 屏蔽单位不存在时的报错。
证书到期检查:证书过期是那种"不检查就一定会出事"的项,用 openssl 读到期时间:
check_cert() {
echo "--- 证书到期检查 ---"
[ -d "$CERT_DIR" ] || return 0
# 找常见的证书扩展名
for f in "$CERT_DIR"/*.crt "$CERT_DIR"/*.pem; do
[ -f "$f" ] || continue
end=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | cut -d= -f2)
[ -z "$end" ] && continue
end_ts=$(date -d "$end" +%s 2>/dev/null) || continue
now_ts=$(date +%s)
days=$(( (end_ts - now_ts) / 86400 ))
echo " $(basename "$f") : 剩余 ${days} 天"
if [ "$days" -lt "$CERT_DAYS" ]; then
echo "[WARN] 证书即将到期:$(basename "$f") 剩余 ${days} 天" >> "${REPORT}.alerts"
fi
done
}
注意 date -d 解析日期是 GNU coreutils 的行为,在 BSD/macOS 上会失败,脚本里已经用 2>/dev/null || continue 兜底。如果证书放在非约定目录,可以用 find "$CERT_DIR" -name '*.crt' -o -name '*.pem' 更灵活地找。
日志错误关键字:扫最近的日志,看错误数量:
check_log() {
echo "--- 近期日志错误统计(近 1 小时)---"
# 只扫 journal 或指定日志,避免全量扫盘
if command -v journalctl >/dev/null 2>&1; then
cnt=$(journalctl --since "1 hour ago" -p err 2>/dev/null | wc -l)
echo " 系统错误日志条数:$cnt"
[ "$cnt" -gt 0 ] && echo "[INFO] 近 1 小时系统错误日志 $cnt 条" >> "${REPORT}.alerts"
fi
# 扫应用日志的关键字
for log in "$LOG_DIR"/nginx/error.log "$LOG_DIR"/messages; do
[ -f "$log" ] || continue
c=$(tail -n 2000 "$log" 2>/dev/null | grep -Ec "$LOG_ERR_KEY")
echo " $(basename "$log") 错误关键字:$c"
[ "$c" -gt 20 ] && echo "[WARN] $log 错误关键字 $c 条,请核查" >> "${REPORT}.alerts"
done
}
扫日志要限制范围(tail -n 2000),别对几十 GB 的日志做全量 grep,否则巡检脚本本身会把机器 IO 打满。阈值也要按日志量调整,有些应用正常运行时就有大量 WARN,需要先观察一段时间才能定。
组装报告与推送
各项检查写完,把结果追加进报告并汇总告警:
{
check_cpu
check_mem
check_disk
check_services
check_cert
check_log
} >> "$REPORT" 2>&1
# 合并子 shell 收集的告警
echo "--------------------------------------------------------"
if [ -f "${REPORT}.alerts" ]; then
echo "==================== 发现问题 ===================="
cat "${REPORT}.alerts"
ALERTS=$(cat "${REPORT}.alerts")
rm -f "${REPORT}.alerts"
else
echo "==================== 全部正常 ===================="
ALERTS=""
fi >> "$REPORT"
echo "报告已生成: $REPORT"
推送部分,如果给了 Webhook 就用 curl 发出去。企业微信群机器人的格式如下:
push_webhook() {
[ -z "$WEBHOOK" ] && return 0
# 有告警发完整报告,无告警发一句"正常"
if [ -n "$ALERTS" ]; then
body=$(sed 's/"/\\"/g' "$REPORT" | awk '{printf "%s\\n",$0}')
else
body="巡检正常:$HOSTNAME_S $(date '+%F %T')"
fi
payload=$(printf '{"msgtype":"text","text":{"content":"%s"}}' "$body")
curl -s -m 10 -H 'Content-Type: application/json' \
-d "$payload" "$WEBHOOK" >/dev/null
}
push_webhook
企业微信、钉钉的群机器人都有消息长度限制(一般单条几 KB),超长报告要么截断只发摘要,要么改发到邮件。更稳妥的做法是把完整报告留在服务器或对象存储,通知里只带摘要和链接。
配 crontab 定时执行
把脚本放到 /usr/local/bin/check.sh 并加执行权限:
sudo install -m 0755 check.sh /usr/local/bin/check.sh
# 手动跑一次验证
sudo sh /usr/local/bin/check.sh -o /tmp/test_report.txt
cat /tmp/test_report.txt
每天早八点执行一次:
crontab -e
# 加入下面一行
0 8 * * * /bin/sh /usr/local/bin/check.sh -o /var/log/inspect/inspect_$(date +\%Y\%m\%d).txt >/dev/null 2>&1
几个 crontab 的坑:百分号 % 在 crontab 里是特殊字符,要写成 \% 才能当作字面值;cron 的环境变量和登录 shell 不同,PATH 很短,脚本里最好用绝对路径调用命令,或者在 crontab 顶部设置 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;cron 的输出默认会以邮件发给本机 root,如果没配邮件服务会堆积,用 >/dev/null 2>&1 丢弃或重定向到日志文件。
如果不想用系统 cron,systemd 提供了 timer 方案,日志管理更规范:
# /etc/systemd/system/inspect.service
[Unit]
Description=Daily server inspection
[Service]
Type=oneshot
ExecStart=/bin/sh /usr/local/bin/check.sh -o /var/log/inspect/inspect_%Y%m%d.txt
配合一个 inspect.timer 定义 OnCalendar=*-*-* 08:00:00,systemctl enable --now inspect.timer 即可。timer 可以看 systemctl list-timers 确认下次执行时间,比 cron 更透明。
写巡检脚本的注意事项
错误处理不能省。 脚本自身出错不能静默。可以在开头加 trap 'echo "[巡检脚本异常退出] 行号 $LINENO" >> "$REPORT"' EXIT,或者关键步骤都判断返回码。最怕的是脚本因为一个命令报错提前退出,cron 却毫无反馈,以为巡检正常。
别把巡检做太重。 巡检是抽样检查,不是全量扫描。不要 du -sh / 全盘统计,不要 grep 整个日志目录。巡检脚本自己要轻,几秒到几十秒完成才合适。
阈值放配置区,别写死在逻辑里。 前面把 CPU_WARN、DISK_WARN 都放在头部,方便改。不同角色机器(数据库、Web、缓存)可以准备多份配置。
能告警也能"报平安"。 只报异常、不报正常,会导致"没消息"到底是正常还是脚本挂了分不清。建议每天发一条正常摘要,或者在没有告警时也发一句简短的"正常"。
测试再上线。 先手动跑几遍,故意造几个条件(比如临时把阈值调到很低)验证告警能触发,确认无误再挂到 cron。
常见问题
脚本在 CentOS 能跑,Ubuntu 上报错?
多半是命令差异或 shell 差异。脚本头部用 #!/bin/sh 并注意 POSIX 兼容,date -d、echo -e、[[ ]] 这些非 POSIX 写法在 dash(Ubuntu 的 /bin/sh)上会失效。要么改写兼容写法,要么显式用 #!/bin/bash 并在确认所有目标机都有 bash 的前提下运行。
巡检出来了告警,但希望能自动处理一些简单问题?
可以扩展脚本,比如磁盘超过阈值时自动清理某个临时目录、服务挂掉时自动重启。但要非常谨慎:自动执行删除、重启这类操作必须有明确的边界和日志,宁可不自动,也别让脚本误删数据。建议自动处理只做"低风险、可回滚"的动作,比如清理已知的临时目录。
服务器几十台,难道每台都要装一份脚本单独跑?
不用。可以在巡检机上用 SSH 批量分发和执行,或者干脆用 Ansible 的 ad-hoc 把检查命令推到所有机器统一执行收集结果。单机脚本适合没有集中管理工具的环境,一旦机器多了就该上配置管理工具,这会在后面的文章里讲。
报告堆积占空间怎么办?
给报告目录配 logrotate,或直接在脚本里清理 N 天前的旧报告:
find /var/log/inspect -name 'inspect_*.txt' -mtime +30 -delete
加 -delete 前先确认路径没写错,误删是不可逆的,可以先 ls 出待删文件确认无误再执行。
总结
- 巡检脚本覆盖 CPU、内存、磁盘、inode、服务状态、证书到期、日志错误几类,就能挡住日常大部分隐患。
- 用
/proc/stat差值算 CPU、用MemAvailable算内存、用df -P解析磁盘,比解析top、free的输出更可靠。 - 管道里的
while read会开子 shell,变量改不回父 shell,告警收集要用临时文件或进程替换。 - 阈值和行为都放在配置区,脚本本体保持轻量,只做抽样不做全量扫描。
- crontab 注意百分号转义和 PATH 问题,也可用 systemd timer 替代,日志更规范。
- 错误处理必须有,脚本自身异常要能暴露;先手动测试造条件,确认告警触发无误再挂定时。
企业QQ咨询




