前言

学了变量、条件、三剑客,是时候写个真正有用的东西了。下面是一个我维护了两年、持续迭代的服务器巡检脚本——每台机器上一份,cron 每天跑,异常发告警。

一、需求设计

  • 检查六项:负载、内存、磁盘、网络、关键服务、安全项
  • 分级输出:OK / WARN(阈值临界)/ FAIL(必须处理)
  • 退出码语义:0 全绿 / 1 有告警(供 cron 判断是否发通知)
  • 兼容 RHEL 系(OpenCloudOS/CentOS/Rocky)

二、完整脚本

#!/usr/bin/env bash
#=============================================================
# server-inspect.sh —— 服务器每日巡检
# 用法: ./server-inspect.sh [-c cpu阈值] [-m 内存阈值] [-d 磁盘阈值]
#=============================================================
set -uo pipefail          # 注意不用 -e: 巡检要"逐项跑完", 不能一条失败就退出

VERSION="2.3"
LOGFILE="/var/log/inspect.log"

# ---------- 可调参数 ----------
CPU_LIMIT=80        # CPU 使用率告警线 %
MEM_LIMIT=80        # 内存使用率告警线 %
DISK_LIMIT=80       # 磁盘使用率告警线 %
LOAD_FACTOR=2       # load 告警线 = 核数 * 该系数
SERVICES=(sshd nginx crond)   # 关键服务白名单

while getopts "c:m:d:" opt; do
    case $opt in
        c) CPU_LIMIT=$OPTARG ;;
        m) MEM_LIMIT=$OPTARG ;;
        d) DISK_LIMIT=$OPTARG ;;
        *) echo "用法: $0 [-c n] [-m n] [-d n]"; exit 2 ;;
    esac
done

# ---------- 输出工具 ----------
RED=$'\033[31m'; YELLOW=$'\033[33m'; GREEN=$'\033[32m'; NC=$'\033[0m'
FAIL_COUNT=0; WARN_COUNT=0

ok()    { printf "%s[ OK ]%s %s\n"  "$GREEN"  "$NC" "$*"; }
warn()  { printf "%s[WARN]%s %s\n"  "$YELLOW" "$NC" "$*"; WARN_COUNT=$((WARN_COUNT+1)); }
fail()  { printf "%s[FAIL]%s %s\n"  "$RED"    "$NC" "$*"; FAIL_COUNT=$((FAIL_COUNT+1)); }
info()  { printf "%s[INFO]%s %s\n"  ""        "$NC" "$*"; }

section() { printf "\n===== %s =====\n" "$*"; }

# ---------- 1. 系统概览 ----------
section "系统概览"
info "主机名: $(hostname)"
info "系统:   $(grep PRETTY_NAME /etc/os-release | cut -d'"' -f2)"
info "内核:   $(uname -r)"
info "运行:   $(uptime -p)"

# ---------- 2. 负载与 CPU ----------
section "负载与 CPU"
CORES=$(nproc)
LOAD1=$(awk '{print int($1)}' /proc/loadavg)
LOAD_LIMIT=$((CORES * LOAD_FACTOR))

if (( LOAD1 > LOAD_LIMIT )); then
    fail "1分钟负载 ${LOAD1} 超限 (阈值=${LOAD_LIMIT}, ${CORES}核)"
else
    ok "负载正常: ${LOAD1} / ${CORES}核 (阈值=${LOAD_LIMIT})"
fi

CPU_USAGE=$(top -bn1 | awk '/^%Cpu/ {print int($2+$4)}')    # us+sy
if (( CPU_USAGE > CPU_LIMIT )); then
    warn "CPU 使用率 ${CPU_USAGE}% 超过 ${CPU_LIMIT}%"
    info "Top3 进程: $(ps aux --sort=-%cpu | awk 'NR>1&&NR<5 {printf "%s(%s%%) ", $11, $3}')"
else
    ok "CPU 使用率 ${CPU_USAGE}%"
fi

# ---------- 3. 内存与 swap ----------
section "内存"
MEM_PCT=$(free | awk '/Mem:/ {printf "%d", $3/$2*100}')
if (( MEM_PCT > MEM_LIMIT )); then
    warn "内存使用率 ${MEM_PCT}% 超过 ${MEM_LIMIT}%"
    info "Top3 进程: $(ps aux --sort=-%mem | awk 'NR>1&&NR<5 {printf "%s(%s%%) ", $11, $4}')"
else
    ok "内存使用率 ${MEM_PCT}%"
fi

SWAP_USED=$(free -m | awk '/Swap:/ {print $3}')
if (( SWAP_USED > 100 )); then
    warn "Swap 已用 ${SWAP_USED}MB(可能存在内存压力)"
else
    ok "Swap 使用 ${SWAP_USED}MB"
fi

# ---------- 4. 磁盘 ----------
section "磁盘空间"
df -h -x tmpfs -x devtmpfs | awk 'NR>1 {
    gsub(/%/,"",$5)
    if ($5+0 >= 85)      printf ">>> [FAIL] %s 使用率 %s%%\n", $6, $5
    else if ($5+0 >= 70) printf ">>> [WARN] %s 使用率 %s%%\n", $6, $5
}' | while read -r line; do echo "$line"; done

