結論先講

Tracing 告訴你「哪裡慢了」,Log 告訴你「為什麼慢」。 兩者缺一不可。但微服務的 log 散在 N 個 container 裡,格式五花八門,你需要一套集中式 Log 系統把它們收攏到同一個地方,並且用 Correlation ID 串起來。小團隊用 Loki,大團隊用 ELK。


單體 vs 微服務的 Log 體驗

單體:一個檔案走天下

# 出問題了?
tail -f /var/log/app.log | grep "ERROR"
 
# 找到了
[2026-04-15 14:03:22] ERROR OrderService: insufficient stock for product 123

一個檔案、一個格式、一個時區。人生美好。

微服務:五份 log 拼拼圖

# 訂單服務(Node.js,JSON 格式,UTC)
{"level":"error","msg":"stock check failed","ts":"2026-04-15T06:03:22.001Z"}
 
# 庫存服務(Go,text 格式,UTC+8)
2026/04/15 14:03:22 [ERROR] product 123: stock insufficient
 
# 通知服務(Python,自訂格式,UTC)
ERROR 2026-04-15 06:03:22,350 notification.py:42 - Failed to send notification
 
# API Gateway(nginx,access log 格式)
192.168.1.100 - - [15/Apr/2026:14:03:22 +0800] "POST /orders" 500 0.342
 
# 訊息佇列(Java,Log4j 格式)
2026-04-15 14:03:22.500 ERROR [consumer-thread-1] c.o.OrderConsumer - Processing failed

五個服務、五種語言、五種 log 格式、兩個時區。你甚至不確定這五行是不是同一個請求。


第一步:統一 Log 格式

Structured Logging(JSON 格式)

所有服務統一用 JSON 輸出 log,欄位名稱一致:

{
  "timestamp": "2026-04-15T06:03:22.001Z",
  "level": "error",
  "service": "order-service",
  "trace_id": "abc-123-def-456",
  "span_id": "span-789",
  "message": "stock check failed",
  "error": "insufficient stock",
  "product_id": 123,
  "user_id": 456
}

必備欄位

