PDF 翻译服务的全链路可观测性设计:OpenTelemetry + Jaeger + Loki 实战方案

前言

去年我们做了一次 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 个服务的日志拼数据"的过去。

八、踩坑经验

  1. OTel BatchSpanProcessor 调优 :默认配置下,批处理延迟会达到 5s,对延迟敏感业务需要调到 1s,并开启 max_export_batch_size 限制。生产环境一般调为 timeout=1s, max_queue_size=2048
  2. Trace 上下文传递 :跨服务调用必须用 propagation.inject_context()propagation.extract_context(),否则 Trace 树会断掉成多个独立 span。HTTPX/Requests/grpc 都有自动埋点,但 RabbitMQ/Kafka 这种消息队列要手动传 header。
  3. Loki 标签基数爆炸 :Loki 的索引是 label-based 的,避免把 customer_id 这种高基数值设为 label,否则 Loki 索引会爆。建议把这类字段放到 log 的 payload 里而不是 label 里。
  4. Jaeger 存储后端选型:默认存储只适合 Demo,生产请直接用 Elasticsearch 或 Cassandra。ClickHouse 后端的新版本(v1.50+)也很推荐。
  5. 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、Jaeger、Loki、可观测性、SRE

相关推荐
我的xiaodoujiao1 小时前
Django 基础知识详细图文教程 1-Django 介绍
后端·python·学习·测试工具·django
IT毕设实战小研1 小时前
基于大数据的二手车数据分析与预测
java·大数据·后端·爬虫·python·算法·课程设计
Allen_LVyingbo1 小时前
医疗AI可扩展网格信息系统中网格计算技术的智能优化
大数据·人工智能·python·算法·机器学习·django·健康医疗
MNLoser1 小时前
AI agent开发——LangGraph接入持久化
linux·人工智能·windows·python
练习两年半的攻城狮1 小时前
RAG 系统中 Excel/表格数据的正确处理方式
python·llamaindex
苏灿烤鱼2 小时前
九个编码 Agent 共用免费额度,本地代理是路由还是绕开?
python·agent·claude
copyer_xyf2 小时前
Neo4j:给 RAG 补上关系检索
python·agent
whcyhhh3 小时前
CTF‑MISC 隐写术完整学习笔记|图片隐写全题型 + 工具 + 实战例题
python·网络安全·ctf·misc·信息隐藏
码视野3 小时前
多宠 RFID 颈圈识别与湿粮半导体制冷保鲜分餐喂食器解决方案(软硬件一体化)
大数据·人工智能·python