Learn
Prometheus/20-capstone-project

毕业项目:搭建一套完整的监控系统

读完前十九章,你已经分别掌握了指标类型、PromQL、抓取机制、告警、记录规则、运维与埋点。这一章把这些碎片拼成一套真正能跑起来的系统——给一个经典的「Nginx + 后端 API + Postgres」三件套做监控。

这个组合不是随便选的,它几乎是互联网服务的「最小完整形态」:

  • Nginx:反向代理 / 负载均衡,是流量的入口,最该最先发现「用户访问不了」。
  • 后端 API:你的业务代码,RED 方法的主战场,最容易出延迟和错误。
  • Postgres:有状态的核心存储,一旦慢或满,会拖垮整条链路,且故障代价最高。

我们还会给每台机器配 node_exporter 看系统资源(USE 方法)。最终交付物是一份可以 docker compose up 直接拉起的配置:抓取、记录规则、告警、Alertmanager 路由、Grafana 面板清单,外加一份上线前核对清单(launch checklist)。

读完本章你会掌握:

  • 用 scrape_configs 同时抓取 node_exporter、nginx-exporter、postgres-exporter 和你自己 App 的 /metrics
  • 用 relabel_configs 给不同角色打 tier/role 标签、用 metric_relabel_configs 在入库前丢弃高基数指标
  • 写记录规则固化 QPS / 错误率 / P99,命名遵循 level:metric:operations
  • 写告警规则覆盖 up==0、错误率超阈值、磁盘与 CPU 饱和度
  • 配 Alertmanager 把告警路由到钉钉 / webhook,区分严重级别
  • 列一份覆盖「入口 / 服务 / 存储 / 主机」四个视角的 Grafana 面板清单
  • 用「上线前核对清单」避免最常漏掉的关键项

第一步:目标拓扑与抓取设计

先画清楚要抓什么、从哪抓。假设部署如下(都在同一个 docker 网络 monitoring 里):

角色组件暴露端口指标来源
入口nginx + nginx-prometheus-exporter9113nginx_exporter 抓 stub_status
业务后端 API(自带 /metrics)8080应用埋点(RED)
存储postgres_exporter9187连 Postgres 拉 pg_stat_*
主机node_exporter9100/proc、/sys

设计原则:一个 exporter 只抓一类东西,Prometheus 通过 job_name 区分来源,再通过 relabel 补充业务语义标签。

第二步:prometheus.yml 的核心——scrape_configs

下面是一份可运行的 scrape_configs(完整文件在文末汇总)。重点看 relabel_configs 怎么给每个 target 打上 tier 和 role:

scrape_configs:
  # 1) 主机资源(USE 方法)
  - job_name: node
    static_configs:
      - targets:
          - 'node-exporter:9100'
        labels:
          tier: infra
          role: host
    scrape_interval: 15s
 
  # 2) Nginx 入口(用 exporter 抓 stub_status)
  - job_name: nginx
    static_configs:
      - targets:
          - 'nginx-exporter:9113'
        labels:
          tier: edge
          role: proxy
    scrape_interval: 15s
 
  # 3) 后端 API(自带 /metrics)
  - job_name: api
    metrics_path: /metrics
    static_configs:
      - targets:
          - 'api:8080'
        labels:
          tier: app
          role: backend
    scrape_interval: 15s
    # 入库前丢指标:postgres 的连接指标我们不在这里抓
    metric_relabel_configs:
      - source_labels: [__name__]
        regex: 'go_.*'
        action: drop
 
  # 4) Postgres(用 exporter 连库)
  - job_name: postgres
    static_configs:
      - targets:
          - 'postgres-exporter:9187'
        labels:
          tier: data
          role: db
    scrape_interval: 30s
    metric_relabel_configs:
      # 某些 pg 指标基数极高(按表 × 索引),只保留慢查询与连接类
      - source_labels: [__name__]
        regex: 'pg_stat_user_tables_.*'
        action: drop
ℹ️提示

static_configs 适合演示;真实环境请换成 file_sd_configs 或 kubernetes 服务发现,否则每次扩缩容都要改配置重启。但两者的 relabel 写法完全一致,本项目的 relabel_configs 可以直接搬过去。

relabel 在这里到底干了什么

relabel_configs 发生在「抓取之前」,用来改 target 的标签(比如把 __address__ 重写、给 target 加业务标签)。本项目里我们偷懒用 static_configs 的 labels 直接写死 tier/role,其实更标准的做法是:

  - job_name: api
    relabel_configs:
      - source_labels: [__address__]
        target_label: instance
      - target_label: tier
        replacement: app
      - target_label: role
        replacement: backend