欄位用途
timestampUTC,ISO 8601 格式
levelerror / warn / info / debug
service哪個服務
trace_id連結到 Tracing(第 43 篇
message人讀的訊息
error錯誤細節(如果有)

各語言怎麼做

// Node.js — pino(最快的 JSON logger)
const pino = require('pino');
const logger = pino({
  level: 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
  timestamp: pino.stdTimeFunctions.isoTime,
});
 
// 使用時帶上 context
logger.info({ userId: 456, productId: 123 }, 'creating order');
logger.error({ err, userId: 456 }, 'stock check failed');
// Go — zerolog
logger := zerolog.New(os.Stdout).With().
    Timestamp().
    Str("service", "stock-service").
    Logger()
 
logger.Error().
    Str("trace_id", traceID).
    Int("product_id", 123).
    Msg("insufficient stock")
# Python — structlog
import structlog
 
logger = structlog.get_logger()
logger.error("stock check failed",
    service="notification-service",
    trace_id=trace_id,
    product_id=123)

第二步:集中收集

方案一:Grafana Loki(推薦小中團隊)

你的服務 → stdout(JSON)
    ↓
Docker log driver / Promtail
    ↓
Loki(只索引 label,不索引全文)
    ↓
Grafana(查詢 + 展示)

Loki 的設計哲學是「like Prometheus, but for logs」——它不像 Elasticsearch 那樣索引每個欄位,只索引 label(service name、level 等),全文搜尋用暴力掃描。

優點:資源佔用極低、與 Grafana 原生整合、S3 存儲成本低 缺點:全文搜尋慢(大量 log 時)、不適合複雜查詢

# docker-compose.logging.yml
services:
  loki:
    image: grafana/loki:latest
    ports:
      - "3100:3100"
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml
 
  promtail:
    image: grafana/promtail:latest
    volumes:
      - /var/log:/var/log
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yaml:/etc/promtail/config.yaml

方案二:ELK Stack(大團隊 / 複雜查詢)

你的服務 → stdout(JSON)
    ↓
Filebeat / Fluentd
    ↓
Elasticsearch(全文索引)
    ↓
Kibana(查詢 + 展示)

優點:全文搜尋極快、複雜查詢(聚合、統計)強大 缺點:Elasticsearch 吃資源(建議最少 4GB RAM)、維護成本高

怎麼選

維度LokiELK
資源佔用低(< 1GB RAM)高(4GB+ RAM)
全文搜尋
複雜查詢有限強大
成本
與 Grafana 整合原生需設定
適合規模< 100GB log/天任意規模

5 個服務以下,先用 Loki。 等 log 量大到 Loki 查詢太慢,再考慮遷移到 ELK。


第三步:Correlation ID 串起一切

Correlation ID(或叫 Request ID)是貫穿整個請求的唯一識別碼。跟 第 43 篇 的 Trace ID 是同一個概念——差別是 Trace ID 由 Tracing SDK 產生,Correlation ID 可以手動產生。

最佳實務:直接用 Trace ID 當 Correlation ID

// Express middleware
const { trace } = require('@opentelemetry/api');
 
app.use((req, res, next) => {
  const span = trace.getActiveSpan();
  const traceId = span?.spanContext().traceId || crypto.randomUUID();
 
  // 注入到 request context
  req.traceId = traceId;
 
  // 注入到 response header(方便前端 debug)
  res.setHeader('X-Trace-Id', traceId);
 
  // logger 自動帶上
  req.logger = logger.child({ trace_id: traceId });
 
  next();
});

這樣當使用者回報「下單失敗」,前端可以把 X-Trace-Id 給你,你直接在 Loki / Kibana 搜:

{service=~".+"} | json | trace_id = "abc-123-def-456"

所有服務、所有相關的 log 一次撈出來,按時間排序。


Log Level 怎麼訂

Level什麼時候用生產環境開不開
error系統無法正常完成操作永遠開
warn不影響功能但需要注意(快沒空間、連線池快滿)永遠開
info重要業務事件(訂單成立、付款完成)永遠開
debug開發時的細節(SQL 查詢、HTTP request body)只在 debug 時開
trace極度詳細(每一行程式碼的執行狀態)永遠不開

生產環境用 info 以上。 debug level 在微服務裡開下去,log 量會爆炸——5 個服務每秒各吐 100 行 debug log,一天就是 4300 萬行。

動態調整 Log Level

好的做法是支援不重啟就能調整 log level:

// 透過環境變數或 API 調整
app.post('/admin/log-level', (req, res) => {
  const { level } = req.body;
  logger.level = level;  // pino 支援動態調整
  res.json({ ok: true, level });
});

某個服務出問題時,把那個服務的 log level 暫時調到 debug,看完再調回來。


常見的坑

1. Log 裡塞了敏感資料

// 千萬不要
{"message": "login failed", "password": "abc123", "credit_card": "4111-1111-1111-1111"}
 
// 應該要
{"message": "login failed", "user_id": 456, "ip": "192.168.1.100"}

用 pino 的 redact 功能自動過濾:

const logger = pino({
  redact: ['password', 'creditCard', 'token', 'authorization'],
});

2. Log 量太大撐爆硬碟

設定 retention policy:

# Loki: 7 天後自動刪
limits_config:
  retention_period: 168h
 
# Elasticsearch: 14 天後自動刪(用 ILM policy)

3. 不同服務的時區不一致

全部用 UTC。 不要在 log 裡用 local time。顯示給人看的時候再轉時區。


跟 Tracing 怎麼串

最終目標:在 Grafana 裡點一筆 Trace,直接跳到對應的 Log。

Grafana → Tempo(Tracing) → 點某個 Span
                              ↓
                      自動帶 trace_id 跳到 Loki(Log)
                              ↓
                      看到該 Span 期間所有的 log

這就是可觀測性三大支柱(Traces + Logs + Metrics)互相連結的威力。Metrics 的部分我們留到下一篇。


下一篇

監控 Dashboard 該看什麼指標 — Trace 找到瓶頸、Log 找到原因,但你不可能每次都手動去查。Dashboard 讓你「一眼」就知道系統健不健康——但 Dashboard 上該放什麼指標?放錯了比沒有更糟。


本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:可觀測性(一):分散式 Tracing 怎麼追一個請求 → 下一篇:可觀測性(三):監控 Dashboard 該看什麼指標