1. 这不是“写个脚本就完事”的事:为什么90%的Shell监控脚本上线三天就失效
“Shell脚本网站监控预警!自动化运维脚本!!!”——看到这个标题,我第一反应不是点开,而是叹了口气。不是因为技术不重要,恰恰相反,太重要了。过去五年里,我亲手维护过27套不同规模的Linux服务集群,从单台边缘设备到跨三地的300+节点混合环境,几乎每一套都经历过“监控脚本上线即告急”的魔咒。某次凌晨三点被短信炸醒,发现报警邮件里赫然写着“磁盘使用率>95%”,而登录服务器一看,根分区才用了62%。追查下去,原来是脚本里用df -h | grep '/$'匹配根分区,但某次系统升级后df输出格式变了,多了一列“Use%”的百分号,导致grep '/$'误匹配到了挂载点为/boot/efi的行——那块小分区确实快满了,但根本不是业务关键路径。更讽刺的是,这个bug在测试环境从未暴露,因为测试机没装UEFI固件。
这背后暴露出一个被严重低估的事实:Shell监控脚本的本质不是“功能实现”,而是“环境契约的持续履约”。它不像Python或Go写的后台服务,有明确的启动入口、配置加载机制和日志框架;它是一段裸奔在操作系统内核之上的文本指令流,其行为高度依赖于bash版本、核心工具(awk/sed/df)的GNU vs BusyBox实现差异、locale设置、甚至/proc文件系统的瞬时状态。你写的不是代码,是与整个Linux发行版生态签订的一份脆弱协议。当协议某处微小变动(比如Ubuntu 22.04默认启用systemd-oomd,导致ps输出新增OOM字段),脚本就可能从精准预警变成制造噪音的源头。
所以,本文不讲“如何用curl检测网站连通性”这种入门操作。我要带你拆解的是:一个真正能活过三个月、扛住两次系统升级、在值班表上不拖累同事的Shell监控脚本,它必须长成什么样子?它需要具备四个刚性能力:环境自检能力(启动时自动识别当前系统特性)、输出解析鲁棒性(对df/ps等命令的输出格式漂移免疫)、状态持久化能力(避免因脚本重启丢失历史阈值)、告警抑制能力(区分真实故障与瞬时抖动)。这些能力,没有一行if [ $? -eq 0 ]能解决。它们藏在case $OSTYPE in的分支里,藏在awk -F'[[:space:]]+'的字段分隔符中,藏在/var/run/monitor/last_alert.timestamp的原子写入逻辑里。接下来,我会用一个真实落地的网站可用性监控脚本为蓝本,逐层展开这四重防御体系。它现在正运行在某高校实验室的12台教学服务器上,连续217天未产生一例误报——而它的核心逻辑,就藏在下面这几行看似平淡的Shell代码里。
2. 环境自检:让脚本学会“看懂自己站在哪片土地上”
很多脚本失败,始于一个傲慢的假设:“我的服务器=CentOS 7”。现实是,运维环境永远在流动:新采购的设备预装Ubuntu 24.04,老旧的物理机跑着Debian 10,容器里可能是精简的Alpine Linux。如果脚本硬编码service nginx status去查Nginx状态,在Alpine里就会因service命令不存在而直接退出。真正的健壮性,始于脚本启动时的“环境扫描”。
2.1 发行版与内核指纹识别:不止是cat /etc/os-release
/etc/os-release是标准,但不够。某些嵌入式设备会伪造该文件,或者干脆缺失。我们必须构建多层校验链:
# 第一层:尝试标准os-release if [[ -f "/etc/os-release" ]]; then . /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_VERSION="${VERSION_ID:-unknown}" else DISTRO_ID="unknown" fi # 第二层:回退到lsb_release(若存在) if [[ -z "$DISTRO_ID" || "$DISTRO_ID" == "unknown" ]] && command -v lsb_release >/dev/null 2>&1; then DISTRO_ID=$(lsb_release -is 2>/dev/null | tr '[:upper:]' '[:lower:]') DISTRO_VERSION=$(lsb_release -rs 2>/dev/null) fi # 第三层:终极兜底——内核与包管理器探测 if [[ -z "$DISTRO_ID" ]]; then # 检查内核是否为musl(Alpine标志) if [[ "$(ldd /bin/sh 2>&1)" == *"musl"* ]]; then DISTRO_ID="alpine" DISTRO_VERSION=$(cat /etc/alpine-release 2>/dev/null | cut -d'.' -f1,2) elif [[ -f "/etc/debian_version" ]]; then DISTRO_ID="debian" DISTRO_VERSION=$(cat /etc/debian_version 2>/dev/null | head -n1 | cut -d'.' -f1) else DISTRO_ID="unknown" fi fi这段代码的价值,远超获取几个变量。它建立了环境决策树:当后续需要选择进程检查方式时,我们不再写死ps aux | grep nginx,而是:
case "$DISTRO_ID" in "alpine"|"busybox") # Alpine常用pidof,更轻量 NGINX_PID=$(pidof nginx 2>/dev/null) ;; "ubuntu"|"debian"|"centos"|"rhel") # 主流发行版用systemctl更可靠 if command -v systemctl >/dev/null 2>&1; then NGINX_STATUS=$(systemctl is-active --quiet nginx 2>/dev/null && echo "running" || echo "stopped") else NGINX_PID=$(pgrep -f "nginx: master process" 2>/dev/null) fi ;; *) # 未知环境,降级为通用ps匹配 NGINX_PID=$(ps aux | awk '$11 ~ /nginx.*master/ {print $2}' 2>/dev/null | head -n1) ;; esac提示:永远不要信任
ps输出的列顺序!不同procps-ng版本下,ps aux的字段数可能从11变到12。上面的awk脚本用$11匹配第11列是危险的。正确做法是先用ps -eo pid,args获取稳定格式,再解析。
2.2 工具链兼容性验证:awk的GNU与BusyBox之争
awk是Shell脚本的瑞士军刀,但它的行为在GNUgawk和BusyBoxawk间差异巨大。最致命的是字段分隔符处理:GNUawk默认以任意空白字符(空格、制表符)为分隔符,而BusyBoxawk对连续空格的处理更严格,常导致$2取到空值。
我们的解决方案是主动声明分隔符并验证:
# 定义一个安全的awk调用函数 safe_awk() { local cmd="$1" shift # 先探测awk类型 if awk --version 2>/dev/null | grep -q "GNU Awk"; then # GNU awk:使用-F'[[:space:]]+'处理任意空白 awk -F'[[:space:]]+' "$cmd" "$@" else # BusyBox/其他:用-F' '并配合gsub清理多余空格 awk -F' ' '{gsub(/[[:space:]]+/, " ", $0); $1=$1; print}' "$@" | \ awk -F' ' "$cmd" fi } # 使用示例:安全提取df输出的已用空间百分比 DISK_USAGE=$(df -P / | tail -n1 | safe_awk '{print $5}' | sed 's/%//')这个safe_awk函数看似复杂,但它把“工具不确定性”这个运维噩梦,转化成了可预测的代码分支。每次调用df、free、netstat等命令后,你都不再需要祈祷输出格式不变,而是拥有了一个能自动适配的解析引擎。
2.3 权限与路径沙盒:为什么/tmp不是你的保险箱
监控脚本常需写临时文件(如上次检查时间戳、历史数据缓存)。但/tmp目录有自动清理策略(systemd-tmpfiles),且不同发行版清理周期不同(Ubuntu 24.04默认7天,CentOS 7是10天)。更糟的是,某些安全加固策略会挂载/tmp为noexec,nosuid,导致脚本无法执行其中的临时二进制。
我们的实践是建立专属运行时目录:
# 在脚本开头定义运行时根目录 RUNTIME_ROOT="/var/run/website-monitor" # 创建目录并确保权限(仅root可写) mkdir -p "$RUNTIME_ROOT" chmod 755 "$RUNTIME_ROOT" chown root:root "$RUNTIME_ROOT" # 所有临时文件走此路径 LAST_CHECK_FILE="$RUNTIME_ROOT/last_check.timestamp" HISTORY_FILE="$RUNTIME_ROOT/history.csv" ALERT_LOCK_FILE="$RUNTIME_ROOT/alert.lock"/var/run是POSIX标准的运行时数据存放位置,由tmpfiles.d管理,生命周期与系统启动绑定,且默认权限安全。这比任何/tmp下的随机目录都可靠。更重要的是,它向系统管理员传递了一个明确信号:“这个脚本需要持久化状态,请勿随意清理”。
3. 输出解析鲁棒性:让脚本读懂“变化中的命令语言”
监控脚本的核心任务,是把curl -I、ping、df等命令的原始输出,翻译成“网站是否宕机”、“磁盘是否将满”这样的业务语义。而命令输出格式的微小变化,就是语义翻译失败的导火索。我们曾遇到过三次重大事故,根源全是输出解析失效:
- 事故1:
curl -I在HTTP/2环境下返回HTTP/2 200而非HTTP/1.1 200,导致grep "200 OK"失效; - 事故2:
ping在新版iputils中默认使用-c 3发送3个包,但输出统计行从3 packets transmitted, 3 received变为3 packets transmitted, 3 received, 0% packet loss; - 事故3:
df -h在某些云主机上因LVM逻辑卷显示,新增/dev/mapper/vg-root挂载点,干扰了/的匹配逻辑。
解决之道,不是给每个命令写一堆if判断,而是建立语义锚点解析模型。
3.1 HTTP状态码解析:放弃字符串匹配,拥抱协议本质
curl -I的输出是HTTP协议头,其结构是标准化的。与其用grep "HTTP/1.1 200",不如直接提取状态行(第一行)并解析:
# 获取HTTP响应头第一行(状态行) STATUS_LINE=$(curl -I -s -m 5 "$TARGET_URL" 2>/dev/null | head -n1) # 安全提取状态码:匹配"HTTP/x.x XXX"模式,捕获XXX if [[ "$STATUS_LINE" =~ HTTP/[0-9]+.[0-9]+\ +([0-9]{3}) ]]; then HTTP_CODE="${BASH_REMATCH[1]}" else HTTP_CODE="000" # 解析失败标记 fi # 判断是否为成功状态(2xx, 3xx通常表示可达) case "$HTTP_CODE" in 2??|3??) IS_UP=true ;; *) IS_UP=false ;; esac这里的关键是[[ string =~ regex ]]语法,它利用Bash内置正则引擎,不依赖外部工具,且BASH_REMATCH数组能精确捕获分组。即使curl返回HTTP/2 200或HTTP/3 200,正则HTTP/[0-9]+.[0-9]+\ +([0-9]{3})依然能完美匹配并提取200。这比任何grep或awk字段提取都更贴近协议本质。
3.2 网络连通性解析:从“包丢失率”到“有效响应”
ping的输出格式争议极大,但它的核心语义只有一个:目标是否在指定时间内响应了ICMP请求。我们完全不必解析“packet loss”百分比,而是直接检查ping命令的退出状态和响应内容:
# 使用ping -c 1 -W 3 发送单个包,超时3秒 if ping -c 1 -W 3 -n "$TARGET_HOST" >/dev/null 2>&1; then PING_RESULT="success" # 进一步提取延迟(可选) PING_TIME=$(ping -c 1 -W 3 -n "$TARGET_HOST" 2>&1 | \ grep "time=" | awk -F'time=' '{print $2}' | awk '{print $1}') else PING_RESULT="fail" PING_TIME="0.0" fiping命令的退出码是POSIX标准:0表示至少收到一个响应,非0表示完全无响应。这比解析输出文本可靠一万倍。-n参数禁用DNS反向解析,避免因DNS问题导致假失败;-W 3设定超时,防止脚本卡死。这才是网络监控的底层逻辑——不关心“丢了多少包”,只关心“有没有回音”。
3.3 磁盘空间解析:用df -P锁定POSIX标准格式
df命令的输出格式是灾难之源。-h(人类可读)选项让数字带单位(G/M),但单位前缀不统一(1.2Gvs1234M),且排序混乱。-h还受LC_NUMERIC影响,某些locale下用逗号作小数点。唯一救星是-P(POSIX)选项:
# df -P 输出固定5列:Filesystem, 1024-blocks, Used, Available, Use% # 且所有数字为纯整数(单位:1024字节),无单位前缀,无locale干扰 DISK_INFO=$(df -P "$MONITOR_PATH" | tail -n1) # 安全分割(用awk,不依赖IFS) FILESYSTEM=$(echo "$DISK_INFO" | awk '{print $1}') TOTAL_BLOCKS=$(echo "$DISK_INFO" | awk '{print $2}') USED_BLOCKS=$(echo "$DISK_INFO" | awk '{print $3}') AVAIL_BLOCKS=$(echo "$DISK_INFO" | awk '{print $4}') USE_PERCENT=$(echo "$DISK_INFO" | awk '{print $5}' | sed 's/%//') # 计算实际使用率(避免Use%字段被篡改) if [[ "$TOTAL_BLOCKS" -gt 0 ]]; then CALCULATED_USE=$(( (100 * $USED_BLOCKS) / $TOTAL_BLOCKS )) else CALCULATED_USE=0 fidf -P是POSIX.1标准强制要求的选项,所有合规的Unix/Linux系统都必须支持。它用固定的空格分隔,且数字绝对纯净。我们甚至可以跳过awk,用read命令安全读取:
# 更高效的方式:用read直接按空格分割 read -r FS TOTAL USED AVAIL USEPCT MOUNTED_ON <<< "$DISK_INFO" USE_PERCENT=${USEPCT%\%} # 移除末尾%这行read命令,就是Shell脚本解析鲁棒性的巅峰——它不依赖任何外部工具,不惧空格,不惧特殊字符,只认空格分隔符,且性能极佳。
4. 状态持久化:让脚本拥有“记忆”,告别重启失忆症
一个没有记忆的监控脚本,就像一个健忘的哨兵。它无法判断“CPU使用率85%”是突发峰值还是持续恶化;它无法知道“网站连续3次超时”是否构成真实故障;它更无法在自身崩溃重启后,继续之前的监控节奏。状态持久化,是脚本从“玩具”走向“生产级”的分水岭。
4.1 时间戳原子写入:解决并发覆盖的幽灵问题
最基础的状态是“上次检查时间”。但多个脚本实例(如cron每分钟执行一次)同时写入同一文件,会导致时间戳被覆盖或损坏。传统echo $(date +%s) > file不是原子操作:>会先清空文件,再写入,中间存在窗口期。
我们的方案是利用mv的原子性:
# 创建临时文件(含PID,避免冲突) TEMP_FILE="${RUNTIME_ROOT}/last_check.$$" echo "$(date +%s)" > "$TEMP_FILE" # 原子移动:mv是原子操作,即使并发也只会有一个成功 mv "$TEMP_FILE" "$LAST_CHECK_FILE" 2>/dev/null || truemv在同一个文件系统内是原子的,这意味着$LAST_CHECK_FILE的内容要么是旧时间戳,要么是新时间戳,绝不会出现“半截数据”。$$是当前进程PID,确保临时文件名唯一,避免mv因文件名冲突失败。
4.2 历史数据环形缓冲:用CSV存储最近100次检查
要实现“连续3次失败才告警”,脚本需要记住最近几次的结果。我们采用轻量级CSV环形缓冲:
# CSV格式:timestamp,http_code,ping_result,disk_usage,alert_status # 每次检查追加一行 echo "$(date +%s),$HTTP_CODE,$PING_RESULT,$USE_PERCENT,$ALERT_STATUS" >> "$HISTORY_FILE" # 保持最多100行(用tail -n100,但需防并发) # 安全截断:先写临时文件,再原子替换 TEMP_HIST="${RUNTIME_ROOT}/history.tmp" tail -n100 "$HISTORY_FILE" > "$TEMP_HIST" mv "$TEMP_HIST" "$HISTORY_FILE" 2>/dev/null || true这个设计巧妙之处在于:它不依赖数据库,却提供了时序查询能力。当需要判断“过去5分钟是否连续失败”,只需:
# 提取最近5分钟(300秒)内的记录 RECENT_DATA=$(awk -v now=$(date +%s) -F',' '$1 > (now-300) {print}' "$HISTORY_FILE") # 统计其中失败次数(HTTP_CODE非2xx/3xx) FAIL_COUNT=$(echo "$RECENT_DATA" | awk -F',' '$2 !~ /^2|^3/ {count++} END{print count+0}') if [[ "$FAIL_COUNT" -ge 3 ]]; then TRIGGER_ALERT=true fiawk在此既是解析器,也是查询引擎。整个过程无需启动任何额外服务,资源消耗近乎为零。
4.3 告警抑制锁:防止“短信轰炸”灾难
最可怕的不是漏报,而是误报引发的告警风暴。当脚本检测到故障,它必须确保在一段时间内(如15分钟)不再重复发送相同告警。这需要一个“告警锁”:
# 锁文件内容:timestamp,alert_type,last_sent_time LOCK_CONTENT="$(date +%s),website_down,$(date +%s)" LOCK_FILE="$ALERT_LOCK_FILE" # 尝试获取锁(检查是否存在且未过期) if [[ -f "$LOCK_FILE" ]]; then read -r LOCK_TS LOCK_TYPE LAST_SENT < "$LOCK_FILE" # 锁过期时间:15分钟(900秒) if [[ $(($(date +%s) - LOCK_TS)) -lt 900 ]]; then # 锁有效,跳过告警 echo "Alert suppressed: lock active for $LOCK_TYPE" exit 0 fi fi # 获取锁成功:写入新锁 echo "$LOCK_CONTENT" > "$LOCK_FILE" # 发送告警(此处省略具体发送逻辑) send_sms_alert "Website $TARGET_URL is DOWN! HTTP:$HTTP_CODE, Ping:$PING_RESULT"这个锁机制简单却有效。它不依赖Redis或数据库,只用一个文件,却实现了分布式系统中最基本的“互斥”和“过期”语义。LOCK_TS是锁创建时间,LAST_SENT是上次发送时间,两者结合,让脚本能智能判断:“这次故障,我是不是已经告诉过人了?”
5. 告警抑制与分级:从“狼来了”到“精准狙击”
监控的终极价值,不是生成告警,而是过滤掉噪音,只让真正需要人工介入的问题浮出水面。一个每小时发10条“磁盘使用率82%”短信的脚本,其危害远大于完全不告警——它会训练运维人员对所有告警麻木。我们必须为告警注入“上下文感知”能力。
5.1 三级告警阈值:区分“注意”、“警告”、“严重”
单一阈值(如“磁盘>90%告警”)是反模式。真实场景中,我们需要:
- 注意级(Notice):磁盘使用率>85%,但增长缓慢(过去1小时<2%),且剩余空间>5GB → 发送邮件,不打扰;
- 警告级(Warning):磁盘使用率>90%,或增长速率>5%/小时 → 发送企业微信消息;
- 严重级(Critical):磁盘使用率>95%,且剩余空间<1GB,或连续3次检查增长>10%/小时 → 电话+短信双通道。
实现的关键是状态差分计算:
# 读取历史数据(最近2次) HIST_LINES=$(tail -n2 "$HISTORY_FILE") if [[ $(echo "$HIST_LINES" | wc -l) -eq 2 ]]; then # 提取两次的磁盘使用率和时间戳 read -r TS1 _ _ USAGE1 _ <<< "$(echo "$HIST_LINES" | head -n1)" read -r TS2 _ _ USAGE2 _ <<< "$(echo "$HIST_LINES" | tail -n1)" if [[ "$TS1" -gt 0 && "$TS2" -gt 0 && "$USAGE1" -gt 0 && "$USAGE2" -gt 0 ]]; then TIME_DIFF=$((TS2 - TS1)) USAGE_DIFF=$((USAGE2 - USAGE1)) # 计算每小时增长率(百分比/小时) if [[ "$TIME_DIFF" -gt 0 ]]; then HOURLY_GROWTH=$(( (USAGE_DIFF * 3600) / TIME_DIFF )) else HOURLY_GROWTH=0 fi fi fi # 应用三级阈值 if [[ "$USE_PERCENT" -ge 95 && "$AVAIL_BLOCKS" -lt 1048576 ]]; then # <1GB ALERT_LEVEL="critical" elif [[ "$USE_PERCENT" -ge 90 || "$HOURLY_GROWTH" -ge 5 ]]; then ALERT_LEVEL="warning" elif [[ "$USE_PERCENT" -ge 85 && "$HOURLY_GROWTH" -lt 2 && "$AVAIL_BLOCKS" -gt 5242880 ]]; then # >5GB ALERT_LEVEL="notice" else ALERT_LEVEL="none" fi这个逻辑把静态阈值,升级为动态风险评估。它让脚本能回答:“这个85%是平稳的85%,还是即将爆炸的85%?”——这才是运维需要的智能。
5.2 上下文关联告警:为什么“网站打不开”不一定是网站的问题
一次真实的故障排查中,脚本报告“网站A超时”,但登录服务器发现Nginx进程正常、端口监听正常。深入检查,发现是上游API服务B的DNS解析失败,导致Nginx upstream timeout。如果告警只说“A网站宕机”,工程师会浪费半小时检查A的配置;如果告警附带“同时检测到上游服务B DNS解析失败”,定位时间缩短到3分钟。
我们的做法是在告警消息中嵌入关联诊断:
# 在触发告警前,执行关联检查 UPSTREAM_CHECKS="" if [[ "$TARGET_URL" == *"api.example.com"* ]]; then # 检查上游DNS if ! nslookup api-backend.example.com >/dev/null 2>&1; then UPSTREAM_CHECKS="$UPSTREAM_CHECKS DNS for api-backend failed; " fi # 检查上游端口 if ! nc -z -w3 api-backend.example.com 443 >/dev/null 2>&1; then UPSTREAM_CHECKS="$UPSTREAM_CHECKS Port 443 on api-backend unreachable; " fi fi # 构建告警消息 ALERT_MSG="ALERT: Website $TARGET_URL is DOWN! - HTTP Code: $HTTP_CODE - Ping Result: $PING_RESULT - Disk Usage: ${USE_PERCENT}% - Time: $(date) ${UPSTREAM_CHECKS:+- Upstream Diagnostics: $UPSTREAM_CHECKS}"这个UPSTREAM_CHECKS变量,就是脚本的“推理引擎”。它不改变监控逻辑,却极大提升了告警的信息密度。工程师拿到的不是故障现象,而是带线索的案发现场。
5.3 静默时段与维护窗口:尊重人的生物钟
再智能的告警,也需要尊重运维人员的休息权。脚本必须支持静默时段(如凌晨2-5点)和计划维护窗口(如每周三上午10-11点):
# 静默时段配置(24小时制) SILENT_START=2 SILENT_END=5 CURRENT_HOUR=$(date +%H) # 维护窗口(每周几,开始/结束小时) MAINTENANCE_DAY=$(date +%u) # 1=Mon, 7=Sun MAINTENANCE_START=10 MAINTENANCE_END=11 # 判断是否在静默或维护期 IN_SILENT=false if [[ "$CURRENT_HOUR" -ge "$SILENT_START" || "$CURRENT_HOUR" -lt "$SILENT_END" ]]; then IN_SILENT=true fi IN_MAINTENANCE=false if [[ "$MAINTENANCE_DAY" -eq 3 && "$CURRENT_HOUR" -ge "$MAINTENANCE_START" && "$CURRENT_HOUR" -lt "$MAINTENANCE_END" ]]; then IN_MAINTENANCE=true fi # 若在静默/维护期,且非critical告警,则降级 if [[ "$IN_SILENT" == "true" || "$IN_MAINTENANCE" == "true" ]] && [[ "$ALERT_LEVEL" != "critical" ]]; then ALERT_LEVEL="notice" # 降级为仅邮件 ALERT_MSG="$ALERT_MSG [INFO] Alert downgraded due to maintenance/silence window." fi这个设计体现了工程伦理:自动化工具的最高境界,不是取代人,而是让人在正确的时间,处理正确的问题。它让脚本从“冷冰冰的机器”,变成了“懂人性的协作者”。
6. 实战部署与巡检清单:让脚本真正活下来
写出脚本只是开始,让它在生产环境长期稳定运行,需要一套完整的部署与巡检流程。我们为某高校实验室部署这套监控时,制定了一份《Shell监控脚本生存指南》,以下是核心条款:
6.1 部署前必检五项
- Shebang校验:确认脚本首行是
#!/usr/bin/env bash而非#!/bin/bash。后者在Alpine等系统中路径不同,会导致/bin/bash: not found错误。 - 权限最小化:脚本文件权限设为
750(owner rwx, group rx, other none),运行用户为专用monitor用户,禁止root直接运行。 - Cron条目审计:
crontab -e中检查是否添加了环境变量:# 在crontab顶部添加,确保PATH正确 SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin - 日志轮转配置:在
/etc/logrotate.d/下创建配置,防止/var/log/monitor.log无限增长:/var/log/monitor.log { daily missingok rotate 30 compress delaycompress notifempty create 640 monitor monitor } - SELinux/AppArmor策略:在CentOS/RHEL上,确认
sestatus为enabled,并检查ausearch -m avc -ts recent | grep monitor是否有拒绝日志。必要时用audit2allow生成策略。
6.2 日常巡检三板斧
- 每日晨检:登录任意一台被监控服务器,手动执行
sudo -u monitor /opt/monitor/check_website.sh -t https://test.example.com -v(-v开启详细日志),确认输出无ERROR字样,且HTTP Code为200。 - 每周核查:检查
/var/run/website-monitor/history.csv的最后10行,确认时间戳递增、无重复、无异常值(如HTTP_CODE为000)。 - 每月压力测试:模拟故障,如
sudo systemctl stop nginx,观察脚本是否在1分钟内触发告警;然后sudo systemctl start nginx,确认告警是否在下次检查时自动清除(alert_status=resolved)。
6.3 故障自愈设计:让脚本成为第一个响应者
最高阶的监控,不仅是发现问题,还能尝试解决问题。我们在脚本中集成了轻量级自愈:
# 当检测到Nginx进程消失,且端口未监听时,尝试重启 if [[ "$NGINX_PID" == "" ]] && ! nc -z -w2 127.0.0.1 80 >/dev/null 2>&1; then echo "$(date): Nginx down, attempting auto-restart..." >> "$LOG_FILE" if systemctl restart nginx 2>>"$LOG_FILE"; then echo "$(date): Nginx restarted successfully." >> "$LOG_FILE" # 重置告警状态 ALERT_STATUS="recovered" # 发送自愈通知 send_alert "Auto-healed: Nginx restarted after crash." else echo "$(date): Auto-restart failed. Manual intervention required." >> "$LOG_FILE" fi fi这个功能有严格前提:仅对无状态服务(如Nginx、sshd)启用,且必须配置systemctl的RestartSec和StartLimitInterval,防止重启风暴。它不是替代人工,而是将“发现-通知-响应”的链条,从小时级压缩到秒级。
注意:自愈操作必须记录完整日志,并发送通知。任何未经记录的自动操作,都是生产环境的定时炸弹。
7. 最后一点个人体会:Shell不是过时的技术,而是被低估的哲学
写完这篇长文,我重新翻看了十年前自己写的第一个check_disk.sh。它只有23行,用df -h | grep '/$' | awk '{print $5}' | sed 's/%//'提取使用率,然后if [ $usage -gt 90 ]就发邮件。它工作了两年,直到某次df输出格式变更,悄无声息地失效了三个月,而没人发现——因为大家早已对它的告警麻木。
今天的脚本,有327行,包含环境探测、输出解析、状态持久化、告警分级、自愈逻辑。它看起来更复杂,但它的可靠性是指数级提升的。这种提升,不来自炫技,而来自对Shell本质的敬畏:它不是一个用来“快速写个脚本”的玩具,而是一门需要深刻理解操作系统契约、工具链边界、并发模型的严肃工程语言。
我见过太多团队,因为“Shell太老”而转向Python或Go。结果呢?他们用Python重写了同样的脆弱逻辑:硬编码subprocess.run(['df', '-h']),用split()解析输出,用全局变量存状态。问题没解决,只是换了个更重的容器。
真正的分水岭,从来不在语言,而在工程思维。当你开始思考“这个awk命令在BusyBox下是否成立”,当你为一行echo加上mv原子写入,当你在告警消息里嵌入上游诊断——你就已经超越了90%的脚本编写者。
所以,别再说“Shell监控脚本很简单”。它一点都不简单。它简单,是因为你还没遇到那个让你凌晨三点爬起来,只为修复一行grep正则的凌晨。而当你真正驯服了它,你会发现,这个诞生于1970年代的古老工具,依然闪耀着最锋利的工程光芒——因为它足够简单,所以足够可靠;因为它足够裸露,所以足够透明。而这,正是所有自动化运维的终极追求。
我在实际使用中发现,最有效的学习方式,不是背诵语法,而是故意制造一次故障:修改/etc/os-release文件,让脚本进入未知发行版分支;手动删除/var/run/monitor/last_check.timestamp,观察它如何重建状态;在cron里故意设置高频率,测试锁机制是否生效。只有在破坏中,你才能真正理解构建的智慧。