概述

监控系统本身也需要被监控。Prometheus 采集了整个基础设施的指标,但如果 Prometheus 自己的配置出了问题、某个 target 掉线了、SSL 证书快过期了、告警规则写得有语法错误——谁来发现这些问题?答案是:一套自动化巡检脚本。本文围绕 Prometheus 生态,构建一套覆盖"拨测→证书检查→配置审计→规则验证→告警模拟→报表生成"的完整巡检工具集。

参考来源:Prometheus 官方文档Blackbox Exporter

一、批量服务拨测脚本

1.1 基于 Blackbox Exporter 的拨测

Blackbox Exporter 是 Prometheus 官方的黑盒探测工具,支持 HTTP、TCP、ICMP、DNS 等协议。通过 Prometheus API 查询探测结果,可以实现批量服务健康检查:

#!/usr/bin/env python3
"""
批量服务拨测脚本
通过 Prometheus API 查询 Blackbox Exporter 探测结果
"""

import requests
import json
from datetime import datetime
from typing import List, Dict, Optional
from dataclasses import dataclass, asdict
import smtplib
from email.mime.text import MIMEText
import sys

@dataclass
class ProbeResult:
    """探测结果"""
    instance: str
    module: str          # http_2xx, tcp_connect, icmp 等
    success: bool
    status_code: Optional[int]
    duration: float      # 探测耗时(秒)
    error: Optional[str]
    last_error: Optional[str]
    ssl_cert_expiry_days: Optional[float]

class PrometheusProber:
    """Prometheus 拨测器"""

    def __init__(self, prometheus_url: str, timeout: int = 30):
        self.prometheus_url = prometheus_url.rstrip('/')
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({'Content-Type': 'application/json'})

    def query(self, promql: str) -> List[Dict]:
        """执行 PromQL 即时查询"""
        resp = self.session.get(
            f"{self.prometheus_url}/api/v1/query",
            params={'query': promql},
            timeout=self.timeout
        )
        resp.raise_for_status()
        data = resp.json()

        if data['status'] != 'success':
            raise Exception(f"Prometheus 查询失败: {data.get('error', 'unknown')}")

        return data['data']['result']

    def query_range(self, promql: str, start: str, end: str, step: str = '60s') -> List[Dict]:
        """执行 PromQL 范围查询"""
        resp = self.session.get(
            f"{self.prometheus_url}/api/v1/query_range",
            params={
                'query': promql,
                'start': start,
                'end': end,
                'step': step
            },
            timeout=self.timeout
        )
        resp.raise_for_status()
        data = resp.json()
        return data['data']['result']

    def probe_all_services(self) -> List[ProbeResult]:
        """探测所有注册的服务"""
        results = []

        # 查询所有 Blackbox 探测结果
        probe_results = self.query(
            'probe_success * 1'
        )

        # 查询探测耗时
        duration_results = self.query('probe_duration_seconds')

        # 查询 HTTP 状态码
        status_results = self.query(
            'probe_http_status_code'
        )

        # 查询 SSL 证书到期天数
        ssl_results = self.query(
            'probe_ssl_earliest_cert_expiry - time()'
        )

        # 查询最近错误信息
        error_results = self.query('probe_last_error')

        # 合并结果
        duration_map = {r['metric']['instance']: float(r['value'][1]) for r in duration_results}
        status_map = {r['metric']['instance']: int(float(r['value'][1])) for r in status_results if r['value'][1] != 'NaN'}
        ssl_map = {r['metric']['instance']: float(r['value'][1]) / 86400 for r in ssl_results if r['value'][1] != 'NaN'}
        error_map = {r['metric']['instance']: r['value'][1] for r in error_results if r['value'][1] != ''}

        for item in probe_results:
            metrics = item['metric']
            instance = metrics.get('instance', 'unknown')
            module = metrics.get('job', 'unknown')
            success = item['value'][1] == '1'

            results.append(ProbeResult(
                instance=instance,
                module=module,
                success=success,
                status_code=status_map.get(instance),
                duration=duration_map.get(instance, 0),
                error=None if success else error_map.get(instance),
                last_error=error_map.get(instance) if not success else None,
                ssl_cert_expiry_days=ssl_map.get(instance)
            ))

        return results

    def check_endpoints(self, endpoints: List[str]) -> List[ProbeResult]:
        """检查指定端点列表"""
        results = self.probe_all_services()
        return [r for r in results if r.instance in endpoints]

class EndpointChecker:
    """端点检查器"""

    def __init__(self, prober: PrometheusProber):
        self.prober = prober

    def check_all(self) -> Dict:
        """执行全面检查"""
        results = self.prober.probe_all_services()

        total = len(results)
        success = sum(1 for r in results if r.success)
        failed = [r for r in results if not r.success]
        slow = [r for r in results if r.success and r.duration > 2.0]
        ssl_warning = [r for r in results if r.ssl_cert_expiry_days and r.ssl_cert_expiry_days < 30]

        report = {
            'timestamp': datetime.now().isoformat(),
            'summary': {
                'total': total,
                'success': success,
                'failed': len(failed),
                'success_rate': f"{success/total*100:.1f}%" if total > 0 else 'N/A',
                'slow_responses': len(slow),
                'ssl_expiring': len(ssl_warning),
            },
            'failed': [asdict(r) for r in failed],
            'slow': [{'instance': r.instance, 'duration': f"{r.duration:.2f}s"} for r in slow],
            'ssl_expiring': [{
                'instance': r.instance,
                'days_left': f"{r.ssl_cert_expiry_days:.0f}"
            } for r in ssl_warning],
        }

        return report

    def print_report(self, report: Dict):
        """打印报告"""
        print("\n" + "=" * 60)
        print("服务拨测报告")
        print("=" * 60)
        print(f"时间: {report['timestamp']}")

        s = report['summary']
        print(f"\n--- 概览 ---")
        print(f"  总端点数:    {s['total']}")
        print(f"  成功:        {s['success']} ({s['success_rate']})")
        print(f"  失败:        {s['failed']}")

        if s['failed'] > 0:
            print(f"\n--- 失败端点 ---")
            for item in report['failed']:
                print(f"  ✗ {item['instance']:<40} | {item['module']}")
                if item['error']:
                    print(f"    错误: {item['error'][:80]}")

        if report['slow']:
            print(f"\n--- 慢响应 (>2s) ---")
            for item in report['slow']:
                print(f"  ⚠ {item['instance']:<40} | {item['duration']}")

        if report['ssl_expiring']:
            print(f"\n--- SSL 证书即将过期 (<30天) ---")
            for item in report['ssl_expiring']:
                print(f"  ⚠ {item['instance']:<40} | {item['days_left']} 天")

        # 退出码
        if s['failed'] > 0:
            print(f"\n❌ 有 {s['failed']} 个端点不可用")
            return 1
        else:
            print(f"\n✅ 所有端点正常")
            return 0

