Prometheus 与 Grafana 指标、告警和看板工具手册
凌晨的接口告警很奇怪:错误率已经升高,CPU 看板却一片绿色。开发者打开 Grafana,发现请求面板没有数据;再去 Prometheus 查询,up 又是 1。这里至少有三个不同事实:目标能被抓取、目标暴露了样本、样本能回答业务问题。up=1 只证明最近一次 scrape 成功,不能证明埋点正确,更不能证明查询和告警正确。
要把这类事故拆开,需要先建立一条最小但完整的链路:应用或 exporter 暴露指标,Prometheus 周期性拉取并写入本地 TSDB,PromQL 把原始序列变成可判断的信号,规则持续计算,Alertmanager 负责分组与通知,Grafana 再把查询和看板作为团队入口。下面的实验会故意制造抓取失败、错误查询、远端写入中断和副本故障,因此请在一台至少有 4 GiB 可用内存、10 GiB 空闲磁盘并已安装 Docker Compose 的开发机上执行;端口 3000、9090、9091、9093、9100 和 8088 应保持空闲,目录中的测试数据不得与共享环境混用。
先把整条指标链跑起来
新建一个空目录 metrics-lab,其中准备 prometheus.yml、prometheus-b.yml、prometheus-remote.yml、rules/app.yml、alertmanager.yml、Grafana provisioning 文件和 compose.yaml。实验使用固定版本,升级前应从 Prometheus releases、Alertmanager releases 与 Grafana releases 重新选择补丁版本,并在测试目录重放本文实验。
# compose.yaml
services:
node-exporter:
image: prom/node-exporter:v1.9.1
ports: ["127.0.0.1:9100:9100"]
prometheus-a:
image: prom/prometheus:v3.12.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=2GB
- --web.enable-lifecycle
- --web.external-url=http://127.0.0.1:9090
ports: ["127.0.0.1:9090:9090"]
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./rules:/etc/prometheus/rules:ro
- prom-a-data:/prometheus
depends_on: [node-exporter, alertmanager, prometheus-remote]
prometheus-b:
image: prom/prometheus:v3.12.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=2GB
- --web.enable-lifecycle
- --web.external-url=http://127.0.0.1:9091
ports: ["127.0.0.1:9091:9090"]
volumes:
- ./prometheus-b.yml:/etc/prometheus/prometheus.yml:ro
- ./rules:/etc/prometheus/rules:ro
- prom-b-data:/prometheus
depends_on: [node-exporter, alertmanager]
prometheus-remote:
image: prom/prometheus:v3.12.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --web.enable-remote-write-receiver
volumes:
- ./prometheus-remote.yml:/etc/prometheus/prometheus.yml:ro
- prom-remote-data:/prometheus
alertmanager:
image: prom/alertmanager:v0.28.1
command: ["--config.file=/etc/alertmanager/alertmanager.yml"]
ports: ["127.0.0.1:9093:9093"]
volumes:
- ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
webhook:
image: mendhak/http-https-echo:37
ports: ["127.0.0.1:8088:8080"]
grafana:
image: grafana/grafana:13.0.2
ports: ["127.0.0.1:3000:3000"]
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-change-me-now}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
depends_on: [prometheus-a]
volumes:
prom-a-data: {}
prom-b-data: {}
prom-remote-data: {}
grafana-data: {}node-exporter 是被抓取目标;prometheus-a 与 prometheus-b 独立抓同一个目标;prometheus-remote 只接收 A 的 remote write;Alertmanager 把通知投递给测试 webhook。所有发布到宿主机的实验端口都显式绑定 127.0.0.1,容器间通信仍走 Compose 网络。这里的两个 Prometheus 没有共享磁盘,也不互相复制,这一点正是后面理解 HA 的关键。
抓取配置如下。global.scrape_interval 是默认抓取周期,evaluation_interval 是规则求值周期;scrape_timeout 必须小于抓取周期,否则前一次抓取还没结束,下一次调度就已经到来。external_labels.replica 只标识副本,不能自动完成去重。
# prometheus.yml
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
external_labels:
cluster: metrics-lab
replica: a
rule_files:
- /etc/prometheus/rules/*.yml
alerting:
alert_relabel_configs:
- regex: replica
action: labeldrop
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
remote_write:
- name: lab-remote
url: http://prometheus-remote:9090/api/v1/write
queue_config:
capacity: 10000
max_samples_per_send: 2000
max_shards: 4B 使用独立配置,保留相同的抓取、规则和 Alertmanager 段,删除 remote_write,并把副本标签改成 b:
# prometheus-b.yml
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
external_labels:
cluster: metrics-lab
replica: b
rule_files:
- /etc/prometheus/rules/*.yml
alerting:
alert_relabel_configs:
- regex: replica
action: labeldrop
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]生产中应通过启动参数、配置模板或编排平台为每个副本注入不同的外部标签,避免维护两份大段重复配置。replica 会保留在 remote write 的样本中,供远端查询层去重;alert_relabel_configs 则在告警发送前删除它,让两个副本产生的同一告警能被 Alertmanager 识别为同一事件。远端接收端只需要一个合法的空抓取配置:
# prometheus-remote.yml
global:
scrape_interval: 1m
scrape_configs: []规则文件同时包含 recording rule 和 alerting rule。前者把反复计算的速率固化成新序列,后者只表达“何时需要行动”;通知路由不应混在 PromQL 中。
# rules/app.yml
groups:
- name: node.rules
interval: 15s
rules:
- record: job:scrape_samples_scraped:sum
expr: sum by (job) (scrape_samples_scraped)
- alert: NodeExporterDown
expr: up{job="node"} == 0
for: 30s
labels:
severity: page
team: platform
annotations:
summary: "node exporter 无法抓取"
runbook_url: "https://runbooks.example.invalid/node-exporter-down"for: 30s 让条件先进入 Pending,持续满足后才 Firing;短暂抖动恢复时不会通知。真实 runbook URL 应指向团队可访问且不含敏感参数的文档。
# alertmanager.yml
route:
receiver: lab-webhook
group_by: [alertname, job]
group_wait: 5s
group_interval: 30s
repeat_interval: 4h
receivers:
- name: lab-webhook
webhook_configs:
- url: http://webhook:8080/prometheus
send_resolved: trueAlertmanager 的 group_wait 用于等待同组告警聚合,group_interval 控制同组新增告警的后续通知,repeat_interval 控制仍未恢复的重复提醒。静默、抑制和路由都发生在 Alertmanager;Prometheus 仍会继续计算告警状态。相关行为可对照 Alertmanager 配置 与 告警高可用。
Grafana 的数据源和看板由文件创建。数据源 URL 必须是 Grafana 容器看到的 http://prometheus-a:9090,不是浏览器使用的 localhost:9090。
# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
prune: true
datasources:
- name: Prometheus
uid: prometheus-main
type: prometheus
access: proxy
url: http://prometheus-a:9090
isDefault: true
editable: false
version: 1
jsonData:
timeInterval: 15s# grafana/provisioning/dashboards/default.yml
apiVersion: 1
providers:
- name: lab
orgId: 1
folder: Metrics Lab
type: file
disableDeletion: false
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards{
"uid": "metrics-lab-node",
"title": "Metrics Lab / Node",
"schemaVersion": 41,
"version": 1,
"refresh": "15s",
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Target up",
"datasource": {"type": "prometheus", "uid": "prometheus-main"},
"targets": [{"refId": "A", "expr": "up{job=\"node\"}"}],
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 0}
}
]
}Grafana 会持续同步 provisioned dashboard,文件更新会覆盖数据库中的同 UID 看板;UI 修改不会自动回写 Git。allowUiUpdates: false 是在明确宣布“文件是权威来源”。官方 provisioning 文档 还说明了多实例时 datasource version 的作用,以及文件监听在某些挂载方式下可能收不到事件,因此这里用 30 秒轮询。
先设置一个仅用于本地实验的管理员密码,再校验配置并启动。下面第一段是 Bash;PowerShell 需要把环境变量赋值改成第二段的写法,其余 docker compose 命令相同。
export GRAFANA_ADMIN_PASSWORD='replace-with-a-local-test-password'
docker compose run --rm --entrypoint promtool prometheus-a \
check config /etc/prometheus/prometheus.yml
docker compose run --rm --entrypoint promtool prometheus-a \
check rules /etc/prometheus/rules/app.yml
docker compose up -d
for i in $(seq 1 60); do
curl -fsS http://127.0.0.1:9090/-/ready >/dev/null && \
curl -fsS http://127.0.0.1:3000/api/health >/dev/null && \
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D%20%3D%3D%201' | grep -q '"result":\[' && break
sleep 2
done
curl -fsS http://127.0.0.1:9090/-/ready
curl -fsS http://127.0.0.1:3000/api/health
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D%20%3D%3D%201' | grep -q '"result":\[{"metric"'$env:GRAFANA_ADMIN_PASSWORD = 'replace-with-a-local-test-password'
docker compose run --rm --entrypoint promtool prometheus-a check config /etc/prometheus/prometheus.yml
docker compose run --rm --entrypoint promtool prometheus-a check rules /etc/prometheus/rules/app.yml
docker compose up -d
$ready = $false
1..60 | ForEach-Object {
try {
$prom = Invoke-RestMethod http://127.0.0.1:9090/-/ready
$grafana = Invoke-RestMethod http://127.0.0.1:3000/api/health
$up = Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D'
if ($prom -and $grafana.database -eq 'ok' -and [double]$up.data.result[0].value[1] -eq 1) {
$ready = $true
return
}
} catch {}
Start-Sleep -Seconds 2
}
if (-not $ready) { throw 'Prometheus、Grafana 或 node-exporter 未在 120 秒内就绪' }前两个 promtool 命令应返回 SUCCESS;就绪轮询只有在 Prometheus、Grafana 和 node-exporter 同时可用时才结束,超时应视为启动失败,而不是继续后续实验。如果 /targets 显示 connection refused,先执行 docker compose ps 和 docker compose logs node-exporter;如果 Grafana 数据源失败而宿主机能打开 Prometheus,检查数据源是否误用了 localhost。
从一个样本读懂 metric、label 与基数
Prometheus 的一个样本由时间戳、浮点值和一组标签确定。metric name 也可视为特殊标签 __name__;同一指标名下,每个不同的标签组合都是独立时间序列。抓取目标时,Prometheus 通常补上 job 与 instance。服务发现先产生候选目标,relabel_configs 在抓取前选择或改写目标,metric_relabel_configs 在样本写入前丢弃或改写指标;把两者弄反,会出现“目标消失”或“无用高基数样本已经抓进来”的不同后果。
指标类型决定允许怎样计算:
Counter 只增加或在进程重启后归零,应该对时间窗口使用 rate() 或 increase(),不能直接把累计值当吞吐。Gauge 可增可减,适合温度、队列长度和并发数;对它盲目使用 rate() 通常没有业务意义。Classic Histogram 暴露 _bucket、_sum、_count,跨实例计算分位数时必须保留 le 再用 histogram_quantile()。
Summary 在客户端计算 quantile,通常不能把多个实例的 quantile 求平均;它适合客户端必须控制误差且无需跨实例聚合的少数场景。
这些类型的完整语义可查 Prometheus metric types。先用下面的 PromQL 对实验环境建立直觉:
up{job="node"}
rate(prometheus_tsdb_head_samples_appended_total[5m])
sum by (job) (scrape_samples_scraped)
job:scrape_samples_scraped:sum查询时先在 Table 模式看标签集合,再决定聚合维度。sum(rate(...)) 会丢掉所有定位标签,sum by (job, status) 会保留告警仍需使用的维度;先聚合再做除法和先做除法再聚合也可能得到完全不同的加权结果。PromQL 的 selector、range vector、函数和聚合规则应就近查 PromQL basics。
基数不是“指标名有多少”,而是所有标签值域的乘积。假设一个请求指标有 20 个路由、6 个状态码、5 个方法、30 个实例,理论上已经是 18,000 条序列;再加入 100 万个 user_id,容量模型立即失控。用户 ID、订单号、trace ID、完整 URL、异常文本和随机请求 ID 不应成为 label。路由要记录模板 /orders/{id},详细个体信息进入日志或 trace,并通过受控关联键跳转。
可以做一个反实验:在测试应用中临时把请求 ID 放入 label,观察 prometheus_tsdb_head_series 和内存持续增长;随后删除该 label、重启应用并等待旧序列退出 Head。不要在共享 Prometheus 上做这个实验,因为停止写入不会立即删除已经存在的块。
告警必须经历失败与恢复
停止 exporter 后不能立刻读取一次就下结论。下面的 Bash 轮询依次等待下一次 scrape 把 up 置为 0、规则进入 Pending、覆盖 for: 30s 后进入 Firing,再覆盖 Alertmanager 的 group_wait: 5s 等到 webhook 收到 firing 通知;每一步都有超时,因而抓取、规则和通知链不会混成一个固定睡眠。
ALERT_STARTED_AT=$(date --iso-8601=seconds)
docker compose stop node-exporter
for i in $(seq 1 12); do
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D%20%3D%3D%200' | grep -q '"result":\[{"metric"' && break
sleep 5
done
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D%20%3D%3D%200' | grep -q '"result":\[{"metric"' || { echo '等待 scrape 失败' >&2; exit 1; }
for i in $(seq 1 6); do
curl -fsS http://127.0.0.1:9090/api/v1/alerts | grep -q '"state":"pending"' && break
sleep 5
done
curl -fsS http://127.0.0.1:9090/api/v1/alerts | grep -q '"state":"pending"' || { echo '等待 Pending 失败' >&2; exit 1; }
for i in $(seq 1 12); do
curl -fsS http://127.0.0.1:9090/api/v1/alerts | grep -q '"state":"firing"' && break
sleep 5
done
curl -fsS http://127.0.0.1:9090/api/v1/alerts | grep -q '"state":"firing"' || { echo '等待 Firing 失败' >&2; exit 1; }
for i in $(seq 1 12); do
docker compose logs --since "$ALERT_STARTED_AT" webhook 2>&1 | grep -q '"status":"firing"' && break
sleep 5
done
docker compose logs --since "$ALERT_STARTED_AT" webhook 2>&1 | grep -q '"status":"firing"' || { echo '等待 firing 通知失败' >&2; exit 1; }
docker compose logs --since "$ALERT_STARTED_AT" webhookPowerShell 使用同样的状态顺序,但用对象解析 JSON,避免把 Bash 的 grep、seq 和变量语法混进同一个代码块:
$alertStartedAt = (Get-Date).ToUniversalTime().ToString('o')
docker compose stop node-exporter
$deadline = (Get-Date).AddSeconds(60)
$upReached = $false
do {
$up = Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D'
if ([double]$up.data.result[0].value[1] -eq 0) { $upReached = $true; break }
Start-Sleep 5
} while ((Get-Date) -lt $deadline)
if (-not $upReached) { throw '等待 scrape 失败' }
foreach ($wanted in 'pending','firing') {
$deadline = (Get-Date).AddSeconds(60)
$stateReached = $false
do {
$alerts = Invoke-RestMethod http://127.0.0.1:9090/api/v1/alerts
if ($alerts.data.alerts.state -contains $wanted) { $stateReached = $true; break }
Start-Sleep 5
} while ((Get-Date) -lt $deadline)
if (-not $stateReached) { throw "等待 $wanted 失败" }
}
$deadline = (Get-Date).AddSeconds(60)
do {
$webhookLog = docker compose logs --since $alertStartedAt webhook 2>&1 | Out-String
if ($webhookLog -match '"status":"firing"') { break }
Start-Sleep 5
} while ((Get-Date) -lt $deadline)
if ($webhookLog -notmatch '"status":"firing"') { throw '未收到 firing 通知' }第一次查询会在下一次 scrape 后变为 0。告警先 Pending,持续 30 秒后 Firing;再等待 group_wait,webhook 日志应出现 Alertmanager 的 POST。若任一步超时,依次查看 Prometheus 的 Targets 与 Alerts、Prometheus 的 Alertmanagers 页面、Alertmanager 的 Alerts 页面和 docker compose logs alertmanager,这样能区分抓取、规则、Prometheus 到 Alertmanager、路由和通知端点五段故障。
恢复 exporter:
docker compose start node-exporter
for i in $(seq 1 12); do
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D%20%3D%3D%201' | grep -q '"result":\[{"metric"' && break
sleep 5
done
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D%20%3D%3D%201' | grep -q '"result":\[{"metric"' || { echo '等待抓取恢复失败' >&2; exit 1; }
for i in $(seq 1 12); do
docker compose logs --since "$ALERT_STARTED_AT" webhook 2>&1 | grep -q '"status":"resolved"' && break
sleep 5
done
docker compose logs --since "$ALERT_STARTED_AT" webhook 2>&1 | grep -q '"status":"resolved"' || { echo '等待 resolved 通知失败' >&2; exit 1; }
docker compose logs --since "$ALERT_STARTED_AT" webhookPowerShell 恢复段对应为:
docker compose start node-exporter
$deadline = (Get-Date).AddSeconds(60)
$upReached = $false
do {
$up = Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D'
if ([double]$up.data.result[0].value[1] -eq 1) { $upReached = $true; break }
Start-Sleep 5
} while ((Get-Date) -lt $deadline)
if (-not $upReached) { throw '等待抓取恢复失败' }
$deadline = (Get-Date).AddSeconds(60)
do {
$webhookLog = docker compose logs --since $alertStartedAt webhook 2>&1 | Out-String
if ($webhookLog -match '"status":"resolved"') { break }
Start-Sleep 5
} while ((Get-Date) -lt $deadline)
if ($webhookLog -notmatch '"status":"resolved"') { throw '未收到 resolved 通知' }up 应回到 1,Prometheus 活跃告警列表中该实例随后消失,webhook 必须收到 resolved 通知。一个只验证 Firing、不验证恢复的规则,很容易留下永不关闭的工单。规则变更时除了 promtool check rules,还应使用 rule unit testing 固定输入序列、求值时刻和预期标签,防止一次 PromQL 重构静默改变告警。
告警表达的是可执行异常,不是“看起来值得关注”的所有曲线。每条告警都需要 owner、严重级别、等待时间、runbook、通知路由与恢复语义。Alertmanager 的 grouping 减少同一事故的通知洪水,inhibition 用高层故障压制其派生告警,silence 是有期限的人工例外;它们不能替代修复噪声规则。
WAL、块与保留策略决定你能否撑过故障
新样本先进入内存 Head,并写入 WAL 以便崩溃恢复,随后形成通常为两小时的持久块并在后台压缩。WAL 不是长期历史库,块也不是共享数据库。官方 storage 文档 明确指出本地 TSDB 不做集群复制,不支持把 NFS/EFS 一类文件系统当可靠本地存储;无快照直接复制数据目录还可能漏掉尚未形成块的数据。
Compose 同时设置 retention.time=7d 与 retention.size=2GB,先触发者生效。size 只会删除已持久化块,WAL 与 memory-mapped chunks 会计入总占用,却不会为了满足 size 立即删除,所以 2GB 不能被理解为硬磁盘上限。生产容量至少要估算:
每日样本数 = 活跃序列数 x 86400 / scrape_interval_seconds
本地净数据量 ≈ 每日样本数 x 每样本压缩字节数 x 保留天数
磁盘预算 = 净数据量 + WAL + Head + 压缩临时空间 + 安全余量每样本字节数与标签、样本形状和 churn 有关,应以压测环境实际的 rate(prometheus_tsdb_head_samples_appended_total[5m])、磁盘增长和块大小校准。不要把 retention size 配到磁盘总量;官方建议最多使用已分配磁盘的约 80% 到 85%,为清理延迟和临时空间留余量。
验证 WAL 恢复时,先记录 prometheus_tsdb_head_series 和一个最新样本,然后强制停止 A 再启动:
docker compose kill prometheus-a
docker compose start prometheus-a
docker compose logs --since=2m prometheus-a
curl -fsS http://127.0.0.1:9090/-/ready启动日志会出现 WAL replay,ready 在重放完成后恢复。正向结果是近期序列仍可查询;反向结果包括数据卷无写权限、磁盘满、损坏块或把数据目录放在不支持的共享文件系统。发生损坏时先保全数据目录并从快照恢复;删除 block 或 WAL 是会丢数据的最后手段,不应成为自动化启动脚本。
remote write 是异步出口,不是同步副本
A 将已经接收的样本从 WAL 交给 remote write queue,再由多个 shard 批量发送。官方 remote write tuning 说明,series cache、队列、CPU 和网络都会增加资源消耗;高 churn 会让标签缓存尤其昂贵。
先确认远端能看到 A 写入的序列:
docker compose exec prometheus-remote wget -qO- \
'http://localhost:9090/api/v1/query?query=up%7Bjob%3D%22node%22%7D'然后制造远端故障。remote write 的队列与退避不是瞬时状态,下面的 Bash 实验把远端持续关闭 90 秒,期间每 5 秒同时采样 pending、两分钟内 retry 增量与日志;不要在观察到第一次连接失败后马上恢复远端。
docker compose stop prometheus-remote
for i in $(seq 1 18); do
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=prometheus_remote_storage_samples_pending'
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=increase%28prometheus_remote_storage_retried_samples_total%5B2m%5D%29'
sleep 5
done
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=max_over_time%28prometheus_remote_storage_samples_pending%5B2m%5D%29%20%3E%200' | grep -q '"result":\[{"metric"' || { echo '故障窗口内未观察到 pending' >&2; exit 1; }
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=increase%28prometheus_remote_storage_retried_samples_total%5B2m%5D%29%20%3E%200' | grep -q '"result":\[{"metric"' || { echo '故障窗口内未观察到 retry' >&2; exit 1; }
docker compose logs --since=2m prometheus-a
docker compose start prometheus-remote
for i in $(seq 1 60); do
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=prometheus_remote_storage_samples_pending%20%3D%3D%200' | grep -q '"result":\[{"metric"' && break
sleep 5
done
curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=prometheus_remote_storage_samples_pending%20%3D%3D%200' | grep -q '"result":\[{"metric"' || { echo 'remote write 积压未在 5 分钟内回落' >&2; exit 1; }PowerShell 可用同一故障窗口和 JSON 对象观察:
docker compose stop prometheus-remote
1..18 | ForEach-Object {
(Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=prometheus_remote_storage_samples_pending').data.result
(Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=increase%28prometheus_remote_storage_retried_samples_total%5B2m%5D%29').data.result
Start-Sleep 5
}
$pendingSeen = Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=max_over_time%28prometheus_remote_storage_samples_pending%5B2m%5D%29%20%3E%200'
$retrySeen = Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=increase%28prometheus_remote_storage_retried_samples_total%5B2m%5D%29%20%3E%200'
if ($pendingSeen.data.result.Count -eq 0) { throw '故障窗口内未观察到 pending' }
if ($retrySeen.data.result.Count -eq 0) { throw '故障窗口内未观察到 retry' }
docker compose logs --since=2m prometheus-a
docker compose start prometheus-remote
$deadline = (Get-Date).AddMinutes(5)
$drained = $false
do {
$pending = Invoke-RestMethod 'http://127.0.0.1:9090/api/v1/query?query=prometheus_remote_storage_samples_pending'
if (($pending.data.result | ForEach-Object { [double]$_.value[1] } | Measure-Object -Sum).Sum -eq 0) { $drained = $true; break }
Start-Sleep 5
} while ((Get-Date) -lt $deadline)
if (-not $drained) { throw 'remote write 积压未在 5 分钟内回落' }停机期间 A 的本地查询仍正常;至少一个 prometheus_remote_storage_samples_pending 序列应高于零,prometheus_remote_storage_retried_samples_total 的窗口增量应增长,日志应出现带退避的重试。远端恢复后 pending 应回落,远端最终补齐中断期间的样本。若积压持续增长,检查 failed_samples_total、retried_samples_total、shard 数、CPU、网络和远端限流。盲目增大 capacity 与 max_shards 会同时增加内存并可能把远端打得更慢。远端离线超过 WAL 可供重放的窗口,旧样本仍会丢失,因此 remote write 不提供无限期可靠队列,也不保证本地查询与远端查询在每一时刻一致。
remote write 迁移应采用双写:先新增目标,比较两端序列数、标签、延迟、规则结果和典型看板;确认新端达到保留窗口后再切查询;最后停止旧写入并按合规要求保留或删除旧数据。回滚时恢复旧数据源与旧写入配置,不要立即销毁旧后端。write_relabel_configs 可以减少发送量,但一旦在出口丢弃,远端无法补回。
两个副本为什么仍不等于完整 HA
打开 9090 与 9091 查询 up{job="node"},两个副本都应有结果。停止 A 后,B 仍能抓取和计算规则:
docker compose stop prometheus-a
curl -fsS 'http://127.0.0.1:9091/api/v1/query?query=up%7Bjob%3D%22node%22%7D'
curl -fsS http://127.0.0.1:3000/api/health这证明采集副本有效,却也暴露反例:Grafana 数据源固定指向 A,所以 Grafana 仍无法查询;两个 Prometheus 都把同一告警发给 Alertmanager,如果标签不能识别同一事件,就会重复通知;A 的 remote write 中断也不会由 B 自动接管。完整架构还需要查询入口或全局查询层、清晰的 replica label 与去重、两个 Prometheus 独立故障域、Alertmanager 集群和远端存储策略。
Alertmanager 的 HA 是多个实例通过 gossip 协调通知状态,而每个 Prometheus 应把告警发送给所有 Alertmanager 实例;不要把它们放在一个负载均衡地址后只随机选一个。网络分区时系统偏向重复通知而不是漏报,这是必须由值班流程接受和演练的取舍。
Grafana 多实例则需要共享 PostgreSQL 或 MySQL,默认 SQLite 只适合开发和小型评估;Grafana HA 文档 说明共享数据库解决配置与会话状态,但 Grafana Alerting 还需要单独的 HA 配置。看板文件、插件版本、Secret、告警状态与负载均衡健康检查也必须一致。仅复制两个 Grafana 容器并挂同一个 SQLite 文件,会把单点故障变成并发写风险。
Grafana 是查询入口,不是数据安全边界
排障时先在 Prometheus 表格查询确认原始序列,再到 Grafana Explore 检查同一 PromQL,最后看 Dashboard variable、时间范围、step、transform 和 panel。这样可以区分“源数据没有”“查询错了”和“展示层改坏了”。看板刷新太快、时间范围太长、变量展开过多,会把查询成本放大到 Prometheus 或远端后端。
Folder 权限保护看板对象,不自动限制用户能通过 datasource 查询哪些序列。Prometheus 自身也不是多租户 RBAC 系统,不应裸露在公网。网络边界使用 TLS/mTLS、反向代理、SSO 和最小访问策略;抓取凭证、remote write token、Grafana datasource 密码通过 Secret 注入,不能放进 dashboard JSON、URL query 或 Git。Provisioning 的敏感字段应放 secureJsonData,并注意环境变量中 $ 的转义规则。
分享、snapshot、anonymous access、public dashboard 与导出 JSON 都可能暴露内部主机名、租户标签、查询表达式和业务数据。指标标签本身也要经过数据分级:即使没有日志正文,邮箱、手机号、账号和完整路径依然可能构成敏感数据。
把指标链变成团队能力
应用团队负责指标语义、类型、单位和有界标签,平台团队负责发现、抓取、TSDB、remote write、Grafana 与 Alertmanager,值班团队负责告警动作和 runbook。三方共同维护一个版本库:Prometheus 配置与规则先过 promtool 和单元测试,Grafana provisioning 与 dashboard JSON 经过评审,凭证由部署系统注入。
容量评审不能只看磁盘。团队需要持续观察活跃序列、series churn、每秒摄入样本、WAL replay 时间、规则求值时长、查询并发、remote write pending、Grafana 查询量和通知成功率。成本由采集频率、序列数、保留期、副本数、remote write 流量、远端存储和人力共同构成;把 scrape interval 从 30 秒改成 5 秒,样本与网络成本大约放大六倍,却未必增加同等诊断价值。
升级时先让一个隔离副本加载新版本,重放配置和规则测试,比较关键 PromQL、WAL 启动、remote write、Alertmanager 通知与 Grafana 插件。保留旧镜像、配置和数据快照,确认没有不可逆数据库迁移后再扩大范围。回滚不能只降镜像:Grafana 数据库迁移、插件状态、Prometheus 块格式、规则语义和 remote write 协议都要纳入兼容检查。
实验结束时先导出需要保留的 dashboard 和规则,再执行:
docker compose down
# 确认 metrics-lab 中没有共享数据后,才删除实验卷:
docker compose down -v不带 -v 可保留 TSDB 和 Grafana 数据用于下一次实验;带 -v 会不可逆删除四个数据卷。真正可退出的方案应能导出规则、看板、通知路由和必要历史数据,并把 SDK/应用指标维持在开放格式,而不是把团队锁在某个 UI 中。
