結論先講

微服務的 debug 難度是指數級成長的。 單體時代,一個請求從頭到尾在同一個 process 裡,stack trace 就是你的好朋友。拆成 5 個服務之後,一個請求跨了 5 個 container、5 份 log、5 個 error handling 機制——你需要分散式 Tracing 才能拼出完整的故事。


單體 vs 微服務:debug 體驗差多少

單體:一個 stack trace 就夠

Error: insufficient stock
    at OrderService.createOrder (order.js:42)
    at ProductService.checkStock (product.js:18)
    at Controller.handleRequest (controller.js:7)

一目了然,從 controller 到 service 到具體的行數。

微服務:你面對的是這個

# 訂單服務的 log
[14:03:22.001] POST /orders → 200 (342ms)

# 庫存服務的 log
[14:03:22.150] POST /stock/reserve → 500 (45ms)

# 通知服務的 log
[14:03:22.350] POST /notify → timeout (3000ms)

三個服務、三台機器、三份 log。時間看起來對得上,但你怎麼確定這三筆 log 屬於同一個使用者請求?靠時間戳排序?如果同一秒有 100 個請求呢?

這正是 第 28 篇 提到的痛:從單體拆出來之後,監控和 debug 的難度暴增。


Tracing 的核心概念

Trace 和 Span

Trace(一個完整的請求路徑)
├── Span A: API Gateway (12ms)
│   ├── Span B: 訂單服務 (300ms)
│   │   ├── Span C: DB 查詢 (25ms)
│   │   └── Span D: 庫存服務呼叫 (200ms)
│   │       └── Span E: Redis 快取查詢 (3ms)
│   └── Span F: 通知服務 (50ms)
  • Trace:一個使用者請求的完整旅程,用一個唯一的 Trace ID 標記
  • Span:Trace 中的一個工作單元(一次 HTTP 呼叫、一次 DB 查詢、一次快取讀取)
  • Parent-Child:Span 之間有父子關係,構成樹狀結構

Trace ID 怎麼傳遞

使用者 → API Gateway
         (產生 Trace ID: abc-123)
         Header: traceparent: 00-abc123-span01-01

API Gateway → 訂單服務
              Header: traceparent: 00-abc123-span02-01

訂單服務 → 庫存服務
           Header: traceparent: 00-abc123-span03-01

訂單服務 → 通知服務
           Header: traceparent: 00-abc123-span04-01

關鍵是 Context Propagation:每個服務收到請求時,從 HTTP header 取出 Trace ID,繼續往下傳。W3C Trace Context 標準用的 header 是 traceparent


OpenTelemetry:現在的標準答案

以前有 Jaeger Client、Zipkin Client、各家 APM 自己的 SDK⋯⋯現在全部統一到 OpenTelemetry(OTel)。

架構

你的服務 (OTel SDK)
    ↓ (OTLP 協議)
OTel Collector
    ↓
後端 (Jaeger / Zipkin / Grafana Tempo / Datadog)

OTel SDK 負責產生 Trace 資料,Collector 負責收集和轉發,後端負責存儲和展示。

Node.js 設定範例

// tracing.js — 在應用啟動前載入
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
 
const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4318/v1/traces',
  }),
  instrumentations: [
    new HttpInstrumentation(),
    new ExpressInstrumentation(),
  ],
});
 
sdk.start();
# 啟動時
node --require ./tracing.js app.js

Auto-instrumentation 會自動幫你追蹤 HTTP 進出、Express route、DB 查詢——不用改業務邏輯。

Go 設定範例

// main.go
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/trace"
)
 
func initTracer() func() {
    exporter, _ := otlptracehttp.New(ctx,
        otlptracehttp.WithEndpoint("otel-collector:4318"),
        otlptracehttp.WithInsecure(),
    )
    tp := trace.NewTracerProvider(trace.WithBatcher(exporter))
    otel.SetTracerProvider(tp)
    return func() { tp.Shutdown(ctx) }
}

Docker Compose 快速搭建

# docker-compose.tracing.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP
    volumes:
      - ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
 
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317"         # 接收 OTLP
    environment:
      - COLLECTOR_OTLP_ENABLED=true
# otel-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
 
exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
 
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp/jaeger]

打開 http://localhost:16686 就能看到 Jaeger UI,搜尋 Trace、看 Span 時間分佈、找出瓶頸。


實戰:找出慢在哪裡

場景:下單 API 回應時間從 200ms 飆到 2 秒

打開 Jaeger,搜尋 POST /orders,找到一筆 2 秒的 Trace:

Trace: POST /orders (2014ms)
├── API Gateway (8ms)
├── 訂單服務 (1998ms)
│   ├── 驗證使用者 (15ms)
│   ├── 查詢商品 (22ms)
│   ├── 呼叫庫存服務 (1900ms)  ← 瓶頸!
│   │   └── Redis GET (2ms)
│   │   └── DB UPDATE (1850ms)  ← 真正的元凶
│   └── 寫入訂單 (40ms)
└── 通知服務 (5ms, async)

一眼就看到:庫存服務的 DB UPDATE 花了 1850ms。打開那筆 Span 的 attributes,看到是 UPDATE products SET stock = stock - 1 WHERE id = 123 的 lock contention。

沒有 Tracing,你可能花半天在訂單服務上加 log、重啟、測試,最後才發現根本不是訂單服務的問題。


踩過的坑

1. 忘記傳遞 Context

// 錯誤:手動發 HTTP 請求沒帶 traceparent
const response = await fetch('http://stock-service/reserve', {
  method: 'POST',
  body: JSON.stringify(data),
});
 
// 正確:用 instrumented 的 HTTP client
// OTel 的 HttpInstrumentation 會自動注入 header
// 如果用原生 fetch,要手動注入
const { context, propagation } = require('@opentelemetry/api');
const headers = {};
propagation.inject(context.active(), headers);

2. Sampling 沒設好

生產環境不可能 100% 的請求都記錄 Trace——成本太高。通常用:

  • Head-based sampling:請求進來時隨機決定要不要記錄(例如 10%)
  • Tail-based sampling:等請求結束後,根據結果決定(例如只記錄 error 或 > 1 秒的)

Tail-based 更精準但更複雜,需要 OTel Collector 支援。

3. 非同步呼叫的 Context 斷掉

Message Queue(Kafka、RabbitMQ)不會自動傳遞 Trace Context。你需要手動把 traceparent 塞進 message header:

// Producer
const headers = {};
propagation.inject(context.active(), headers);
await kafka.send({
  topic: 'order-events',
  messages: [{ value: payload, headers }],
});
 
// Consumer
const extractedContext = propagation.extract(ROOT_CONTEXT, message.headers);
context.with(extractedContext, () => {
  // 在這個 context 下的所有操作都會連到同一個 Trace
  processMessage(message);
});

Jaeger vs Zipkin vs Grafana Tempo

維度JaegerZipkinGrafana Tempo
維護者CNCF社群Grafana Labs
存儲後端Cassandra / ES / KafkaMySQL / ES / CassandraObject Storage (S3, GCS)
成本中(要維護 ES)低(只要 S3)
與 Grafana 整合可以可以原生
適合場景通用輕量入門已有 Grafana 的團隊

如果你照 第 05 篇 已經搭好 Grafana,用 Tempo 整合最無痛。


下一篇

集中 Log 不再拼湊三份 log — Trace 告訴你「哪裡慢了」,Log 告訴你「為什麼慢」。但 5 個服務的 log 散在 5 台機器上,你要怎麼集中起來?


本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:Docker Compose 上生產的正確姿勢 → 下一篇:可觀測性(二):集中 Log 不再拼湊三份 log