def main():
    prometheus_url = sys.argv[1] if len(sys.argv) > 1 else 'http://prometheus:9090'

    prober = PrometheusProber(prometheus_url)
    checker = EndpointChecker(prober)

    report = checker.check_all()
    exit_code = checker.print_report(report)

    # 保存 JSON 报告
    report_file = f"/tmp/probe_report_{datetime.now():%Y%m%d_%H%M%S}.json"
    with open(report_file, 'w') as f:
        json.dump(report, f, indent=2, ensure_ascii=False, default=str)
    print(f"\n报告已保存: {report_file}")

    sys.exit(exit_code)

if __name__ == '__main__':
    main()

1.2 直接拨测脚本(不依赖 Prometheus)

当 Prometheus 不可用时,可以直接对目标进行拨测:

#!/usr/bin/env python3
"""直接 HTTP/TCP 拨测脚本"""

import requests
import socket
import ssl
from datetime import datetime, timezone
from urllib.parse import urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict
import json

class DirectProber:
    """直接探测器"""

    def __init__(self, timeout: int = 10):
        self.timeout = timeout

    def http_probe(self, url: str, expected_status: int = 200) -> Dict:
        """HTTP 探测"""
        result = {
            'type': 'http',
            'target': url,
            'timestamp': datetime.now(timezone.utc).isoformat(),
        }

        try:
            resp = requests.get(
                url,
                timeout=self.timeout,
                allow_redirects=True,
                verify=True,
                headers={'User-Agent': 'MonitoringProbe/1.0'}
            )
            result['success'] = resp.status_code == expected_status
            result['status_code'] = resp.status_code
            result['response_time'] = resp.elapsed.total_seconds()
            result['content_length'] = len(resp.content)
            result['ssl_verified'] = True

            # 检查 SSL 证书
            if url.startswith('https://'):
                parsed = urlparse(url)
                cert_info = self.check_ssl_cert(parsed.hostname, parsed.port or 443)
                result['ssl_info'] = cert_info

        except requests.exceptions.SSLError as e:
            result['success'] = False
            result['error'] = f'SSL Error: {e}'
        except requests.exceptions.ConnectionError as e:
            result['success'] = False
            result['error'] = f'Connection Error: {e}'
        except requests.exceptions.Timeout:
            result['success'] = False
            result['error'] = f'Timeout after {self.timeout}s'
        except Exception as e:
            result['success'] = False
            result['error'] = str(e)

        return result

    def tcp_probe(self, host: str, port: int) -> Dict:
        """TCP 端口探测"""
        result = {
            'type': 'tcp',
            'target': f'{host}:{port}',
            'timestamp': datetime.now(timezone.utc).isoformat(),
        }

        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(self.timeout)
            start = datetime.now()
            sock.connect((host, port))
            duration = (datetime.now() - start).total_seconds()
            sock.close()

            result['success'] = True
            result['response_time'] = duration
        except socket.timeout:
            result['success'] = False
            result['error'] = f'Timeout after {self.timeout}s'
        except ConnectionRefusedError:
            result['success'] = False
            result['error'] = 'Connection refused'
        except Exception as e:
            result['success'] = False
            result['error'] = str(e)

        return result

    def check_ssl_cert(self, hostname: str, port: int = 443) -> Dict:
        """检查 SSL 证书"""
        context = ssl.create_default_context()
        result = {}

        try:
            with socket.create_connection((hostname, port), timeout=self.timeout) as sock:
                with context.wrap_socket(sock, server_hostname=hostname) as ssock:
                    cert = ssock.getpeercert()

                    result['issuer'] = dict(x[0] for x in cert['issuer'])
                    result['subject'] = dict(x[0] for x in cert['subject'])
                    result['not_before'] = cert['notBefore']
                    result['not_after'] = cert['notAfter']

                    # 计算剩余天数
                    expiry_date = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
                    days_left = (expiry_date - datetime.now()).days
                    result['days_until_expiry'] = days_left
                    result['expiring_soon'] = days_left < 30

        except Exception as e:
            result['error'] = str(e)

        return result

    def batch_probe(self, targets: List[str], max_workers: int = 10) -> List[Dict]:
        """批量并行探测"""
        results = []

        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = []
            for target in targets:
                if target.startswith('http://') or target.startswith('https://'):
                    futures.append(executor.submit(self.http_probe, target))
                else:
                    # 解析 host:port
                    parts = target.split(':')
                    host = parts[0]
                    port = int(parts[1]) if len(parts) > 1 else 80
                    futures.append(executor.submit(self.tcp_probe, host, port))

            for future in as_completed(futures):
                results.append(future.result())

        return results

# === 配置 ===
TARGETS = [
    'https://api.example.com/health',
    'https://www.example.com',
    'https://admin.example.com',
    'db.internal:5432',
    'redis.internal:6379',
    'rabbitmq.internal:5672',
    'https://grafana.example.com/api/health',
    'https://prometheus.example.com/-/healthy',
]

