Shell 巡检脚本工程化:写一个带日志、超时与告警的巡检脚本

很多团队的巡检脚本是越写越长的命令堆砌:没有日志、挂死没人知道、某项检查失败了整个脚本跟着退出、出了问题只在终端闪过一行红字。本文把巡检脚本按工程化思路重做一遍:骨架规范统一(日志函数、配置区、退出码约定),每个巡检项是一个独立函数,外部命令全部加超时,检查结果先聚合再统一输出,脚本退出码可以直接被 cron 和监控系统利用。最后给一份完整示例,改改配置区就能用到自己的机器上。

适用环境:主流 Linux 发行版(systemd 系统),Shell 为 bash 4 以上。文中主机、域名、证书路径均为占位(vm-01、example.com),请按实际替换。

前置条件

  • 以 root 或有 sudo 权限的账号运行(读服务状态、证书文件需要)。
  • 系统中已有 coreutils 的 timeout 命令、ss(iproute2)、openssl,缺失时先装。
  • 规划好日志目录 /var/log/inspect 和脚本存放路径 /usr/local/sbin/inspect.sh。
  • 想清楚告警出口:本地邮件(mailx)或一个能接收 POST 的 Webhook 地址,二选一即可。

第一步:脚本骨架——配置、日志与退出码约定

骨架部分定三件事。第一是 set 选项的取舍:巡检脚本里“某个命令返回非零”往往是正常的检查结果(比如服务没运行),如果开了 set -e,脚本会在第一个异常项处直接退出,后面的检查全丢。所以巡检类脚本只开 set -uo pipefail:-u 防未定义变量拼错,pipefail 让管道里任一命令失败都能被感知,错误处理由每个检查函数自己负责。第二是统一日志函数,带时间戳和级别,同时写屏幕和文件。第三是退出码约定:0 全部正常、1 有警告、2 有严重异常——cron 和监控只认这个数字,约定必须稳定。

#!/usr/bin/env bash
# inspect.sh —— 主机日常巡检脚本(示例,可按需增删检查项)
set -uo pipefail

# ---------- 配置区 ----------
DISK_WARN=80                 # 磁盘使用率警告阈值(%)
DISK_CRIT=90                 # 磁盘使用率严重阈值(%)
MEM_WARN=90                  # 内存使用率警告阈值(%)
CERT_WARN_DAYS=30            # 证书距到期天数警告阈值
SERVICES="sshd nginx"        # 必须在运行的服务
PORTS="22 80 443"            # 必须在监听的端口
CERT_FILES="/etc/pki/tls/certs/example.com.crt"
LOG_DIR="/var/log/inspect"
LOG_FILE="${LOG_DIR}/inspect-$(date +%F).log"
WEBHOOK_URL="https://example.com/hook/inspect"
# ----------------------------

mkdir -p "$LOG_DIR"
FAIL=0
WARN=0
ISSUES=()

log() {
  local level="$1"; shift
  printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE"
}

第二步:巡检项函数化拆分

每个巡检项写成一个 check_* 函数:只做一件事,发现问题就把计数器加一、把描述追加进 ISSUES 数组,不要在函数里直接打印告警——输出统一放到汇总阶段,这样日志和告警内容天然一致。阈值全部引用配置区变量,调整标准只改一处。

check_disk() {
  local fs blocks used avail cap mp usage
  # df -P 保证每个文件系统一行,避免长设备名换行导致解析错位
  while read -r fs blocks used avail cap mp; do
    usage="${cap%\%}"
    case "$usage" in ''|*[!0-9]*) continue ;; esac
    if [ "$usage" -ge "$DISK_CRIT" ]; then
      FAIL=$((FAIL + 1)); ISSUES+=("磁盘 ${mp} 使用率 ${usage}%(严重)")
    elif [ "$usage" -ge "$DISK_WARN" ]; then
      WARN=$((WARN + 1)); ISSUES+=("磁盘 ${mp} 使用率 ${usage}%")
    fi
  done < <(df -P)
}

