监控采集与告警脚本

系统讲解用 Shell 采集系统指标并上报到 textfile collector 或 Pushgateway、阈值判定中的持续时长与迟滞区间、去抖状态机、告警通道集成与去重抑制,以及脚本自身的超时、加锁与心跳可靠性设计,附完整实例与常见踩坑速查表。

1. 监控脚本的两种形态

一句话总结: 采集脚本分「被拉」与「主动推」两条路:能被 Prometheus 直接抓的走 exporter,抓不到的一次性任务走 Pushgateway——选错会让监控数据出现难以排查的断层。

1.1 pull 与 push 的边界

形态适用场景数据落点
pull常驻服务、定时任务产出的文件node_exporter textfile collector
push批处理任务、短生命周期容器Pushgateway

pull 模型的好处是「目标挂了 Prometheus 立刻知道」;push 模型的坑是「任务挂了就再也不会推数据,而监控看到的是最后一条陈旧值」——所以 push 场景必须配一个死信开关(dead man’s switch)。

1.2 三类脚本

① 采集器:周期性读取指标 → 写成 .prom 文件或推送到 Pushgateway
② 判定器:读指标 → 按阈值与去抖规则判定 → 触发或恢复告警
③ 执行器:接收告警事件 → 分发到各通道 → 去重、抑制、限流

三类脚本可以合一,但生产环境建议拆开:采集失败不应该影响告警分发,告警分发失败也不应该丢指标。

2. 指标采集与上报

一句话总结: 指标采集的原则是「只读、幂等、原子写」——采集脚本不改业务状态,重复执行结果一致,写出文件必须是原子替换。

2.1 从 /proc 与 /sys 读指标

#!/usr/bin/env bash
set -euo pipefail

# 内存使用率(排除 cache 与 buffer)
read_mem_used_pct() {
    local total available
    total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
    available=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
    awk -v t="$total" -v a="$available" 'BEGIN {
        printf "%.2f", (t - a) / t * 100
    }'
}

# 磁盘使用率(取根分区)
read_disk_used_pct() {
    df -P / | awk 'NR==2 {gsub(/%/,"",$5); print $5}'
}

# 负载(1 分钟,归一化到核数)
read_load1_ratio() {
    local load cores
    load=$(cut -d' ' -f1 /proc/loadavg)
    cores=$(nproc)
    awk -v l="$load" -v c="$cores" 'BEGIN { printf "%.3f", l / c }'
}

/proc/meminfo 的 MemAvailable 比「free + buffers + cached」的老算法更准确,2.6.27 之后的内核都提供。df -P 的 -P 保证输出是 POSIX 格式,避免长设备名换行导致的字段错位。

2.2 textfile collector

node_exporter 的 textfile collector 会扫描指定目录下的 .prom 文件并暴露为指标,这是「Shell 采集 + Prometheus 抓取」的标准桥梁。

#!/usr/bin/env bash
set -euo pipefail

TEXTFILE_DIR=${TEXTFILE_DIR:-/var/lib/node_exporter/textfile}
JOB=backup_check
TMP=$(mktemp "${TEXTFILE_DIR}/.${JOB}.XXXXXX")
trap 'rm -f "$TMP"' EXIT

# 采集:备份目录里最新文件的时间戳
latest=$(find /var/backups -type f -name '*.tar.gz' -printf '%T@\n' 2>/dev/null |
    sort -nr | head -1 || echo 0)
age=$(( $(date +%s) - ${latest%%.*} ))

{
    printf '# HELP %s_last_backup_age_seconds 距上次备份的秒数\n' "$JOB"
    printf '# TYPE %s_last_backup_age_seconds gauge\n' "$JOB"
    printf '%s_last_backup_age_seconds %d\n' "$JOB" "$age"
    printf '%s_last_run_timestamp_seconds %d\n' "$JOB" "$(date +%s)"
} > "$TMP"

