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 里挂一行。巡检的价值不在某一次跑出多少异常,而在于它每天都在跑、结果有人看、异常有闭环;把退出码接进监控、把告警发到真正有人盯的渠道,这个脚本才算完成了从“能跑”到“在岗”的转变。







