前言
去年我们做了一次 PDF 翻译服务的大重构------把原来单体 Python Flask 应用拆成了 4 个微服务:API 网关、PDF 解析、翻译引擎调用、结果合并。上线一周后,问题来了:
- 报告"B 端客户上传失败"的工单,从前端、网络、到后端 4 个微服务,没人能快速定位问题在哪;
- 翻译超时到底是 LLM 引擎慢、是网络慢、还是 PDF 解析慢?平均排查时间 1.5 小时;
- 月底用量统计对不上------日志分散在 4 个服务的容器里,归因查询写一次要半小时。
之后我们用了 3 周时间把整套系统的可观测性(Observability)搭起来:指标走 Prometheus、链路追踪走 Jaeger + OpenTelemetry、日志走 Loki,统一在 Grafana 里看。改造之后,定级问题平均定位时间从 1.5 小时降到 8 分钟,月底用量对账从半小时降到 5 分钟。
这篇把整套方案的全部代码和踩坑过程写下来,给同样做 SaaS 翻译/AI 推理服务的同学参考。
环境准备
- Docker 20.10+
- Docker Compose v2
- Python 3.10+ 的 FastAPI 应用 4 个
- Grafana 10.x / Prometheus / Loki / Jaeger / Tempo
快速验证环境:
bash
docker --version
docker compose version
一、可观测性的三大支柱
做任何可观测性方案前,先把概念对齐。业界公认是 Metrics(指标)、Logs(日志)、Traces(链路追踪) 三件套:
| 维度 | 数据特征 | 典型问题 | 常用工具 |
|---|---|---|---|
| Metrics | 数值型、按时间聚合 | CPU/内存/QPS/延迟/错误率 | Prometheus |
| Logs | 文本型、离散事件 | 报错堆栈、业务事件、用户行为 | Loki / ELK |
| Traces | 树状调用链 | 一次请求跨了多少服务、卡在哪 | Jaeger / Tempo |
PDF 翻译服务面临的核心问题是:一次用户请求会经过 4 个微服务,每个微服务又有自己的日志 ,单看任何一个维度的数据都无法回答"这个请求失败在哪个环节"。只有把三个维度通过同一个 Trace ID 关联起来,才能快速定位。
二、技术选型:为什么是 OTel + Jaeger + Loki + Prometheus
我比较过几个常见组合,最终选的方案:
| 选型 | 替代方案 | 选择理由 |
|---|---|---|
| OpenTelemetry | 各自 SDK 自接 | OTel 是 CNCF 标准,未来迁移成本最低 |
| Jaeger | Zipkin / Tempo | Jaeger 对 Java/Go/Python 的探针成熟,社区案例最多 |
| Loki | ELK / Splunk | Loki 资源消耗小,存储成本只有 ELK 的 1/5 |
| Prometheus | VictoriaMetrics / InfluxDB | 生态最强,Alertmanager 配套完整 |
| Grafana | Kibana / Datadog | 统一查询层,可以把 Traces/Logs/Metrics 在一个面板里联看 |
唯一一个常见的替代是 Tempo(Grafana 自家出),它和 Loki 配合做"trace-to-log"的跳转特别顺。但我们选 Jaeger 是因为它的存储后端更可控,方便我们后期接 ClickHouse。
三、OpenTelemetry Collector 部署
所有服务的可观测性数据统一通过 OTel Collector 中转,再分发到 Jaeger/Prometheus/Loki。
3.1 docker-compose.yml
yaml
version: "3.8"
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.96.0
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./otel-collector/config.yaml:/etc/otelcol/config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
networks: [observability]
jaeger:
image: jaegertracing/jaeger:1.53
environment:
- COLLECTOR_OTLP_ENABLED=true
ports:
- "16686:16686" # UI
- "14250:14250"
- "14268:14268"
networks: [observability]
prometheus:
image: prom/prometheus:v2.49.1
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
ports: ["9090:9090"]
networks: [observability]
loki:
image: grafana/loki:2.9.3
ports: ["3100:3100"]
volumes:
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
networks: [observability]
grafana:
image: grafana/grafana:10.2.2
ports: ["3000:3000"]
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-storage:/var/lib/grafana
networks: [observability]
# 业务服务(示例 4 个)
pdf-translator-api-gateway:
build: ./services/api-gateway
ports: ["8000:8000"]
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on: [otel-collector]
networks: [observability]
pdf-translator-parser:
build: ./services/pdf-parser
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on: [otel-collector]
networks: [observability]
pdf-translator-engine:
build: ./services/translation-engine
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on: [otel-collector]
networks: [observability]
pdf-translator-merger:
build: ./services/result-merger
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on: [otel-collector]
networks: [observability]
volumes:
grafana-storage:
networks:
observability:
driver: bridge
3.2 otel-collector/config.yaml
yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1000
# 给所有 span 自动注入 service.instance.id
resource:
attributes:
- key: deployment.environment
value: production
action: upsert
exporters:
# Traces -> Jaeger
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
# Metrics -> Prometheus
prometheus:
endpoint: 0.0.0.0:8889
resource_to_telemetry_conversion:
enabled: true
# Logs -> Loki
loki:
endpoint: loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, resource]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [batch, resource]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [batch, resource]
exporters: [loki]
四、Python 服务接入 OpenTelemetry(以 FastAPI 为例)
4.1 安装依赖
bash
pip install opentelemetry-api
pip install opentelemetry-sdk
pip install opentelemetry-instrumentation-fastapi
pip install opentelemetry-instrumentation-httpx
pip install opentelemetry-instrumentation-logging
pip install opentelemetry-exporter-otlp
4.2 统一观测初始化模块
新建 app/observability.py:
python
# app/observability.py
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
import logging
import os
def setup_observability(app, service_name: str):
"""统一的 OTel 初始化,所有 FastAPI 服务调用一次即可。"""
resource = Resource.create({
"service.name": service_name,
"service.version": "1.0.0",
"deployment.environment": os.getenv("ENV", "development"),
})
# Trace
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
insecure=True,
)
)
)
trace.set_tracer_provider(tracer_provider)
# Metrics
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
insecure=True,
),
export_interval_millis=10000,
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[metric_reader])
)
# FastAPI / HTTPX 自动埋点
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument()
# 日志 Trace ID 注入(关键!让 Loki 的每条日志带 Trace ID)
LoggingInstrumentor().instrument(set_logging_format=True)
logging.info(f"[observability] initialized for {service_name}")
4.3 网关服务(API Gateway)示例
python
# services/api-gateway/main.py
from fastapi import FastAPI, UploadFile, File, HTTPException
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
import httpx
import logging
import os
from app.observability import setup_observability
app = FastAPI(title="PDF Translator API Gateway")
setup_observability(app, "pdf-translator-api-gateway")
logger = logging.getLogger("api-gateway")
tracer = trace.get_tracer(__name__)
INTERNAL_SERVICES = {
"parser": os.getenv("PARSER_URL", "http://pdf-translator-parser:8001"),
"engine": os.getenv("ENGINE_URL", "http://pdf-translator-engine:8002"),
"merger": os.getenv("MERGER_URL", "http://pdf-translator-merger:8003"),
}
@app.post("/api/v1/translate")
async def translate_pdf(file: UploadFile = File(...), target_lang: str = "en"):
"""统一入口:接收 PDF,分发到下游三个服务"""
with tracer.start_as_current_span("gateway.translate_pdf") as span:
span.set_attribute("file.name", file.filename or "")
span.set_attribute("file.size", file.size or 0)
span.set_attribute("target.lang", target_lang)
try:
# Step 1: 解析 PDF
with tracer.start_as_current_span("gateway.parse_pdf") as parse_span:
parse_span.set_attribute("step", "1.parse")
async with httpx.AsyncClient(timeout=60) as client:
files = {"file": (file.filename, await file.read(), "application/pdf")}
parse_resp = await client.post(
f"{INTERNAL_SERVICES['parser']}/parse",
files=files,
)
parse_resp.raise_for_status()
pages = parse_resp.json()["pages"]
parse_span.set_attribute("pages.count", len(pages))
# Step 2: 翻译引擎
with tracer.start_as_current_span("gateway.translate_engine") as eng_span:
eng_span.set_attribute("step", "2.translate")
eng_span.set_attribute("pages.count", len(pages))
async with httpx.AsyncClient(timeout=600) as client:
eng_resp = await client.post(
f"{INTERNAL_SERVICES['engine']}/translate",
json={"pages": pages, "target_lang": target_lang},
)
eng_resp.raise_for_status()
translated_pages = eng_resp.json()["pages"]
eng_span.set_attribute(
"total.output_tokens",
sum(p.get("tokens", 0) for p in translated_pages),
)
# Step 3: 结果合并
with tracer.start_as_current_span("gateway.merge_result") as merge_span:
merge_span.set_attribute("step", "3.merge")
async with httpx.AsyncClient(timeout=60) as client:
merge_resp = await client.post(
f"{INTERNAL_SERVICES['merger']}/merge",
json={"pages": translated_pages, "original_filename": file.filename},
)
merge_resp.raise_for_status()
result = merge_resp.json()
span.set_attribute("status", "success")
return result
except httpx.HTTPStatusError as e:
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
logger.exception(f"Translate failed: {e}")
raise HTTPException(status_code=502, detail="Upstream service error")
except Exception as e:
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
logger.exception(f"Unexpected error: {e}")
raise HTTPException(status_code=500, detail="Internal error")
4.4 自定义业务指标(关键)
python
# services/translation-engine/metrics.py
from opentelemetry import metrics
import time
from functools import wraps
meter = metrics.get_meter("translation-engine")
# Counter:翻译总页数
translation_pages_total = meter.create_counter(
"translation.pages.total",
description="Total pages translated",
unit="1",
)
# Histogram:翻译耗时
translation_duration = meter.create_histogram(
"translation.duration",
description="Time spent translating one page",
unit="ms",
)
# Gauge:引擎活跃任务数
active_translations = meter.create_up_down_counter(
"translation.active",
description="Number of active translation tasks",
)
def track_translation(func):
"""装饰器:自动记录调用次数和耗时"""
@wraps(func)
async def wrapper(*args, **kwargs):
active_translations.add(1)
start = time.time()
try:
result = await func(*args, **kwargs)
translation_pages_total.add(
len(result.get("pages", [])),
{"target_lang": kwargs.get("target_lang", "unknown")},
)
return result
finally:
elapsed_ms = (time.time() - start) * 1000
translation_duration.record(elapsed_ms)
active_translations.add(-1)
return wrapper
五、Grafana 统一面板配置
我们在 Grafana 里建了 4 个核心面板:
5.1 数据源配置
Grafana 自动发现 docker-compose 里的 3 个数据源(Prometheus/Loki/Jaeger),在 grafana/provisioning/datasources/datasources.yaml 中:
yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
- name: Jaeger
type: jaeger
access: proxy
url: http://jaeger:16686
5.2 核心查询示例(PromQL + LogQL + TraceQL)
5.2.1 QPS 与延迟分布
promql
# 每分钟翻译请求数
sum(rate(translation_pages_total[1m]))
# P95 翻译延迟
histogram_quantile(0.95, sum(rate(translation_duration_bucket[5m])) by (le))
5.2.2 错误率
promql
# 5xx 错误率
sum(rate(http_requests_total{status=~"5.."}[5m])) /
sum(rate(http_requests_total[5m]))
5.2.3 从 Trace ID 跳转到 Loki 日志
Grafana 10.x 提供了一个杀手级特性:Correlation------在 Trace 详情页直接跳转到对应服务/时间的日志:
logql
{service_name="pdf-translator-api-gateway"} | json | trace_id="<traceid>"
5.2.4 反向:从 Loki 日志跳转到 Trace
logql
{service_name="pdf-translator-engine"} |= "ERROR"
点开任意一行 ERROR,Grafana 自动把日志里的 trace_id 字段提取出来,点击直接跳到 Jaeger 对应 trace。
六、SLO(Service Level Objective)配置
可观测性不是为了"看",是为了"主动报警"。我们在 Prometheus + Alertmanager 里配置了 4 个核心 SLO:
yaml
# prometheus/alerts/pdf_translator.yml
groups:
- name: pdf-translator-slo
rules:
- alert: TranslateP95TooHigh
expr: histogram_quantile(0.95, sum(rate(translation_duration_bucket[5m])) by (le)) > 30000
for: 5m
labels:
severity: warning
annotations:
summary: "P95 翻译延迟超 30s"
description: "近 5 分钟 P95 翻译延迟 {{ $value }}ms"
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m])) /
sum(rate(http_requests_total[5m])) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "5xx 错误率超过 5%"
- alert: QuotaNearlyExhausted
expr: pdf_translator_quota_used / pdf_translator_quota_total > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "月度翻译配额已使用 85%+"
- alert: EngineStuck
expr: translation_active > 100
for: 3m
labels:
severity: critical
annotations:
summary: "活跃翻译任务数持续 > 100,疑似线程池阻塞"
Alertmanager 配置对应的通知渠道(钉钉/邮件/Slack)这里不展开。
七、月底用量对账自动化
通过 OTel Metrics 我们把"客户维度"的翻译用量直接采集:
python
# services/translation-engine/billing.py
from opentelemetry import metrics
meter = metrics.get_meter("translation-engine")
customer_pages_counter = meter.create_counter(
"billing.pages_per_customer",
description="Translation pages per customer for billing",
unit="1",
)
def record_customer_usage(customer_id: str, pages: int):
customer_pages_counter.add(
pages,
{"customer.id": customer_id},
)
月底直接查 PromQL:
promql
sum by (customer_id) (increase(billing_pages_per_customer[30d]))
3 分钟导出当月用量 CSV,发送给财务对账------彻底告别"爬 4 个服务的日志拼数据"的过去。
八、踩坑经验
- OTel BatchSpanProcessor 调优 :默认配置下,批处理延迟会达到 5s,对延迟敏感业务需要调到 1s,并开启
max_export_batch_size限制。生产环境一般调为timeout=1s, max_queue_size=2048。 - Trace 上下文传递 :跨服务调用必须用
propagation.inject_context()和propagation.extract_context(),否则 Trace 树会断掉成多个独立 span。HTTPX/Requests/grpc 都有自动埋点,但 RabbitMQ/Kafka 这种消息队列要手动传 header。 - Loki 标签基数爆炸 :Loki 的索引是 label-based 的,避免把
customer_id这种高基数值设为 label,否则 Loki 索引会爆。建议把这类字段放到 log 的 payload 里而不是 label 里。 - Jaeger 存储后端选型:默认存储只适合 Demo,生产请直接用 Elasticsearch 或 Cassandra。ClickHouse 后端的新版本(v1.50+)也很推荐。
- Grafana Correlation 启用条件 :必须在 Loki 数据源里开启
derivedFields,并在 OTel LoggingInstrumentor 里启用set_logging_format=True。
九、改造前后对比
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 故障平均定位时间 | 1.5 小时 | 8 分钟 |
| 月底用量对账 | 30 分钟 | 5 分钟 |
| 错误率告警发现 | 客户投诉后 | 实时告警 |
| 新服务接入可观测性时间 | 1-2 天 | 30 分钟 |
| 日志存储成本/月 | 800 元 (ELK) | 180 元 (Loki) |
总结
可观测性的核心不是工具,是统一 Trace ID 把三个维度串起来的能力。OpenTelemetry 作为 CNCF 标准,解决了"工具绑定"的问题;Jaeger + Loki + Prometheus 各自做好自己的本职;Grafana 把所有面板聚合在同一个查询层------这套组合拳对 SaaS / AI 服务非常适用。
完整项目代码已发布在示例仓库:GitHub 仓库地址(示例占位)。改造完成后,我们的运维成本下降了一半,SRE 同事可以专注在性能优化而不是被工单淹没。
参考资料
- OpenTelemetry 官方文档:https://opentelemetry.io/docs/
- Jaeger 部署最佳实践:https://www.jaegertracing.io/docs/
- Grafana Loki 标签建模:https://grafana.com/docs/loki/latest/
- Prometheus SLO 实战:https://prometheus.io/docs/prometheus/latest/
标签:OpenTelemetry、Jaeger、Loki、可观测性、SRE