毕业项目:搭建一套完整的监控系统
读完前十九章,你已经分别掌握了指标类型、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-exporter | 9113 | nginx_exporter 抓 stub_status |
| 业务 | 后端 API(自带 /metrics) | 8080 | 应用埋点(RED) |
| 存储 | postgres_exporter | 9187 | 连 Postgres 拉 pg_stat_* |
| 主机 | node_exporter | 9100 | /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: dropstatic_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: backendmetric_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上面这份 prometheus.yml 里,Nginx 用的是 nginx-exporter 暴露的 9113 端口。假设你的 Nginx 启用了 stub_status,但 exporter 容器和 Nginx 容器是分开的,exporter 需要通过 http://nginx:80/stub_status 去拉数据。请:
- 写出 exporter 侧读取 Nginx 状态需要的环境变量(或启动参数)思路。
- 给
nginx这个job加一段relabel_configs,把抓回来的指标统一打上tier: edge、role: proxy(不要再用static_configs的labels偷懒)。
现在你想在记录规则里同时固化 P95 延迟,并且想快速找出「当前错误率最高的前 3 个 route」。请:
- 写出 P95 的记录规则(命名要符合
level:metric:operations)。 - 用一条 PromQL 找出错误率最高的前 3 个 route(提示:
topk配合已经算好的错误率记录规则)。
postgres_exporter 暴露 pg_stat_activity_count(当前连接数)和 pg_stat_activity_max_connections(最大连接数)。请写一条告警:当「当前连接数 / 最大连接数 > 0.85」并持续 5 分钟时触发,级别 warning,注释里用 humanizePercentage 显示当前占用比。
下面这条「核对项」写得不对,请指出问题并改写成正确的形式:
- 给
pg_stat_user_tables_*配了labeldrop把relname标签删掉,避免基数爆炸。
同时,请补充一条「监控的监控」相关的核对项(参考本章第 10 条思路)。