metric_relabel_configs 发生在「抓取之后、入库之前」,是挡住高基数指标的最后一道闸门。action: drop + regex 匹配 __name__ 可以整族丢弃(如上面的 go_.*、表级 pg_stat_user_tables_.*)。这是第 18 章讲过的「三道防线」中最省钱的一道——指标根本不进 TSDB,后续所有资源都省了。

⚠️注意

metric_relabel_configs 的 labeldrop 匹配的是标签名;而上面这种用 __name__ 做 drop 匹配的是指标名。别搞混:想丢掉「某个指标族」用 __name__ + drop;想丢掉「某个标签维度」用 labeldrop。本项目两个都用到了。

第三步:记录规则——把 QPS / 错误率 / P99 固化

这些指标会被 Grafana 和告警反复使用,与其每次查询都重算,不如用记录规则固化。命名遵循 level:metric:operations:

groups:
  - name: api_recording
    interval: 30s
    rules:
      # QPS(按 route 聚合的每秒请求数)
      - record: job:api_requests:rate5m
        expr: sum by (route) (rate(api_requests_total[5m]))
 
      # 错误率(5xx 占比)
      - record: job:api_errors:ratio_rate5m
        expr:
          sum by (route) (rate(api_requests_total{code=~"5.."}[5m]))
          /
          sum by (route) (rate(api_requests_total[5m]))
 
      # P99 延迟(跨实例聚合后再算分位数)
      - record: job:api_duration:p99_rate5m
        expr:
          histogram_quantile(
            0.99,
            sum by (le, route) (rate(api_request_duration_seconds_bucket[5m]))
          )

三条铁律再强调一次:永远 sum(rate(...)) 不是 rate(sum(...))(先 sum 再 rate 会丢失按 route 的拆分,且数学上不对);rate 的区间(5m)不小于规则 interval(30s);如果以后有依赖这些结果的规则,要放进同一个 group 并按依赖顺序书写(同组串行求值)。

第四步:告警规则——把「事件」而不是「状态」告诉人

告警只应该告诉人「需要立刻决策的事」。本项目四类告警:target 失联、错误率超标、磁盘将满、CPU 饱和。

groups:
  - name: infra_alerts
    rules:
      # 1) 任何 target 失联超过 1 分钟
      - alert: TargetDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "实例 {{ $labels.instance }} (job={{ $labels.job }}) 失联"
          description: "up 为 0 已超过 1 分钟,抓取已中断。"
 
      # 2) 错误率超过 5%
      - alert: APIHighErrorRate
        expr: job:api_errors:ratio_rate5m > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "接口 {{ $labels.route }} 错误率 {{ humanizePercentage $value }}"
          description: "近 5 分钟 5xx 占比超过 5%,当前 {{ humanizePercentage $value }}。"
 
      # 3) 磁盘空间将在 4 小时内写满(用 predict_linear 预判)
      - alert: DiskWillFill
        expr:
          predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[1h], 4 * 3600) < 0
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "磁盘 {{ $labels.mountpoint }} 4 小时内将写满"
          description: "按近 1 小时趋势外推,挂载点 {{ $labels.mountpoint }} 可用空间将在 4 小时内耗尽。"
 
      # 4) CPU 饱和(load1 远超核数,且持续)
      - alert: CPUSaturation
        expr:
          node_load1
          >
          count by (instance) (node_cpu_seconds_total{mode="idle"}) * 0.9
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "实例 {{ $labels.instance }} CPU 持续饱和"
          description: "load1 长时间高于核数 90%,排队开始发生。"
💡小技巧

up == 0 是所有监控里最该先配的一条——它不依赖任何业务埋点,只要抓取断了就会触发,是「监控本身还活着吗」的底线。把它放在 infra_alerts 这种永不缺席的组里。for 字段用于去抖:避免一次网络抖动就发一条告警。

⚠️注意

「磁盘 80% 满」是状态不是事件,不要直接对它告警——它会一直 firing,变成噪音。正确做法是像上面 DiskWillFill 那样,用 predict_linear 预测「多久后会满」并对此告警。这是第 18 章讲的「告警是事件不是状态」原则。

第五步:Alertmanager——把告警路由到钉钉 / webhook

