health-monitor.sh 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  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. # ---- 加载配置 ----
  46. CONFIG_FILE="/etc/health-monitor.env"
  47. if [ ! -f "$CONFIG_FILE" ]; then
  48. echo "[$(date '+%F %T')] FATAL: $CONFIG_FILE 不存在,请创建并填 QQ 邮箱授权码" >> /var/log/health-monitor.log
  49. exit 2
  50. fi
  51. # shellcheck disable=SC1090
  52. source "$CONFIG_FILE"
  53. : "${HEALTH_URL:=http://127.0.0.1:3100/health}"
  54. : "${API_URL:=http://127.0.0.1:3100/api/navigation/config}"
  55. : "${SMTP_HOST:=smtp.qq.com}"
  56. : "${SMTP_PORT:=465}"
  57. : "${MAIL_FROM:=$QQ_USER}"
  58. : "${CONSEC_FAIL_THRESHOLD:=2}"
  59. : "${ALERT_COOLDOWN_SEC:=1800}"
  60. : "${LOG_FILE:=/var/log/health-monitor.log}"
  61. : "${STATE_DIR:=/var/lib/health-monitor}"
  62. STATE_FILE="$STATE_DIR/state"
  63. mkdir -p "$STATE_DIR"
  64. touch "$LOG_FILE"
  65. log() {
  66. echo "[$(date '+%F %T')] $*" >> "$LOG_FILE"
  67. }
  68. # ---- 检测 ----
  69. check_endpoint() {
  70. local url="$1"
  71. local code
  72. code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 6 "$url" 2>/dev/null)
  73. if [ "$code" = "200" ]; then
  74. echo "ok"
  75. else
  76. echo "fail:$code"
  77. fi
  78. }
  79. health=$(check_endpoint "$HEALTH_URL")
  80. api=$(check_endpoint "$API_URL")
  81. # 提取详细失败原因
  82. fail_detail=""
  83. if [[ "$health" != ok ]]; then fail_detail+="health=$health "; fi
  84. if [[ "$api" != ok ]]; then fail_detail+="api=$api "; fi
  85. if [ -z "$fail_detail" ]; then
  86. # ---- 正常 ----
  87. prev_consec=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $1}' || echo 0)
  88. prev_status=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $2}' || echo ok)
  89. # 恢复通知
  90. if [ "$prev_status" = "down" ]; then
  91. log "RECOVERED: $HEALTH_URL + $API_URL 均恢复"
  92. send_mail "✅ [book] 服务已恢复" \
  93. "时间: $(date '+%F %T')\n主机: $(hostname)\nURL: $HEALTH_URL\n之前: 连续 ${prev_consec} 次失败\n当前: 全部 200 OK"
  94. fi
  95. echo "0:ok" > "$STATE_FILE"
  96. log "OK: health=$health api=$api"
  97. exit 0
  98. fi
  99. # ---- 故障 ----
  100. prev_consec=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $1}' || echo 0)
  101. prev_status=$(cat "$STATE_FILE" 2>/dev/null | awk -F: '{print $2}' || echo ok)
  102. new_consec=$((prev_consec + 1))
  103. last_alert_at=$(cat "$STATE_DIR/last_alert_at" 2>/dev/null || echo 0)
  104. now=$(date +%s)
  105. elapsed=$((now - last_alert_at))
  106. echo "${new_consec}:down" > "$STATE_FILE"
  107. log "FAIL: $fail_detail (consec=$new_consec)"
  108. # 达到阈值才发,且要过冷却期
  109. if [ "$new_consec" -ge "$CONSEC_FAIL_THRESHOLD" ] && [ "$elapsed" -ge "$ALERT_COOLDOWN_SEC" ]; then
  110. log "ALERT: 触发告警 (consec=$new_consec, elapsed=${elapsed}s)"
  111. send_mail "🚨 [book] 服务异常 - $fail_detail" \
  112. "时间: $(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'"
  113. if [ $? -eq 0 ]; then
  114. echo "$now" > "$STATE_DIR/last_alert_at"
  115. fi
  116. fi
  117. # (send_mail 函数定义在脚本顶部,供 check 调用)