check_mem() {
  local pct
  pct="$(free -m | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}')"
  if [ "$pct" -ge "$MEM_WARN" ]; then
    WARN=$((WARN + 1)); ISSUES+=("内存使用率 ${pct}%")
  fi
}

check_service() {
  local svc
  for svc in $SERVICES; do
    if ! timeout 5 systemctl is-active --quiet "$svc"; then
      FAIL=$((FAIL + 1)); ISSUES+=("服务 ${svc} 未运行")
    fi
  done
}

check_port() {
  local port
  for port in $PORTS; do
    if ! ss -lnt | grep -q ":${port}[[:space:]]"; then
      FAIL=$((FAIL + 1)); ISSUES+=("端口 ${port} 未监听")
    fi
  done
}

check_cert() {
  local f end
  for f in $CERT_FILES; do
    [ -f "$f" ] || continue
    # checkend:证书在指定秒数内到期则返回非零
    if ! openssl x509 -in "$f" -noout -checkend $((CERT_WARN_DAYS * 86400)) >/dev/null 2>&1; then
      end="$(openssl x509 -in "$f" -noout -enddate | cut -d= -f2)"
      WARN=$((WARN + 1)); ISSUES+=("证书 ${f} 将于 ${end} 到期")
    fi
  done
}

第三步:给关键命令加超时,防挂死

巡检脚本最丢人的死法是自己先挂住:NFS 卡死时 df 会阻塞、systemctl 偶发无响应、openssl 连远程证书时网络黑洞能等几分钟。对策是所有可能阻塞的外部调用一律用 timeout N 命令 包一层——上面的 check_service 已经这么做了;涉及远程探测(如 curl 健康检查、openssl s_client 拉证书)同理,curl 再叠加 -m 总超时和 --connect-timeout。同时给整个脚本在 cron 层再加一道总保险:用 flock 防并发重入,用 timeout 限制总时长,脚本卡死也不会拖垮下一轮巡检。

超时时间的设定有个朴素原则:单个检查项的超时应远小于巡检周期,巡检整体超时应小于两倍周期。比如每天跑一次的巡检,单项超时给 3 到 10 秒、整体给 10 分钟绰绰有余;如果某项检查正常就要跑两分钟,那它就不该放在日常巡检里,应该拆成独立任务单独调度。还有一个细节:timeout 杀掉的是它直接启动的命令,命令若再派生子进程可能杀不干净,这类检查优先选能自带超时参数的工具(curl 的 -m、ssh 的 -o ConnectTimeout 和 -o BatchMode),timeout 只做最后兜底。

第四步:汇总输出、退出码与主函数

main() {
  log INFO "巡检开始:$(hostname)"
  check_disk
  check_mem
  check_service
  check_port
  check_cert

  local item
  for item in "${ISSUES[@]}"; do
    log WARN "$item"
  done
  log INFO "巡检结束:严重 ${FAIL} 项,警告 ${WARN} 项"

  if [ "$FAIL" -gt 0 ]; then
    alert "巡检发现 ${FAIL} 项严重异常"
    exit 2
  fi
  if [ "$WARN" -gt 0 ]; then
    exit 1
  fi
  exit 0
}
main "$@"

注意 ISSUES 为空时 "${ISSUES[@]}" 在老版本 bash 加 set -u 会报错,bash 4.4 以后已修复;仍在用老系统的,把数组展开写成 ${ISSUES[@]+"${ISSUES[@]}"} 兜底。

第五步:接入 cron 与失败告警

告警函数保持最小实现:有严重异常才触发,把主机名、异常条数和明细 POST 到 Webhook;没有 Webhook 的环境,把 alert 函数体换成 mail 命令即可。发送动作本身也加超时,且失败不影响脚本退出码:

alert() {
  local msg="$1"
  local detail
  detail="$(printf '%s; ' "${ISSUES[@]}")"
  curl -fsS -m 10 -X POST "$WEBHOOK_URL" \
    -H 'Content-Type: application/json' \
    -d "{\"host\":\"$(hostname)\",\"msg\":\"${msg}\",\"detail\":\"${detail}\"}" \
    >/dev/null 2>&1 || log WARN "告警发送失败"
}
# /etc/crontab:每天 07:30 巡检,flock 防重入,timeout 限总时长 10 分钟
30 7 * * * root flock -n /run/inspect.lock timeout 600 /usr/local/sbin/inspect.sh >> /var/log/inspect/cron.log 2>&1