Prometheus 只负责「产生告警」,Alertmanager 负责「发给谁、怎么发、去重、分组、静默」。下面是一份把 critical 路由到钉钉、warning 路由到通用 webhook 的配置:

route:
  receiver: default-webhook
  group_by: ['alertname', 'job']
  group_wait: 30s        # 同组告警合并等待
  group_interval: 5m     # 同组新告警补充间隔
  repeat_interval: 4h    # 持续 firing 的重复提醒间隔
  routes:
    # critical 走钉钉,且按实例分组,避免刷屏
    - matchers:
        - severity = "critical"
      receiver: dingtalk-critical
      group_by: ['alertname', 'instance']
      repeat_interval: 1h
 
receivers:
  - name: default-webhook
    webhook_configs:
      - url: 'http://alert-bridge:8088/webhook'
        send_resolved: true
 
  - name: dingtalk-critical
    webhook_configs:
      - url: 'http://dingtalk-bridge:8060/dingtalk/webhook'
        send_resolved: true
 
inhibit_rules:
  # 同实例若已有 critical,抑制同源 warning,减少噪音
  - source_matchers: ['severity = "critical"']
    target_matchers: ['severity = "warning"']
    equal: ['instance']

钉钉本身不是原生 Prometheus 接收端,所以这里走的是「webhook 桥接服务」(如 prometheus-webhook-dingtalk)把告警体转成钉钉消息。send_resolved: true 保证告警恢复时也会通知,闭环体验很重要。

ℹ️提示

inhibit_rules(抑制规则)是治理告警噪音的利器:当 TargetDown(critical)已经触发时,这台机器上所有 warning 级告警都会被自动抑制——因为「机器都失联了,慢一点/SAT 高这些次级问题毫无意义」。这比删告警有效得多。

第六步:Grafana 面板清单——四个视角覆盖全链路

不需要真的写 JSON(Grafana 面板可导入,本项目只列清单与每张面板该用的指标)。目标是覆盖「入口 → 服务 → 存储 → 主机」四个视角,任何一个故障都能在某一屏里被看到:

入口视角(Nginx)

  • 每秒请求数:rate(nginx_http_requests_total[5m])
  • 按状态码分布:sum by (code) (rate(nginx_http_requests_total[5m]))
  • 连接数:nginx_http_connections{state="active|reading|writing|waiting"}
  • 上游响应时间:nginx_upstream_response_msecs_avg

服务视角(后端 API,RED)

  • QPS:记录规则 job:api_requests:rate5m
  • 错误率:job:api_errors:ratio_rate5m(配阈值线 5%)
  • P99 延迟:job:api_duration:p99_rate5m
  • 按 route 拆分的分位数热力(用 histogram_quantile 原地算,便于下钻)

存储视角(Postgres)

  • 活跃连接数:pg_stat_activity_count
  • 慢查询数:rate(pg_stat_database_deadlocks[5m]) 与 pg_stat_statements 相关指标
  • 缓存命中率:pg_stat_database_blks_hit / (blks_hit + blks_read)
  • 数据库大小与表膨胀(来自 postgres_exporter 的 pg_database_size_bytes 等)

主机视角(node_exporter,USE)

  • CPU 使用率:1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))
  • 内存可用:node_memory_available_bytes / node_memory_total_bytes
  • 磁盘可用与预测:predict_linear(node_filesystem_avail_bytes[1h], 4*3600)
  • 网络吞吐:rate(node_network_receive_bytes_total[5m])
💡小技巧

面板不是越多越好。一个视角一屏、每张面板回答一个具体问题、阈值线画清楚「正常/异常」边界,比堆 50 张图更能让人发现问题。先保证上面这 16 张核心面板,再按需扩展。

第七步:上线前核对清单(launch checklist)

