health-monitor.sh 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. #!/bin/bash
  2. #
  3. # health-monitor.sh — 线上后端健康监控 + 邮件告警
  4. #
  5. # 部署路径:/usr/local/bin/health-monitor.sh
  6. # 凭证路径:/etc/health-monitor.env (QQ 邮箱授权码,chmod 600)
  7. # 状态路径:/var/lib/health-monitor/state
  8. # 日志路径:/var/log/health-monitor.log
  9. # cron 路径:/etc/cron.d/health-monitor (*/5 * * * *)
  10. #
  11. # 告警策略:
  12. # - 连续 2 次失败才发告警(防抖动)
  13. # - 同一故障 30 分钟内只发一次(防告警风暴)
  14. # - 服务恢复时单独发一条 ✅
  15. #
  16. # 邮件走 QQ 邮箱 SMTP 465 (SSL),阿里云默认屏蔽 25 出站但 465/587 通畅
  17. set -u
  18. # ---- 邮件发送(调用 Python smtplib,避免 openssl s_client 交互卡死)----
  19. MAIL_SCRIPT="${MAIL_SCRIPT:-/usr/local/bin/health-send-mail.py}"
  20. send_mail() {
  21. local subject="$1"
  22. local body="$2"
  23. if [ -z "${QQ_USER:-}" ] || [ -z "${QQ_AUTH_CODE:-}" ]; then
  24. log "SKIP mail: QQ_USER 或 QQ_AUTH_CODE 未配置"
  25. return 1
  26. fi
  27. if [ ! -x "$MAIL_SCRIPT" ]; then
  28. log "MAIL FAIL: $MAIL_SCRIPT 不存在或不可执行"
  29. return 1
  30. fi
  31. local out
  32. out=$(QQ_USER="$QQ_USER" QQ_AUTH_CODE="$QQ_AUTH_CODE" \
  33. SMTP_HOST="$SMTP_HOST" SMTP_PORT="$SMTP_PORT" \
  34. MAIL_FROM="$MAIL_FROM" \
  35. python3 "$MAIL_SCRIPT" "$subject" <<< "$body" 2>&1)
  36. local rc=$?
  37. if [ $rc -eq 0 ]; then
  38. log "MAIL OK: $subject | $out"
  39. return 0
  40. else
  41. log "MAIL FAIL (rc=$rc): $subject | $out"
  42. return 1
  43. fi
  44. }
  45. # ---- Push 推送(push.rrbrr.com WebSocket 通道)----
  46. PUSH_SCRIPT="${PUSH_SCRIPT:-/usr/local/bin/health-push.py}"
  47. send_push() {
  48. local title="$1"
  49. local body="$2"
  50. if [ "${PUSH_ENABLED:-true}" != "true" ]; then
  51. log "SKIP push: PUSH_ENABLED != true"
  52. return 0
  53. fi
  54. if [ ! -x "$PUSH_SCRIPT" ]; then
  55. log "PUSH FAIL: $PUSH_SCRIPT 不存在或不可执行"
  56. return 1
  57. fi
  58. local out
  59. out=$(PUSH_API_URL="$PUSH_API_URL" PUSH_API_KEY="$PUSH_API_KEY" \
  60. PUSH_USER_ID="$PUSH_USER_ID" PUSH_CHANNEL="$PUSH_CHANNEL" \
  61. python3 "$PUSH_SCRIPT" "$title" <<< "$body" 2>&1)
  62. local rc=$?
  63. if [ $rc -eq 0 ]; then
  64. log "PUSH OK: $title | $out"
  65. return 0
  66. else
  67. log "PUSH FAIL (rc=$rc): $title | $out"
  68. return 1
  69. fi
  70. }
  71. # ---- 统一告警入口:同时走 mail + push ----
  72. send_alert() {
  73. local subject="$1"
  74. local body="$2"
  75. send_mail "$subject" "$body"
  76. send_push "$subject" "$body"
  77. }
  78. # ---- 加载配置 ----
  79. CONFIG_FILE="/etc/health-monitor.env"
  80. if [ ! -f "$CONFIG_FILE" ]; then
  81. echo "[$(date '+%F %T')] FATAL: $CONFIG_FILE 不存在,请创建并填 QQ 邮箱授权码" >> /var/log/health-monitor.log
  82. exit 2
  83. fi
  84. # shellcheck disable=SC1090
  85. source "$CONFIG_FILE"
  86. : "${HEALTH_URL:=http://127.0.0.1:3100/health}"
  87. : "${API_URL:=http://127.0.0.1:3100/api/navigation/config}"
  88. : "${SMTP_HOST:=smtp.qq.com}"
  89. : "${SMTP_PORT:=465}"
  90. : "${MAIL_FROM:=$QQ_USER}"
  91. : "${CONSEC_FAIL_THRESHOLD:=2}"
  92. : "${ALERT_COOLDOWN_SEC:=1800}"
  93. : "${LOG_FILE:=/var/log/health-monitor.log}"
  94. : "${STATE_DIR:=/var/lib/health-monitor}"
  95. STATE_FILE="$STATE_DIR/state"
  96. mkdir -p "$STATE_DIR"
  97. touch "$LOG_FILE"
  98. log() {
  99. echo "[$(date '+%F %T')] $*" >> "$LOG_FILE"
  100. }
  101. # ---- 检测 ----
  102. check_endpoint() {
  103. local url="$1"
  104. local code
  105. code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 6 "$url" 2>/dev/null)
  106. if [ "$code" = "200" ]; then
  107. echo "ok"
  108. else
  109. echo "fail:$code"
  110. fi
  111. }
  112. health=$(check_endpoint "$HEALTH_URL")
  113. api=$(check_endpoint "$API_URL")
  114. # 提取详细失败原因
  115. fail_detail=""
  116. if [[ "$health" != ok ]]; then fail_detail+="health=$health "; fi
  117. if [[ "$api" != ok ]]; then fail_detail+="api=$api "; fi
  118. if [ -z "$fail_detail" ]; then
  119. # ---- 正常 ----
  120. prev_consec=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $1}' || echo 0)
  121. prev_status=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $2}' || echo ok)
  122. # 恢复通知
  123. if [ "$prev_status" = "down" ]; then
  124. log "RECOVERED: $HEALTH_URL + $API_URL 均恢复"
  125. send_alert "✅ [book] 服务已恢复" \
  126. "时间: $(date '+%F %T')\n主机: $(hostname)\nURL: $HEALTH_URL\n之前: 连续 ${prev_consec} 次失败\n当前: 全部 200 OK"
  127. fi
  128. echo "0:ok" > "$STATE_FILE"
  129. log "OK: health=$health api=$api"
  130. exit 0
  131. fi
  132. # ---- 故障 ----
  133. prev_consec=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $1}' || echo 0)
  134. prev_status=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $2}' || echo ok)
  135. new_consec=$((prev_consec + 1))
  136. last_alert_at=$(cat "$STATE_DIR/last_alert_at" 2>/dev/null || echo 0)
  137. now=$(date +%s)
  138. elapsed=$((now - last_alert_at))
  139. echo "${new_consec}:down" > "$STATE_FILE"
  140. log "FAIL: $fail_detail (consec=$new_consec)"
  141. # 达到阈值才发,且要过冷却期
  142. if [ "$new_consec" -ge "$CONSEC_FAIL_THRESHOLD" ] && [ "$elapsed" -ge "$ALERT_COOLDOWN_SEC" ]; then
  143. log "ALERT: 触发告警 (consec=$new_consec, elapsed=${elapsed}s)"
  144. send_alert "🚨 [book] 服务异常 - $fail_detail" \
  145. "时间: $(date '+%F %T')\n主机: $(hostname)\nURL: $HEALTH_URL\nAPI: $API_URL\n\n检测结果:\n health: $health\n api: $api\n\n连续失败次数: $new_consec\n上次告警: $((elapsed / 60)) 分钟前\n\n可能原因:\n 1) PM2 进程退出: pm2 list\n 2) 端口被占用: ss -lntp | grep 3100\n 3) 进程 OOM: dmesg | tail -20\n 4) 磁盘满: df -h /\n\nssh 排查: ssh -p 22622 root@8.159.134.106 'pm2 logs server --lines 50'"
  146. if [ $? -eq 0 ]; then
  147. echo "$now" > "$STATE_DIR/last_alert_at"
  148. fi
  149. fi
  150. # (send_mail 函数定义在脚本顶部,供 check 调用)