chmod 750 /usr/local/sbin/inspect.sh

可移植性注意

  • 数组、进程替换 < <(...)、[[ ]] 都是 bash 特性,脚本 shebang 写 bash 就别再幻想用 sh 跑;真要跑 POSIX sh,数组得换成临时文件或位置参数重写。
  • free 是 procps 的命令,某些精简镜像没有;df -P 是 POSIX 写法,各发行版行为一致,优先用;ss 在老系统上可能要退回 netstat -lnt。
  • 不同发行版服务名不同(apache2 与 httpd、ssh 与 sshd),配置区集中定义就是为了改一处适配。
  • 在调用外部命令前用 command -v timeout >/dev/null || exit 2 做存在性检查,缺命令时明确报错,而不是默默跳过检查项——跳过等于假巡检。

验证

  • 手工执行一次:/usr/local/sbin/inspect.sh; echo $?,退出码应与机器状态相符,日志文件里有完整的开始、异常明细、结束三段。
  • 人为制造一个异常:停掉一个被检服务再跑,确认计数、日志、告警三处都出现,恢复服务后退出码回到 0。
  • 用 bash -n inspect.sh 做语法检查,改完脚本先跑它;用 timeout 5 单独验证某个慢检查项不会拖住整体。
  • 等一个 cron 周期,确认 /var/log/inspect/cron.log 有执行记录且没有并发重入(flock 生效时第二份进程会直接退出)。

常见问题

巡检跑到一半莫名其妙就结束了。多半是有人给脚本加了 set -e,某个检查命令返回非零(例如 grep 没匹配到)就把脚本带走。巡检、探活类脚本默认不开 -e,改为在每个检查里显式判断返回值。

手工跑正常,cron 里跑就报命令找不到或结果不对。cron 的环境极简,PATH 只有 /usr/bin:/bin,且没有 LANG 等变量。脚本里用绝对路径或在开头显式设置 PATH,中文日志乱码时在 crontab 里补 LANG=zh_CN.UTF-8。

磁盘检查偶尔报出离谱的数字。df 在设备名过长时会换行输出,一行变两行就把字段读串了。用 df -P 强制单行,或改用 df --output=pcent,target(GNU coreutils)只取需要的列。

grep 配合管道时灵时不灵。开了 pipefail 后,ss | grep -q 中 grep 提前退出可能让上游收到 SIGPIPE 并返回非零,误判为检查失败。对这类“匹配即成功”的管道,可在 grep 后用 || true 明确语义,或改用 awk 一次完成过滤与判断。

日志文件几个月后把磁盘写满了。按日期命名的日志只增不减,必须配轮转。简单办法是在 logrotate 里给 /var/log/inspect/*.log 加一条 daily rotate 30 compress 的规则;或者脚本自己在 main 开头删除 30 天前的旧日志:find "$LOG_DIR" -name 'inspect-*.log' -mtime +30 -delete。巡检脚本因为磁盘满而失败、而磁盘满恰恰是它该报出来的问题,这种黑色幽默并不罕见,轮转要在上线第一天就配好。

小结

巡检脚本的工程化,说到底是四件事:约定先行(日志格式、退出码、阈值集中配置)、检查解耦(函数化加聚合输出)、永不挂死(命令级超时加 cron 层总兜底)、结果可消费(退出码给机器看,告警给人看)。示例脚本不到两百行,把配置区换成自己的服务、端口和证书就能上线,后续加新检查项也只是多写一个 check_* 函数并在 main 里挂一行。巡检的价值不在某一次跑出多少异常,而在于它每天都在跑、结果有人看、异常有闭环;把退出码接进监控、把告警发到真正有人盯的渠道,这个脚本才算完成了从“能跑”到“在岗”的转变。

发表回复

后才能评论