把所有容易漏的关键项列成清单,上线前逐条打勾。这比任何文档都实用:

  • 抓取通了:在 Prometheus Web 的 Status > Targets 页面,四个 job 的 up 都是 1;若有 down,先查网络/端口/认证,再查 exporter 是否真在监听。
  • up==0 告警已配且能触发:故意停掉一个 exporter,确认 1 分钟内收到 TargetDown。
  • 标签基数自检:跑 topk(20, count by (__name__)({__name__=~".+"})),确认没有意外的百万级序列;尤其检查 postgres_exporter 的表级指标是否已被 metric_relabel_configs 挡掉。
  • 记录规则在跑:Status > Rules 里能看到 job:api_requests:rate5m 等三条,且 last evaluation 是最近 30s 内。
  • 告警能端到端送达:用 Alertmanager 的「Silence」反向验证——先静默一条,再手动触发,确认钉钉/webhook 收到且 send_resolved 在恢复时也收到。
  • 保留期与容量:确认 storage.tsdb.retention.time 符合合规要求,并用第 18 章公式粗算磁盘是否够;内存配了 GOMEMLIMIT 吗?
  • 抓取间隔一致:scrape_interval 与记录规则的 rate 区间、告警的 [5m] 窗口匹配(间隔不要超过 2 分钟)。
  • Grafana 四个视角都能打开:入口/服务/存储/主机各至少一屏,且数据与实时抓取一致(不是空面板)。
  • runbook 就位:每条 critical 告警的注释里都写了 summary/description,且对应 runbook 链接已填进 annotations。
  • 自监控在盯:prometheus_rule_group_iterations_missed_total 与 prometheus_target_scrape_pool_exceeded_target_limit 这类 Prometheus 自身指标已纳入你这边的监控,避免「告警失效却无人知晓」。
⚠️注意

清单里最常被漏的是第 10 条。Prometheus 自己的健康没人盯,结果规则求值被跳过、target 超限被静默丢弃——这些故障发生时不会报警(因为报警系统自己挂了)。把「监控的监控」也接入这套系统,是毕业项目的真正闭环。

参考汇总:一份可运行的 prometheus.yml

把前面各步拼起来,去掉注释,就是一份可直接用的主配置骨架(Alertmanager、Grafana、各 exporter 的 service 定义省略,放到 docker-compose 里即可):

global:
  scrape_interval: 15s
  evaluation_interval: 30s
  external_labels:
    cluster: learn-demo
 
rule_files:
  - /etc/prometheus/rules/*.yml
 
alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']
 
scrape_configs:
  - job_name: node
    static_configs:
      - targets: ['node-exporter:9100']
        labels: { tier: infra, role: host }
 
  - job_name: nginx
    static_configs:
      - targets: ['nginx-exporter:9113']
        labels: { tier: edge, role: proxy }
 
  - job_name: api
    metrics_path: /metrics
    static_configs:
      - targets: ['api:8080']
        labels: { tier: app, role: backend }
    metric_relabel_configs:
      - source_labels: [__name__]
        regex: 'go_.*'
        action: drop
 
  - job_name: postgres
    static_configs:
      - targets: ['postgres-exporter:9187']
        labels: { tier: data, role: db }
    scrape_interval: 30s
    metric_relabel_configs:
      - source_labels: [__name__]
        regex: 'pg_stat_user_tables_.*'
        action: drop
🎯练习 1:补全缺失的 scrape 与 relabel

上面这份 prometheus.yml 里,Nginx 用的是 nginx-exporter 暴露的 9113 端口。假设你的 Nginx 启用了 stub_status,但 exporter 容器和 Nginx 容器是分开的,exporter 需要通过 http://nginx:80/stub_status 去拉数据。请:

  1. 写出 exporter 侧读取 Nginx 状态需要的环境变量(或启动参数)思路。
  2. 给 nginx 这个 job 加一段 relabel_configs,把抓回来的指标统一打上 tier: edge、role: proxy(不要再用 static_configs 的 labels 偷懒)。
🎯练习 2:设计记录规则覆盖 P95 与慢路由

现在你想在记录规则里同时固化 P95 延迟,并且想快速找出「当前错误率最高的前 3 个 route」。请:

  1. 写出 P95 的记录规则(命名要符合 level:metric:operations)。
  2. 用一条 PromQL 找出错误率最高的前 3 个 route(提示:topk 配合已经算好的错误率记录规则)。
🎯练习 3:写一条 Postgres 连接耗尽告警

postgres_exporter 暴露 pg_stat_activity_count(当前连接数)和 pg_stat_activity_max_connections(最大连接数)。请写一条告警:当「当前连接数 / 最大连接数 > 0.85」并持续 5 分钟时触发,级别 warning,注释里用 humanizePercentage 显示当前占用比。

🎯练习 4:补全上线核对清单的遗漏项

下面这条「核对项」写得不对,请指出问题并改写成正确的形式:

  • 给 pg_stat_user_tables_* 配了 labeldrop 把 relname 标签删掉,避免基数爆炸。

同时,请补充一条「监控的监控」相关的核对项(参考本章第 10 条思路)。