本篇是《Prometheus 从入门到生产实践》第 4 / 24 篇。配置以文中版本与环境前提为准,实验和故障均为教学场景。完整程序、配置及校验范围见配套实验包。
抓到指标只是第一步,指标是否表达了正确业务含义更加重要。本篇提供一个本机教学服务,把请求次数、耗时分布和正在处理的请求数连起来,作为后续 PromQL 的统一数据源。

1. 先定义观测边界
业务端点是 /api;?fail=1 模拟 HTTP 500,?slow=1 增加约 0.4 秒等待,普通请求约等待 0.02 秒。/metrics 本身不计入业务请求,避免采集动作反过来污染业务流量。
请求结束时增加 counter 并观察耗时;处理期间增加 inflight gauge,结束后减少。指标表达的是这个教学处理边界,不是完整网络往返或数据库事务耗时。
标签 method 固定 GET,route 固定模板 /api,code 固定两种。不能直接把 query、原始 URL、用户名或异常全文放成标签。
2. 完整教学服务
保存包中的 demo_exporter.py,使用 Python 3 运行:
"""Loopback-only instructional metrics endpoint; use an official client SDK in production."""
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOCK = threading.Lock()
COUNTS = {'200': 0, '500': 0}
DURATIONS = {code: {'buckets': [0, 0, 0], 'sum': 0.0, 'count': 0} for code in COUNTS}
BUCKETS = [0.1, 0.3, 1.0]
INFLIGHT = 0
def metrics():
with LOCK:
lines = ['# HELP ops_demo_requests_total Completed instructional requests.',
'# TYPE ops_demo_requests_total counter']
for code, count in COUNTS.items():
lines.append(f'ops_demo_requests_total{{method="GET",route="/api",code="{code}"}} {count}')
lines += ['# HELP ops_demo_request_duration_seconds Instructional request duration.',
'# TYPE ops_demo_request_duration_seconds histogram']
for code, values in DURATIONS.items():
labels = f'method="GET",route="/api",code="{code}"'
for upper, count in zip(BUCKETS, values['buckets']):
lines.append(f'ops_demo_request_duration_seconds_bucket{{{labels},le="{upper}"}} {count}')
lines.append(f'ops_demo_request_duration_seconds_bucket{{{labels},le="+Inf"}} {values["count"]}')
lines.append(f'ops_demo_request_duration_seconds_sum{{{labels}}} {values["sum"]}')
lines.append(f'ops_demo_request_duration_seconds_count{{{labels}}} {values["count"]}')
lines += ['# HELP ops_demo_inflight_requests Current instructional requests.',
'# TYPE ops_demo_inflight_requests gauge',
f'ops_demo_inflight_requests {INFLIGHT}']
return ('\n'.join(lines) + '\n').encode()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
global INFLIGHT
if self.path == '/metrics':
body, status, kind = metrics(), 200, 'text/plain; version=0.0.4; charset=utf-8'
elif self.path.split('?', 1)[0] == '/api':
from urllib.parse import parse_qs, urlsplit
query = parse_qs(urlsplit(self.path).query)
status = 500 if query.get('fail') == ['1'] else 200
started = time.perf_counter()
with LOCK:
INFLIGHT += 1
try:
time.sleep(0.4 if query.get('slow') == ['1'] else 0.02)
finally:
elapsed = time.perf_counter() - started
with LOCK:
INFLIGHT -= 1
COUNTS[str(status)] += 1
histogram = DURATIONS[str(status)]
histogram['count'] += 1
histogram['sum'] += elapsed
for index, upper in enumerate(BUCKETS):
histogram['buckets'][index] += int(elapsed <= upper)
body, kind = json.dumps({'status': status}).encode(), 'application/json'
else:
body, status, kind = b'not found\n', 404, 'text/plain'
self.send_response(status)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except ConnectionError:
pass
def log_message(self, *args):
pass
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=19100)
args = parser.parse_args()
ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever()
python3 demo_exporter.py
curl --max-time 3 http://127.0.0.1:19100/api
curl --max-time 3 'http://127.0.0.1:19100/api?fail=1&slow=1'
curl --max-time 3 http://127.0.0.1:19100/metrics
注意第二条业务请求的 HTTP 500 是故意产生的。curl 默认可能仍返回成功传输退出码;若用它判断健康,需要另行指定失败策略。
3. 手写格式是为了理解,生产优先 SDK
程序显式输出 HELP、TYPE、标签和数值,设置 exposition Content-Type,并以换行结束。直方图桶为累计计数,+Inf 与 count 相同;sum 保存观察值总和。
这里用锁避免多个请求同时更新共享计数,桶、总和和次数增量维护,不保留所有请求的明细,内存不会因每次请求多存一个对象。
不过手写服务没有完整的生产部署、认证、优雅终止、采样或库生态。真实业务优先使用 官方客户端库 提供的计数和序列化能力,再把业务定义测清楚。格式参考 exposition formats。
4. 让数据可以重复生成
配套 traffic.py 会产生有限次数的请求:
python3 traffic.py --count 60 --interval 0.1
在干净服务中运行一次,预期总业务次数为 60,其中 500 为 12 次;慢请求共 6 次。具体落入哪个有限桶受实际调度影响,所以要根据观测的时间与桶计数判断,不把 sleep 值当作无误差测量。
短流量结束后,Counter 总值保留,rate 在窗口逐渐滑过请求后会下降,最后可能为零。这不是数据被删除,而是窗口里没有新增请求。进程重启又会使计数从头开始,后续查询需要处理 reset。
5. 指标验收不只看有没有 HELP
先检查 sum(ops_demo_requests_total{job="demo"}),再按 code 分组。检查每个 code 的 count 与对应 counter 是否一致、桶是否不递减、+Inf 是否等于 count、sum 是否非负。
确认 /metrics 抓取不会增加业务 counter。再同时发多个慢请求,观察 inflight 临时升高并最终回到零。Gauge 卡住不归零可能是异常路径漏了收尾,不能总靠面板把数值截断为零。
原生直方图具有不同的样本表示与协议协商条件,本服务只导出经典直方图。不要把原生 histogram 查询复制过来却期待它自动生成原生数据。
6. 练习:为指标写一份契约
写出每项指标的名字、单位、标签、增长或减少时机、重启行为,以及不包含什么。用固定请求数验证 counter,用一次慢请求验证桶累积。
如果要增加 POST,先决定 method 维度;如果新增路由,先规范化模板;如果要记录异常原因,先评估有限分类与日志关联,而不是把所有错误文字变成标签。
上一篇:时间序列、类型与标签:指标设计先控制基数 · 系列目录 · 下一篇:Exporter与黑盒探测:主机正常不代表用户路径正常