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. 总结
一个能长期可靠运行的监控告警脚本,需要同时满足四组约束:
- 采集侧:只读、幂等、原子写、失败有本地缓冲。
- 判定侧:持续时长 + 迟滞 + 状态持久化,缺一不可。
- 分发侧:去重、抑制、恢复成对、通道独立失败。
- 自身侧:超时、加锁、心跳,让「脚本挂了」这件事本身可被观测。
把这四组约束落实之后,Shell 写的监控脚本完全可以承担生产环境的一线职责。指标采集之后的日志侧分析(错误率、异常模式统计)是另一个维度,可以配合 日志解析与聚合 一起使用;指标最终如何存储与查询,可以参考 Prometheus 深入剖析 与 监控告警系统设计 。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。