# 原子替换:先写临时文件再 mv,避免 Prometheus 抓到半截文件
chmod 0644 "$TMP"
mv -f "$TMP" "${TEXTFILE_DIR}/${JOB}.prom"

一句话总结: .prom 文件必须原子替换(写临时文件 + mv),否则 Prometheus 恰好抓到写了一半的文件会解析失败,整个 collector 的指标全丢。

2.3 推送到 Pushgateway

push_metrics() {
    local job=$1 instance=$2; shift 2
    {
        while [ $# -gt 0 ]; do printf '%s\n' "$1"; shift; done
    } | curl -fsS --max-time 10 --data-binary @- \
        "http://pushgateway:9091/metrics/job/${job}/instance/${instance}"
}

# 用法
push_metrics batch_etl "$(hostname)" \
    '# TYPE etl_rows_total counter' \
    "etl_rows_total $(wc -l < /var/log/etl/rows.csv)"

三个细节:--data-binary 而不是 --data(后者会剥掉换行)、URL 里的 job/instance 会成为标签、失败必须让脚本非零退出(-f 让 curl 在 4xx/5xx 时报错)。

2.4 上报失败的处理

if ! push_metrics "$JOB" "$(hostname)" "${metrics[@]}"; then
    echo "推送失败,写入本地缓冲" >&2
    printf '%s\n' "${metrics[@]}" >> /var/spool/metrics/pending.prom
    exit 1
fi

本地缓冲 + 下次重放,比「失败就算了」更能保证数据连续性,但要注意缓冲文件需要定期清理,否则磁盘会被慢慢吃满。

3. 阈值判定与去抖

一句话总结: 裸阈值告警的结局一定是「狼来了」——必须有持续时长、迟滞区间与状态持久化三件套,否则没人会认真看告警。

3.1 瞬时值与持续时长

# 错误:一次采样超阈值就告警,瞬时尖峰造成大量误报
if [ "$(read_disk_used_pct)" -gt 90 ]; then alert; fi

# 正确:连续 N 次超阈值才告警
threshold=90
needed=3
state_file=/var/lib/monitor/disk.state
count=$(cat "$state_file" 2>/dev/null || echo 0)

if [ "$(read_disk_used_pct)" -gt "$threshold" ]; then
    count=$(( count + 1 ))
else
    count=0
fi
printf '%s\n' "$count" > "$state_file"

[ "$count" -ge "$needed" ] && alert "磁盘使用率持续超阈值"

3.2 迟滞:把「触发」与「恢复」的阈值分开

使用率
 95% ┤        ┌──────┐
 90% ┤   ┌────┘      └────┐      ← 触发阈值 90%
 85% ┤───┘                 └───   ← 恢复阈值 85%
     └─────────────────────────→ 时间
     正常   告警中    正常
hysteresis() {
    local value=$1 high=$2 low=$3 state=$4
    local cur
    cur=$(cat "$state" 2>/dev/null || echo ok)

    if [ "$cur" = ok ] && [ "$value" -ge "$high" ]; then
        printf 'alert\n' > "$state"; return 1     # 1 = 进入告警
    elif [ "$cur" = alert ] && [ "$value" -le "$low" ]; then
        printf 'ok\n' > "$state"; return 0        # 0 = 恢复正常
    fi
    [ "$cur" = alert ] && return 1 || return 0
}

一句话总结: 迟滞区间(触发 90、恢复 85)的作用是防止指标在阈值附近来回抖动时反复触发与恢复,是降低告警噪音最廉价的手段。

3.3 状态持久化与并发

状态文件是这套机制的核心,必须处理两件事:并发写与丢失。

STATE_DIR=/var/lib/monitor
mkdir -p "$STATE_DIR"

# 原子写状态:临时文件 + mv
write_state() {
    local key=$1 value=$2
    printf '%s\n' "$value" > "${STATE_DIR}/.${key}.$$"
    mv -f "${STATE_DIR}/.${key}.$$" "${STATE_DIR}/${key}"
}

如果采集脚本可能被并发触发(比如 cron 与手动执行撞车),用文件锁保护状态文件,具体做法见 文件锁与并发控制 。

3.4 状态文件丢了怎么办

# 状态文件丢失时,默认进入「未知」而不是「正常」
cur=$(cat "$state" 2>/dev/null) || cur=unknown

case "$cur" in
    ok|alert) ;;
    *) echo "状态文件缺失,本次只上报不告警" >&2; exit 0 ;;