# === 执行 ===
prober = DirectProber(timeout=10)
results = prober.batch_probe(TARGETS, max_workers=5)

# 输出结果
print("\n=== 批量拨测结果 ===\n")
success_count = 0
for r in results:
    status = "✓" if r['success'] else "✗"
    time_str = f"{r.get('response_time', 0):.3f}s" if r.get('response_time') else 'N/A'
    print(f"  {status} {r['target']:<45} {time_str:>8}")
    if not r['success']:
        print(f"    └─ {r.get('error', 'unknown error')}")
    if 'ssl_info' in r and r['ssl_info'].get('days_until_expiry') is not None:
        days = r['ssl_info']['days_until_expiry']
        marker = "⚠" if days < 30 else " "
        print(f"    └─{marker} SSL 证书剩余 {days} 天")

print(f"\n总计: {len(results)}  成功: {sum(1 for r in results if r['success'])}  "
      f"失败: {sum(1 for r in results if not r['success'])}")

二、SSL 证书到期检查

2.1 批量证书检查脚本

#!/usr/bin/env python3
"""
SSL 证书到期检查脚本
批量检查多域名证书有效期,提前告警
"""

import ssl
import socket
from datetime import datetime, timedelta
from typing import List, Dict
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import smtplib
from email.mime.text import MIMEText
import sys

class SSLCertChecker:
    """SSL 证书检查器"""

    def __init__(self, warning_days: int = 30, critical_days: int = 7, timeout: int = 10):
        self.warning_days = warning_days
        self.critical_days = critical_days
        self.timeout = timeout

    def check_cert(self, hostname: str, port: int = 443) -> Dict:
        """检查单个域名的 SSL 证书"""
        result = {
            'hostname': hostname,
            'port': port,
            'checked_at': datetime.now().isoformat(),
        }

        context = ssl.create_default_context()

        try:
            with socket.create_connection((hostname, port), timeout=self.timeout) as sock:
                with context.wrap_socket(sock, server_hostname=hostname) as ssock:
                    cert = ssock.getpeercert()

                    # 解析证书信息
                    result['issuer'] = dict(x[0] for x in cert['issuer'])
                    result['subject'] = dict(x[0] for x in cert['subject'])
                    result['not_before'] = cert['notBefore']
                    result['not_after'] = cert['notAfter']

                    # 获取 SAN(Subject Alternative Names)
                    san_list = []
                    for key, value in cert.get('subjectAltName', []):
                        san_list.append(value)
                    result['san'] = san_list

                    # 计算剩余天数
                    expiry_date = datetime.strptime(
                        cert['notAfter'], '%b %d %H:%M:%S %Y %Z'
                    )
                    days_left = (expiry_date - datetime.now()).days
                    result['days_left'] = days_left
                    result['expiry_date'] = expiry_date.strftime('%Y-%m-%d')

                    # 状态分级
                    if days_left <= self.critical_days:
                        result['status'] = 'CRITICAL'
                    elif days_left <= self.warning_days:
                        result['status'] = 'WARNING'
                    else:
                        result['status'] = 'OK'

                    result['success'] = True

        except ssl.SSLCertVerificationError as e:
            result['success'] = False
            result['status'] = 'ERROR'
            result['error'] = f'证书验证失败: {e}'
        except socket.timeout:
            result['success'] = False
            result['status'] = 'ERROR'
            result['error'] = f'连接超时 ({self.timeout}s)'
        except Exception as e:
            result['success'] = False
            result['status'] = 'ERROR'
            result['error'] = str(e)

        return result

    def batch_check(self, domains: List[str], max_workers: int = 10) -> List[Dict]:
        """批量检查"""
        results = []

        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {
                executor.submit(self.check_cert, domain): domain
                for domain in domains
            }

            for future in as_completed(futures):
                domain = futures[future]
                try:
                    result = future.result()
                    results.append(result)
                except Exception as e:
                    results.append({
                        'hostname': domain,
                        'success': False,
                        'status': 'ERROR',
                        'error': str(e)
                    })

        # 按紧急程度排序
        status_order = {'CRITICAL': 0, 'WARNING': 1, 'ERROR': 2, 'OK': 3}
        results.sort(key=lambda x: (
            status_order.get(x.get('status', 'ERROR'), 99),
            x.get('days_left', 9999)
        ))

        return results

    def print_report(self, results: List[Dict]):
        """打印报告"""
        print("\n" + "=" * 70)
        print("SSL 证书到期检查报告")
        print("=" * 70)
        print(f"检查时间: {datetime.now():%Y-%m-%d %H:%M:%S}")
        print(f"告警阈值: WARNING < {self.warning_days}天, CRITICAL < {self.critical_days}天")
        print("=" * 70)

        # 统计
        stats = {'OK': 0, 'WARNING': 0, 'CRITICAL': 0, 'ERROR': 0}
        for r in results:
            stats[r.get('status', 'ERROR')] = stats.get(r.get('status', 'ERROR'), 0) + 1

        print(f"\n总计: {len(results)}  "
              f"OK: {stats['OK']}  "
              f"WARNING: {stats['WARNING']}  "
              f"CRITICAL: {stats['CRITICAL']}  "
              f"ERROR: {stats['ERROR']}")

        # 详细列表
        print(f"\n{'域名':<35} {'状态':<10} {'剩余天数':<10} {'到期日期':<15} {'颁发者'}")
        print("-" * 90)

        for r in results:
            hostname = r['hostname'][:34]
            status = r.get('status', 'ERROR')

            if status == 'OK':
                marker = '  '
            elif status == 'WARNING':
                marker = '⚠ '
            elif status == 'CRITICAL':
                marker = '🔴'
            else:
                marker = '✗ '

            days = str(r.get('days_left', 'N/A'))
            expiry = r.get('expiry_date', 'N/A')
            issuer = r.get('issuer', {}).get('organizationName', 'N/A')[:25]

            print(f"{marker} {hostname:<33} {status:<10} {days:<10} {expiry:<15} {issuer}")

            if not r.get('success'):
                print(f"   └─ 错误: {r.get('error', 'unknown')[:70]}")

        # 退出码
        if stats['CRITICAL'] > 0 or stats['ERROR'] > 0:
            return 2
        elif stats['WARNING'] > 0:
            return 1
        return 0

    def send_alert_email(self, results: List[Dict], recipients: List[str],
                         smtp_server: str = 'localhost'):
        """发送告警邮件"""
        issues = [r for r in results if r.get('status') in ('CRITICAL', 'WARNING', 'ERROR')]
        if not issues:
            return

        subject = f"[SSL告警] {len(issues)} 个证书需要关注"
        body = "以下 SSL 证书即将过期或存在问题:\n\n"

        for r in issues:
            body += f"域名: {r['hostname']}\n"
            body += f"状态: {r.get('status', 'ERROR')}\n"
            if r.get('days_left') is not None:
                body += f"剩余天数: {r['days_left']}\n"
                body += f"到期日期: {r.get('expiry_date', 'N/A')}\n"
            if not r.get('success'):
                body += f"错误: {r.get('error', 'N/A')}\n"
            body += "\n"

        msg = MIMEText(body)
        msg['Subject'] = subject
        msg['From'] = 'ssl-monitor@example.com'
        msg['To'] = ', '.join(recipients)

        try:
            with smtplib.SMTP(smtp_server) as server:
                server.sendmail(msg['From'], recipients, msg.as_string())
            print(f"告警邮件已发送至 {len(recipients)} 个收件人")
        except Exception as e:
            print(f"邮件发送失败: {e}")