df -h -x tmpfs -x devtmpfs | awk 'NR>1 {gsub(/%/,"",$5); if($5+0<'$DISK_LIMIT') exit 0; else exit 1}' \
    && ok "所有分区使用率低于 ${DISK_LIMIT}%" \
    || warn "存在分区使用率超过 ${DISK_LIMIT}%(详见上方明细)"

INODE_PCT=$(df -i / | awk 'NR==2 {gsub(/%/,"",$5); print $5}')
if (( INODE_PCT > DISK_LIMIT )); then
    warn "根分区 inode 使用率 ${INODE_PCT}%(小文件过多?)"
else
    ok "根分区 inode 使用率 ${INODE_PCT}%"
fi

# ---------- 5. 网络 ----------
section "网络"
GATEWAY=$(ip route | awk '/default/ {print $3; exit}')
if ping -c2 -W2 "$GATEWAY" &>/dev/null; then
    ok "网关 ${GATEWAY} 可达"
else
    fail "网关 ${GATEWAY} 不可达"
fi

EXT_IF=$(ip -br route get 1.1.1.1 2>/dev/null | awk '{print $5; exit}')
if ip link show "$EXT_IF" | grep -q "state UP"; then
    ok "出口网卡 ${EXT_IF} 状态 UP"
else
    fail "出口网卡 ${EXT_IF} 异常"
fi

ERRS=$(ip -s link show "$EXT_IF" 2>/dev/null | awk '/RX:/{getline; print $3+$4}')
if (( ${ERRS:-0} > 1000 )); then
    warn "${EXT_IF} 收发错误/丢包累计 ${ERRS}"
else
    ok "网卡无异常计数"
fi

# ---------- 6. 关键服务 ----------
section "关键服务"
for svc in "${SERVICES[@]}"; do
    if systemctl is-active --quiet "$svc"; then
        ok "服务 ${svc} 运行中"
    else
        fail "服务 ${svc} 未运行"
    fi
done

# ---------- 7. 安全项 ----------
section "安全检查"
SSH_ROOT=$(sshd -T 2>/dev/null | awk '/^permitrootlogin/ {print $2}')
[[ "$SSH_ROOT" == "no" ]] && ok "SSH 已禁止 root 登录" \
                        || warn "SSH 允许 root 登录(建议关闭)"

SSH_PWD=$(sshd -T 2>/dev/null | awk '/^passwordauthentication/ {print $2}')
[[ "$SSH_PWD" == "no" ]] && ok "SSH 已禁用密码登录" \
                       || warn "SSH 仍允许密码登录(建议改密钥)"

LAST_FAIL=$(grep -c "Failed password" /var/log/secure 2>/dev/null || echo 0)
if (( LAST_FAIL > 100 )); then
    warn "本日 SSH 爆破尝试 ${LAST_FAIL} 次(建议 fail2ban)"
else
    ok "SSH 爆破尝试 ${LAST_FAIL} 次"
fi

REBOOT_REQ=$(dnf -q needs-restarting 2>/dev/null | wc -l)
if (( REBOOT_REQ > 0 )); then
    warn "有 ${REBOOT_REQ} 个更新待重启生效"
else
    ok "无待重启的更新"
fi

# ---------- 汇总 ----------
section "巡检结果汇总 ($(date '+%F %T'))"
info "告警: ${WARN_COUNT} 项 / 失败: ${FAIL_COUNT} 项"

if (( FAIL_COUNT > 0 )); then
    fail "巡检不通过, 请立即处理"
    exit 1
elif (( WARN_COUNT > 0 )); then
    warn "巡检通过, 但存在告警项"
    exit 0
else
    ok "全部通过, 系统健康"
    exit 0
fi

三、用到的技术点回放

技术 位置
set -uo pipefail(不用 -e) 巡检要跑完全部项目
getopts 命令行阈值参数
数组 + for 服务白名单遍历
awk 算术 df/free/top 的百分比提取
管道 + while read 分区逐行判断
条件兜底 ${ERRS:-0} 变量可能为空的防御
退出码 供上游判断

四、接入 cron 与告警

install -m 750 server-inspect.sh /opt/scripts/

# /etc/cron.d/inspect
0 9 * * * root /opt/scripts/server-inspect.sh > /var/log/inspect-$(date +\%F).log 2>&1

# 只在异常时发告警(钉钉 webhook)
curl -s -H "Content-Type: application/json" \
     -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"$(hostname) 巡检FAIL\"}}" \
     "https://oapi.dingtalk.com/robot/send?token=xxx"

五、扩展方向

  • HTML 报告(printf 拼 <table>,邮件正文直接看)
  • 多机汇总(fetch 各机报告到中控,awk 拼大表)
  • 趋势记录(结果追加 CSV,画使用率曲线)
  • 重构成 Ansible role / Python 版本(机器多时)

小结

一个好的巡检脚本标准:退出码可靠、输出可分级、阈值可配置、误报少。把这个脚本扔到你的每台服务器上,明早起来看 FAIL 项——你大概率会发现自己忽略已久的问题。


本文是「Linux 运维」系列第 10 篇。