服务器运行状态如何自动巡检?Shell脚本与定时任务实战

在没有完善监控系统、或者监控刚起步的环境里,运维每天早上第一件事往往就是登几台机器敲一遍 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 替代,日志更规范。
  • 错误处理必须有,脚本自身异常要能暴露;先手动测试造条件,确认告警触发无误再挂定时。