# === 配置 ===
DOMAINS = [
    'www.example.com',
    'api.example.com',
    'admin.example.com',
    'grafana.example.com',
    'prometheus.example.com',
    'alertmanager.example.com',
    'registry.example.com',
    'git.example.com',
    'jenkins.example.com',
    'vault.example.com',
]

# === 执行 ===
checker = SSLCertChecker(warning_days=30, critical_days=7)
results = checker.batch_check(DOMAINS)
exit_code = checker.print_report(results)

# 保存结果
report_file = f"/tmp/ssl_check_{datetime.now():%Y%m%d}.json"
with open(report_file, 'w') as f:
    json.dump(results, f, indent=2, ensure_ascii=False, default=str)
print(f"\n详细报告: {report_file}")

# 发送告警邮件(仅有问题时)
# checker.send_alert_email(results, ['ops@example.com'])

sys.exit(exit_code)

三、配置漂移检测

3.1 Prometheus 配置验证

#!/usr/bin/env python3
"""
Prometheus 配置漂移检测
检查 Prometheus 配置文件、规则文件、Target 状态
"""

import requests
import yaml
import json
from datetime import datetime
from typing import List, Dict, Optional
import hashlib
import os

class PrometheusConfigChecker:
    """Prometheus 配置检查器"""

    def __init__(self, prometheus_url: str, config_path: str = '/etc/prometheus/prometheus.yml'):
        self.prometheus_url = prometheus_url.rstrip('/')
        self.config_path = config_path
        self.session = requests.Session()

    def check_config_loaded(self) -> Dict:
        """检查 Prometheus 加载的配置"""
        result = {'check': 'config_loaded', 'passed': True}

        try:
            resp = self.session.get(
                f"{self.prometheus_url}/api/v1/status/config",
                timeout=10
            )
            data = resp.json()

            if data['status'] == 'success':
                loaded_config = yaml.safe_load(data['data']['yaml'])
                result['scrape_configs_count'] = len(loaded_config.get('scrape_configs', []))
                result['rule_files'] = loaded_config.get('rule_files', [])

                # 与磁盘配置比较
                if os.path.exists(self.config_path):
                    with open(self.config_path) as f:
                        disk_config = yaml.safe_load(f)

                    disk_scrape_count = len(disk_config.get('scrape_configs', []))
                    if disk_scrape_count != result['scrape_configs_count']:
                        result['passed'] = False
                        result['error'] = (
                            f"配置不一致: 磁盘={disk_scrape_count}个scrape_config, "
                            f"运行中={result['scrape_configs_count']}个"
                        )
            else:
                result['passed'] = False
                result['error'] = '无法获取运行配置'

        except Exception as e:
            result['passed'] = False
            result['error'] = str(e)

        return result

    def check_targets_health(self) -> Dict:
        """检查所有 Target 的健康状态"""
        result = {'check': 'targets_health', 'passed': True, 'details': []}

        try:
            resp = self.session.get(
                f"{self.prometheus_url}/api/v1/targets",
                params={'state': 'active'},
                timeout=10
            )
            data = resp.json()

            active_targets = data['data']['activeTargets']
            total = len(active_targets)
            up = sum(1 for t in active_targets if t['health'] == 'up')
            down = [t for t in active_targets if t['health'] == 'down']

            result['total_targets'] = total
            result['up_targets'] = up
            result['down_targets'] = len(down)

            if down:
                result['passed'] = len(down) / total < 0.1  # 超过10% down 则告警
                result['details'] = [{
                    'job': t['labels'].get('job', ''),
                    'instance': t['labels'].get('instance', ''),
                    'last_error': t.get('lastError', ''),
                    'last_scrape': t.get('lastScrape', ''),
                } for t in down]

        except Exception as e:
            result['passed'] = False
            result['error'] = str(e)

        return result

    def check_rules_loaded(self) -> Dict:
        """检查告警规则加载状态"""
        result = {'check': 'rules_loaded', 'passed': True, 'details': {}}

        try:
            resp = self.session.get(
                f"{self.prometheus_url}/api/v1/rules",
                timeout=10
            )
            data = resp.json()

            groups = data['data']['groups']
            total_rules = 0
            alerting_rules = 0
            recording_rules = 0
            firing_alerts = 0

            for group in groups:
                for rule in group['rules']:
                    total_rules += 1
                    if rule['type'] == 'alerting':
                        alerting_rules += 1
                        for alert in rule.get('alerts', []):
                            if alert['state'] == 'firing':
                                firing_alerts += 1
                    elif rule['type'] == 'recording':
                        recording_rules += 1

            result['total_rules'] = total_rules
            result['alerting_rules'] = alerting_rules
            result['recording_rules'] = recording_rules
            result['firing_alerts'] = firing_alerts
            result['groups'] = len(groups)

        except Exception as e:
            result['passed'] = False
            result['error'] = str(e)

        return result

    def check_flags(self) -> Dict:
        """检查 Prometheus 启动参数"""
        result = {'check': 'flags', 'passed': True, 'issues': []}

        try:
            resp = self.session.get(
                f"{self.prometheus_url}/api/v1/status/flags",
                timeout=10
            )
            data = resp.json()
            flags = data['data']

            # 检查关键配置
            checks = [
                ('retention_time', '15d', lambda v: v >= '15d'),
                ('storage.tsdb.path', '/data/prometheus', lambda v: True),
                ('web.enable-lifecycle', 'true', lambda v: v == 'true'),
            ]

            for flag_name, expected, validator in checks:
                actual = flags.get(flag_name, 'not set')
                if not validator(actual):
                    result['passed'] = False
                    result['issues'].append(f"{flag_name}: expected={expected}, actual={actual}")

            result['flags'] = flags

        except Exception as e:
            result['passed'] = False
            result['error'] = str(e)

        return result

    def check_tsdb_stats(self) -> Dict:
        """检查 TSDB 统计信息"""
        result = {'check': 'tsdb_stats', 'passed': True}

        try:
            resp = self.session.get(
                f"{self.prometheus_url}/api/v1/status/tsdb",
                timeout=10
            )
            data = resp.json()
            stats = data['data']

            result['head_series'] = stats.get('headStats', {}).get('numSeries', 0)
            result['head_chunks'] = stats.get('headStats', {}).get('numLabelPairs', 0)
            result['block_count'] = len(stats.get('blocks', []))

            # 检查序列数是否异常增长
            if result['head_series'] > 5_000_000:
                result['passed'] = False
                result['warning'] = f"序列数过多: {result['head_series']:,}(可能存在高基数问题)"

        except Exception as e:
            result['passed'] = False
            result['error'] = str(e)

        return result

    def run_all_checks(self) -> List[Dict]:
        """运行所有检查"""
        return [
            self.check_config_loaded(),
            self.check_targets_health(),
            self.check_rules_loaded(),
            self.check_flags(),
            self.check_tsdb_stats(),
        ]