esac

默认成 ok 会让一次磁盘故障后的告警被静默吞掉;默认成「未知并跳过」是更安全的选择。

4. 告警通道集成

一句话总结: 告警通道的设计目标是「同一事件只送达一次、恢复时也要送达、通道故障时不阻塞主流程」。

4.1 Webhook 通用封装

send_webhook() {
    local url=$1 payload=$2
    curl -fsS --max-time 5 \
        -H 'Content-Type: application/json' \
        --data-binary "$payload" \
        "$url" >/dev/null
}

# Slack 格式
slack_payload() {
    printf '{"text":"[%s] %s\n%s"}' "$1" "$2" "$3"
}

# PagerDuty Events API v2
pd_payload() {
    local action=$1 summary=$2 key=$3
    printf '{"routing_key":"%s","event_action":"%s","dedup_key":"%s","payload":{"summary":"%s","source":"%s","severity":"critical"}}' \
        "$key" "$action" "$key" "$summary" "$(hostname)"
}

PagerDuty 的 dedup_key 是去重的关键:同一个 dedup_key 的 trigger 只会产生一个事件,后续 resolve 会自动关闭它。

4.2 去重与抑制

# 去重:同一告警在静默窗口内只发一次
DEDUP_DIR=/var/lib/monitor/dedup
SILENCE=1800     # 30 分钟

should_notify() {
    local key=$1
    local stamp="${DEDUP_DIR}/${key}"
    local now; now=$(date +%s)
    local last=0
    [ -f "$stamp" ] && last=$(cat "$stamp" 2>/dev/null || echo 0)

    if [ $(( now - last )) -lt "$SILENCE" ]; then
        return 1     # 静默期内,不发
    fi
    printf '%s\n' "$now" > "$stamp"
    return 0
}
# 抑制:维护窗口内只记日志不发告警
if [ -f /var/lib/monitor/maintenance ]; then
    echo "维护窗口,跳过告警: $msg" >> /var/log/monitor/suppressed.log
    exit 0
fi

4.3 恢复通知

# 触发与恢复必须成对,否则告警列表会永远挂着一堆已恢复的事件
notify() {
    local state=$1 msg=$2
    if [ "$state" = alert ]; then
        send_webhook "$SLACK_URL" "$(slack_payload ALERT "$msg" "$(hostname)")"
        send_webhook "$PD_URL" "$(pd_payload trigger "$msg" "$DEDUP_KEY")"
    else
        send_webhook "$SLACK_URL" "$(slack_payload RECOVERED "$msg" "$(hostname)")"
        send_webhook "$PD_URL" "$(pd_payload resolve "$msg" "$DEDUP_KEY")"
    fi
}

4.4 通道故障不能阻塞

# 每个通道独立失败,互不影响;通道失败只记日志不改变退出码
for ch in slack pagerduty email; do
    if ! "send_${ch}" "$msg"; then
        echo "通道 ${ch} 发送失败" >&2
        failures=$(( failures + 1 ))
    fi
done

[ "$failures" -eq 3 ] && exit 1 || exit 0

全部通道都失败才返回非零,否则一个通道的网络抖动会让整个脚本被标记为失败。告警链路的整体设计(分组、路由、升级策略)在 告警设计:值班与事件响应 里有更系统的讨论。

5. 脚本自身的可靠性

一句话总结: 监控脚本自己也需要被监控——超时、加锁、心跳三件事没做,脚本挂掉的时候你连「它挂了」都不知道。