# === 执行 ===
checker = PrometheusConfigChecker('http://prometheus:9090')
results = checker.run_all_checks()

print("\n" + "=" * 60)
print("Prometheus 配置巡检报告")
print("=" * 60)

all_passed = True
for r in results:
    status = "✓ PASS" if r['passed'] else "✗ FAIL"
    print(f"\n[{status}] {r['check']}")

    if r['check'] == 'targets_health':
        print(f"  Targets: {r.get('up_targets', 0)}/{r.get('total_targets', 0)} UP")
        if r.get('down_targets', 0) > 0:
            print(f"  Down: {r['down_targets']}")
            for d in r.get('details', [])[:5]:
                print(f"    - {d['job']}/{d['instance']}: {d['last_error'][:60]}")

    elif r['check'] == 'rules_loaded':
        print(f"  规则: {r.get('total_rules', 0)} (告警={r.get('alerting_rules', 0)}, "
              f"记录={r.get('recording_rules', 0)})")
        print(f"  正在告警: {r.get('firing_alerts', 0)}")

    elif r['check'] == 'tsdb_stats':
        print(f"  序列数: {r.get('head_series', 0):,}")
        print(f"  Block数: {r.get('block_count', 0)}")

    if not r['passed']:
        all_passed = False
        if 'error' in r:
            print(f"  错误: {r['error']}")
        if 'warning' in r:
            print(f"  警告: {r['warning']}")
        if 'issues' in r:
            for issue in r['issues']:
                print(f"  - {issue}")

print("\n" + "=" * 60)
if all_passed:
    print("✅ 所有检查通过")
else:
    print("❌ 存在检查失败项,请处理")

四、告警规则验证

4.1 Prometheus 规则语法检查

#!/usr/bin/env bash
#
# prometheus_rules_check.sh - Prometheus 告警规则语法验证
#
set -euo pipefail

RULES_DIR="${1:-/etc/prometheus/rules}"
PROMTOOL="${PROMTOOL:-promtool}"
ERRORS=0
CHECKED=0

echo "========================================"
echo "Prometheus 规则文件检查"
echo "========================================"
echo "目录: ${RULES_DIR}"
echo ""

# 检查 promtool 是否可用
if ! command -v "${PROMTOOL}" &>/dev/null; then
    echo "✗ promtool 未安装"
    exit 1
fi

# 检查所有规则文件
for rule_file in "${RULES_DIR}"/*.yml "${RULES_DIR}"/*.yaml; do
    [[ -f "$rule_file" ]] || continue

    CHECKED=$((CHECKED + 1))
    filename=$(basename "$rule_file")

    if "${PROMTOOL}" check rules "$rule_file" 2>/dev/null; then
        echo "  ✓ ${filename}"

        # 统计规则数
        rule_count=$(yq e '.groups[].rules | length' "$rule_file" 2>/dev/null | paste -sd+ | bc || echo "?")
        echo "    规则数: ${rule_count}"
    else
        echo "  ✗ ${filename}"
        "${PROMTOOL}" check rules "$rule_file" 2>&1 | sed 's/^/    /'
        ERRORS=$((ERRORS + 1))
    fi
done

echo ""
echo "========================================"
echo "检查完成: ${CHECKED} 个文件, ${ERRORS} 个错误"

if [[ ${ERRORS} -gt 0 ]]; then
    exit 1
fi

echo "✅ 所有规则文件语法正确"

4.2 告警规则测试

#!/usr/bin/env python3
"""
告警规则测试脚本
使用 promtool test rules 验证告警规则在不同数据场景下的触发情况
"""

import subprocess
import yaml
import json
import tempfile
import os
from typing import List, Dict
from datetime import datetime

class AlertRuleTester:
    """告警规则测试器"""

    def __init__(self, rules_file: str):
        self.rules_file = rules_file
        self.promtool = 'promtool'

    def generate_test_file(self, test_cases: List[Dict]) -> str:
        """生成 promtool 测试文件"""
        test_config = {
            'rule_files': [self.rules_file],
            'evaluation_interval': '1m',
            'tests': test_cases
        }

        # 写入临时文件
        fd, tmp_path = tempfile.mkstemp(suffix='.yml', prefix='prom_test_')
        with os.fdopen(fd, 'w') as f:
            yaml.dump(test_config, f, default_flow_style=False)

        return tmp_path

    def run_test(self, test_file: str) -> Dict:
        """运行测试"""
        result = subprocess.run(
            [self.promtool, 'test', 'rules', test_file],
            capture_output=True,
            text=True
        )

        return {
            'success': result.returncode == 0,
            'stdout': result.stdout,
            'stderr': result.stderr,
        }

    def test_high_cpu_alert(self):
        """测试高 CPU 告警规则"""
        test_cases = [
            # 场景1:CPU 正常,不应告警
            {
                'interval': '1m',
                'input_series': [{
                    'series': 'node_cpu_seconds_total{cpu="0",mode="idle",instance="web-01"}',
                    'values': '0+100x10'  # idle 持续增长
                }],
                'alert_rule_test': [{
                    'eval_time': '10m',
                    'alertname': 'HighCpuUsage',
                    'exp_alerts': []  # 期望不触发
                }]
            },
            # 场景2:CPU 飙高,应告警
            {
                'interval': '1m',
                'input_series': [{
                    'series': 'node_cpu_seconds_total{cpu="0",mode="idle",instance="web-01"}',
                    'values': '0+10x10'  # idle 增长慢,CPU 高
                }],
                'alert_rule_test': [{
                    'eval_time': '10m',
                    'alertname': 'HighCpuUsage',
                    'exp_alerts': [{
                        'exp_labels': {
                            'severity': 'warning',
                            'instance': 'web-01'
                        },
                        'exp_annotations': {}
                    }]
                }]
            }
        ]

        test_file = self.generate_test_file(test_cases)
        result = self.run_test(test_file)
        os.unlink(test_file)

        return result

    def test_disk_space_alert(self):
        """测试磁盘空间告警"""
        test_cases = [
            # 磁盘使用率 > 85% 持续 5 分钟
            {
                'interval': '1m',
                'input_series': [
                    {
                        'series': 'node_filesystem_size_bytes{mountpoint="/",instance="db-01"}',
                        'values': '100000000000x10'
                    },
                    {
                        'series': 'node_filesystem_free_bytes{mountpoint="/",instance="db-01"}',
                        'values': '10000000000x10'  # 10% free
                    }
                ],
                'alert_rule_test': [{
                    'eval_time': '5m',
                    'alertname': 'DiskSpaceWarning',
                    'exp_alerts': [{
                        'exp_labels': {
                            'severity': 'warning',
                            'instance': 'db-01'
                        },
                        'exp_annotations': {}
                    }]
                }]
            }
        ]

        test_file = self.generate_test_file(test_cases)
        result = self.run_test(test_file)
        os.unlink(test_file)

        return result

# === 使用示例 ===
tester = AlertRuleTester('/etc/prometheus/rules/node_alerts.yml')

print("=== 告警规则测试 ===\n")

tests = [
    ('HighCpuUsage', tester.test_high_cpu_alert),
    ('DiskSpaceWarning', tester.test_disk_space_alert),
]

all_passed = True
for name, test_func in tests:
    result = test_func()
    status = "✓ PASS" if result['success'] else "✗ FAIL"
    print(f"  {status}  {name}")
    if not result['success']:
        all_passed = False
        print(f"    {result['stderr'][:200]}")

print(f"\n{'✅ 所有测试通过' if all_passed else '❌ 存在测试失败'}")

五、自动化报表生成

5.1 巡检报表生成器

#!/usr/bin/env python3
"""
Prometheus 自动化巡检报表生成器
汇总所有检查项,生成 HTML 报表
"""

import requests
import json
from datetime import datetime, timedelta
from typing import Dict, List
import os

class InspectionReportGenerator:
    """巡检报表生成器"""

    def __init__(self, prometheus_url: str):
        self.prometheus_url = prometheus_url.rstrip('/')
        self.session = requests.Session()

    def query(self, promql: str) -> List[Dict]:
        """执行 PromQL 查询"""
        resp = self.session.get(
            f"{self.prometheus_url}/api/v1/query",
            params={'query': promql},
            timeout=30
        )
        data = resp.json()
        return data.get('data', {}).get('result', [])

    def collect_metrics(self) -> Dict:
        """收集巡检指标"""
        metrics = {}

        # 1. Prometheus 自身状态
        metrics['prometheus_up'] = self.query('prometheus_tsdb_head_series')
        metrics['prometheus_ingest_rate'] = self.query(
            'sum(rate(prometheus_tsdb_head_samples_appended_total[5m]))'
        )
        metrics['prometheus_storage'] = self.query(
            'prometheus_tsdb_head_samples_appended_total'
        )

        # 2. Target 健康
        metrics['targets_up'] = self.query(
            'count(up == 1)'
        )
        metrics['targets_down'] = self.query(
            'count(up == 0)'
        )

        # 3. 告警状态
        metrics['alerts_firing'] = self.query(
            'sum(ALERTS{alertstate="firing"}) by (alertname, severity)'
        )
        metrics['alerts_pending'] = self.query(
            'sum(ALERTS{alertstate="pending"}) by (alertname)'
        )

        # 4. 节点健康
        metrics['nodes_total'] = self.query('count(node_uname_info)')
        metrics['nodes_down'] = self.query('count(up{job="node_exporter"} == 0)')

        # 5. 资源使用率 Top 5
        metrics['top_cpu'] = self.query(
            'topk(5, 100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100))'
        )
        metrics['top_memory'] = self.query(
            'topk(5, 100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))'
        )
        metrics['top_disk'] = self.query(
            'topk(5, 100 * (1 - node_filesystem_free_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}))'
        )

        # 6. 网络延迟
        metrics['http_latency_p99'] = self.query(
            'histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, handler))'
        )

        # 7. 服务可用性
        metrics['service_availability'] = self.query(
            'avg by(service)(rate(http_requests_total{status!~"5.."}[5m]) / rate(http_requests_total[5m])) * 100'
        )

        return metrics

    def generate_html_report(self) -> str:
        """生成 HTML 报表"""
        metrics = self.collect_metrics()
        now = datetime.now()

        # 解析关键指标
        targets_up = int(float(metrics['targets_up'][0]['value'][1])) if metrics['targets_up'] else 0
        targets_down = int(float(metrics['targets_down'][0]['value'][1])) if metrics['targets_down'] else 0
        total_targets = targets_up + targets_down
        availability = (targets_up / total_targets * 100) if total_targets > 0 else 0

        firing_alerts = metrics['alerts_firing']
        alert_count = len(firing_alerts)

        # Top CPU 表格行
        cpu_rows = ""
        for item in metrics.get('top_cpu', [])[:5]:
            instance = item['metric'].get('instance', 'N/A')
            value = float(item['value'][1])
            cpu_rows += f"<tr><td>{instance}</td><td>{value:.1f}%</td></tr>"

        # 告警表格行
        alert_rows = ""
        for item in firing_alerts:
            name = item['metric'].get('alertname', 'N/A')
            severity = item['metric'].get('severity', 'N/A')
            count = item['value'][1]
            color = '#f44336' if severity == 'critical' else '#ff9800'
            alert_rows += f"""<tr>
                <td style="color:{color};font-weight:bold">{severity}</td>
                <td>{name}</td>
                <td>{count}</td>
            </tr>"""

        html = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>巡检报告 - {now:%Y-%m-%d %H:%M}</title>
    <style>
        body {{ font-family: -apple-system, 'Segoe UI', sans-serif; margin: 0; padding: 20px; background: #f0f2f5; }}
        .header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;
                   padding: 30px; border-radius: 10px; margin-bottom: 20px; }}
        .header h1 {{ margin: 0; font-size: 24px; }}
        .header .meta {{ margin-top: 10px; opacity: 0.9; }}
        .grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 20px; }}
        .card {{ background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }}
        .card .value {{ font-size: 2.5em; font-weight: 700; }}
        .card .label {{ color: #666; font-size: 0.85em; margin-top: 5px; }}
        .card.ok .value {{ color: #4CAF50; }}
        .card.warn .value {{ color: #FF9800; }}
        .card.error .value {{ color: #f44336; }}
        table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px;
                overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }}
        th {{ background: #f5f5f5; padding: 12px; text-align: left; font-weight: 600; }}
        td {{ padding: 10px 12px; border-bottom: 1px solid #eee; }}
        .section {{ margin-bottom: 20px; }}
        .section h2 {{ color: #333; font-size: 18px; margin-bottom: 10px; }}
    </style>
</head>
<body>
    <div class="header">
        <h1>监控巡检报告</h1>
        <div class="meta">生成时间: {now:%Y-%m-%d %H:%M:%S} | Prometheus: {self.prometheus_url}</div>
    </div>

    <div class="grid">
        <div class="card {'ok' if availability > 95 else 'error' if availability < 90 else 'warn'}">
            <div class="value">{availability:.1f}%</div>
            <div class="label">Target 可用率 ({targets_up}/{total_targets})</div>
        </div>
        <div class="card {'ok' if alert_count == 0 else 'error'}">
            <div class="value">{alert_count}</div>
            <div class="label">活跃告警数</div>
        </div>
        <div class="card ok">
            <div class="value">{targets_up}</div>
            <div class="label">在线 Target</div>
        </div>
        <div class="card {'ok' if targets_down == 0 else 'error'}">
            <div class="value">{targets_down}</div>
            <div class="label">离线 Target</div>
        </div>
    </div>

    <div class="section">
        <h2>活跃告警</h2>
        <table>
            <tr><th>级别</th><th>告警名称</th><th>实例数</th></tr>
            {alert_rows if alert_rows else '<tr><td colspan="3" style="text-align:center;color:#999">暂无活跃告警</td></tr>'}
        </table>
    </div>

    <div class="section">
        <h2>CPU 使用率 Top 5</h2>
        <table>
            <tr><th>实例</th><th>CPU 使用率</th></tr>
            {cpu_rows if cpu_rows else '<tr><td colspan="2" style="text-align:center;color:#999">无数据</td></tr>'}
        </table>
    </div>

    <div class="section" style="color:#999;font-size:0.85em;text-align:center;margin-top:30px;">
        自动生成 by Prometheus 巡检脚本 | {now:%Y-%m-%d %H:%M:%S}
    </div>
</body>
</html>"""

        return html

    def save_report(self, output_dir: str = '/var/www/reports'):
        """保存报表"""
        os.makedirs(output_dir, exist_ok=True)

        html = self.generate_html_report()

        # 保存带时间戳的文件
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filepath = os.path.join(output_dir, f'inspection_{timestamp}.html')
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(html)

        # 同时保存为 latest.html
        latest_path = os.path.join(output_dir, 'latest.html')
        with open(latest_path, 'w', encoding='utf-8') as f:
            f.write(html)

        print(f"巡检报表已生成: {filepath}")
        print(f"最新报表: {latest_path}")
        return filepath

# === 执行 ===
generator = InspectionReportGenerator('http://prometheus:9090')
generator.save_report('/tmp/reports')

六、定时巡检与告警

6.1 巡检调度脚本

#!/usr/bin/env bash
#
# inspection_scheduler.sh - 巡检任务调度器
#
set -euo pipefail

PROMETHEUS_URL="${PROMETHEUS_URL:-http://prometheus:9090}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPORT_DIR="${REPORT_DIR:-/tmp/reports}"
LOG_FILE="/var/log/inspection.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }

mkdir -p "$REPORT_DIR"

# 1. 服务拨测(每 5 分钟)
run_probes() {
    log "执行服务拨测..."
    python3 "${SCRIPT_DIR}/probe_services.py" "$PROMETHEUS_URL" \
        > "${REPORT_DIR}/probe_result.json" 2>&1 || \
        log "拨测失败"
}

# 2. SSL 证书检查(每天 9:00)
check_ssl() {
    log "执行 SSL 证书检查..."
    python3 "${SCRIPT_DIR}/ssl_checker.py" \
        > "${REPORT_DIR}/ssl_result.json" 2>&1 || \
        log "SSL 检查失败"
}

# 3. Prometheus 配置检查(每小时)
check_prometheus() {
    log "执行 Prometheus 配置检查..."
    python3 "${SCRIPT_DIR}/prometheus_config_check.py" "$PROMETHEUS_URL" \
        > "${REPORT_DIR}/config_result.json" 2>&1 || \
        log "配置检查失败"
}

# 4. 生成巡检报表(每小时)
generate_report() {
    log "生成巡检报表..."
    python3 "${SCRIPT_DIR}/generate_report.py" "$PROMETHEUS_URL" "$REPORT_DIR" \
        2>&1 | tee -a "$LOG_FILE" || \
        log "报表生成失败"
}

# 5. 告警规则验证(每次部署后手动执行)
validate_rules() {
    log "验证告警规则..."
    bash "${SCRIPT_DIR}/prometheus_rules_check.sh" /etc/prometheus/rules/ \
        2>&1 | tee -a "$LOG_FILE" || \
        log "规则验证失败"
}

# === 主循环 ===
while true; do
    CURRENT_HOUR=$(date +%H)
    CURRENT_MIN=$(date +%M)

    # 每 5 分钟拨测
    if (( CURRENT_MIN % 5 == 0 )); then
        run_probes
    fi

    # 每小时检查配置和生成报表
    if (( CURRENT_MIN == 0 )); then
        check_prometheus
        generate_report
    fi

    # 每天 9:00 检查 SSL
    if [[ "$CURRENT_HOUR" == "09" && "$CURRENT_MIN" == "00" ]]; then
        check_ssl
    fi

    sleep 60
done

6.2 systemd timer 配置

# /etc/systemd/system/prom-inspection.service
[Unit]
Description=Prometheus Automated Inspection
After=network.target

[Service]
Type=oneshot
ExecStart=/opt/scripts/inspection/run_inspection.sh
User=monitor
Group=monitor
Environment=PROMETHEUS_URL=http://prometheus:9090
Environment=REPORT_DIR=/var/lib/inspection/reports
TimeoutStartSec=300

# /etc/systemd/system/prom-inspection.timer
[Unit]
Description=Run Prometheus Inspection Hourly

[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target
# 启用定时任务
systemctl daemon-reload
systemctl enable --now prom-inspection.timer

# 查看下次执行时间
systemctl list-timers prom-inspection.timer

# 手动触发
systemctl start prom-inspection.service

总结

监控系统的可靠性不能靠"出了问题再看"来保证,必须有一套主动巡检机制持续验证。本文围绕 Prometheus 构建的工具集覆盖了巡检的核心维度:

  1. 拨测是最直接的可用性验证:通过 Blackbox Exporter 或直接 HTTP/TCP 探测,持续验证服务端点是否可达、响应是否正常。并行探测 + 阈值判断,几百个端点 10 秒内完成
  2. SSL 证书到期是高频事故源:批量检查所有域名证书,按 WARNING(30天)/CRITICAL(7天) 分级告警。自动发邮件通知,避免证书过期导致服务不可用
  3. 配置漂移要持续检测:对比磁盘配置与运行时配置、检查 Target 健康状态、验证规则加载数量、监控 TSDB 序列数。任何一个维度异常都可能导致监控盲区
  4. 告警规则要测试:用 promtool test rules 构造测试数据,验证告警在正常和异常场景下的触发行为。规则变更不上测试,等于裸奔上线
  5. 报表要自动化:把所有检查结果汇总为 HTML 报表,定时生成、保留历史、提供 latest 链接。让运维人员每天打开一个页面就能掌握全局状态
  6. 调度要可靠:用 systemd timer 替代 cron,获得更好的日志集成和失败重试能力。巡检脚本自身的执行状态也要纳入监控

巡检的本质是"监控监控系统"——把监控覆盖的每个环节(采集→存储→规则→告警→通知)都纳入检查范围,确保监控本身是可靠的。当巡检脚本报出"一切正常"时,你才能真正相信 Prometheus 上看到的数据是准确的。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Prometheus 官方文档 — Prometheus 官方,参考了Prometheus 官方文档相关内容
  2. Blackbox Exporter — GitHub 开源社区,参考了Blackbox Exporter相关内容