5.1 超时保护

# 给外部调用加超时,避免采集脚本被慢接口拖死
with_timeout() {
    local secs=$1; shift
    if command -v timeout >/dev/null 2>&1; then
        timeout "$secs" "$@"
    else
        "$@" &                       # 无 timeout 时的降级实现
        local pid=$!
        ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) &
        local watcher=$!
        wait "$pid"; local rc=$?
        kill "$watcher" 2>/dev/null
        return $rc
    fi
}

with_timeout 5 curl -fsS http://svc/health || echo "健康检查超时"

5.2 加锁避免重叠执行

# 采集周期短于执行时间时,必须加锁防止实例堆积
LOCK=/var/lock/monitor.lock
exec 9>"$LOCK"
if ! flock -n 9; then
    echo "上一次采集尚未结束,跳过本轮" >&2
    exit 0
fi

flock -n 非阻塞获取,拿不到就退出——这是采集类脚本的标准做法,比「等锁」更符合监控场景。

5.3 心跳与死信开关

# 每次成功执行都推一个心跳,外部用 absent() 规则判断脚本是否停摆
heartbeat() {
    push_metrics monitor_heartbeat "$(hostname)" \
        '# TYPE monitor_heartbeat_timestamp_seconds gauge' \
        "monitor_heartbeat_timestamp_seconds $(date +%s)"
}

# 主流程成功结束才发心跳
trap 'heartbeat' EXIT

对应的 Prometheus 规则:

- alert: MonitorScriptStale
  expr: time() - monitor_heartbeat_timestamp_seconds > 600
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "监控脚本超过 10 分钟未上报心跳"

一句话总结: 心跳必须走独立通道(比如单独写一个 .prom 文件),不能和业务指标混在一起——否则业务指标出问题时心跳也会一起消失,失去「监控监控」的意义。

5.4 权限与凭据

# 令牌从环境或文件读,绝不硬编码
: "${SLACK_URL:?缺少 SLACK_URL 环境变量}"

# 若必须落盘,权限收到 0600 并属主限定
umask 077
printf '%s' "$TOKEN" > /etc/monitor/token
chmod 0600 /etc/monitor/token

告警脚本常被放进 systemd timer 或 cron,环境变量不会自动继承,需要显式声明,参见 systemd 单元与定时器 与 cron 定时任务 里的环境传递写法。

6. 踩坑速查

症状原因处理
指标时有时无.prom 文件非原子写临时文件 + mv
告警风暴无静默窗口与去重加 dedup_key 与静默期
恢复通知丢失只实现了触发分支触发/恢复成对实现
指标永久陈旧任务挂了仍显示最后值加心跳与 absent() 规则
采集实例堆积无锁,周期短于执行时间flock -n
脚本被慢接口拖死外部调用无超时包 timeout
Pushgateway 数据缺行用了 --data 而非 --data-binary换 --data-binary
告警渠道抖动致脚本失败单通道失败即返回非零全部失败才返回非零

7. 总结

一个能长期可靠运行的监控告警脚本,需要同时满足四组约束:

  1. 采集侧:只读、幂等、原子写、失败有本地缓冲。
  2. 判定侧:持续时长 + 迟滞 + 状态持久化,缺一不可。
  3. 分发侧:去重、抑制、恢复成对、通道独立失败。
  4. 自身侧:超时、加锁、心跳,让「脚本挂了」这件事本身可被观测。

把这四组约束落实之后,Shell 写的监控脚本完全可以承担生产环境的一线职责。指标采集之后的日志侧分析(错误率、异常模式统计)是另一个维度,可以配合 日志解析与聚合 一起使用;指标最终如何存储与查询,可以参考 Prometheus 深入剖析 与 监控告警系统设计 。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 日志轮转与归档
  2. Shell 处理二进制数据
  3. Shell 脚本的 POSIX 可移植性