文章

從黑盒到透明:衍生品交易系統的可觀測性架構實踐

從黑盒到透明:衍生品交易系統的可觀測性架構實踐

前言

交易系統最可怕的不是崩潰 — 崩潰至少會被注意到。最可怕的是靜默失敗:服務看起來正常,但報價已經停止更新、策略已經不再觸發、數據已經悄悄偏移。

我的兩套衍生品交易系統(Shioaji 微服務群 + IBAPI 單體)共 35 個容器運行在同一台 VM 上。隨著服務數量增長,我逐步建立了一套基於可觀測性三支柱(Metrics / Logs / Traces)的監控架構,再加上獨立於監控堆疊的 Telegram 告警通道,確保系統問題能在第一時間被發現和處理。

可觀測性不是「看 Dashboard」,是「系統能自己告訴你哪裡有問題」。


架構全景

可觀測性三支柱架構 Metrics · Logs · Traces · Independent Alerting 應用層 — 35 個容器 Shioaji (15 微服務) IBAPI (API+Web) MudWeb MultiAgent Redis PostgreSQL Traefik metrics logs traces Pillar 1: Metrics Prometheus (15s scrape) Exporters: • node-exporter → CPU/Mem/Disk • cAdvisor → 容器指標 • redis-exporter → Redis ops • Traefik → HTTP 延遲/狀態 • Docker SD → 自動發現 → Alertmanager → Slack 3 頻道 Pillar 2: Logs Promtail → Loki Scrape Jobs: • docker-containers (全部) • shioaji-logs (結構化解析) • ibapi-logs (結構化解析) • syslog (系統日誌) JSON pipeline: log → level label 提取 Pillar 3: Traces Tempo (OTLP) 接收端: • OTLP gRPC (:4317) • OTLP HTTP (:4318) • Zipkin (:9411) • Jaeger (:14268) ShioajiPy OpenTelemetry SDK 主動推送 Grafana — 統一視覺化 system-overview container-metrics resource-usage service-logs error-logs 獨立告警通道 health_monitor.py → Telegram 推送(不依賴監控堆疊) Docker Networks: infra-network ↔ shioaji-network ↔ ibapi-network 跨網路服務發現 · Prometheus 多網路 scrape · Tempo 跨專案 trace 收集 管理入口 Homepage Traefik Dashboard Portainer Grafana Prometheus Seq

整個監控堆疊和交易系統共存於同一台 VM,透過 Docker 的 bridge network 互相連接。


Pillar 1:Metrics — 用數字描述系統狀態

Metrics 回答的問題是:「系統現在怎麼樣?」 — CPU 多少、記憶體剩多少、每秒處理幾個請求、延遲是多少。

收集架構

Prometheus 每 15 秒主動 scrape 所有資料源:

Exporter職責關鍵指標
node-exporter主機層級CPU 使用率、記憶體、磁碟 I/O、網路流量
cAdvisor容器層級每個容器的 CPU/Mem/Net、OOM 事件
redis-exporterRedis連線數、ops/sec、記憶體使用、key 數量
TraefikAPI GatewayHTTP 狀態碼分佈、請求延遲直方圖
Docker SD自動發現透過 label prometheus.scrape=true 自動註冊

為什麼用 Docker Service Discovery?

手動維護 static_configs 在容器數量多的環境中是不可行的。Docker SD 讓 Prometheus 自動發現標記了 prometheus.scrape=true 的容器,並從 label 中讀取 metrics port:

- job_name: 'docker-containers' docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 30s relabel_configs: - source_labels: [__meta_docker_container_label_prometheus_scrape] regex: 'true' action: keep

新增一個微服務時,只需要在 docker-compose.yml 加上 label,Prometheus 就會自動開始收集它的指標。零配置擴展。

告警規則

Alertmanager 根據嚴重程度分流到不同 Slack 頻道:

嚴重程度路由等待時間重複間隔
Critical#critical-alerts10 秒1 小時
Warning#infrastructure-alerts1 分鐘4 小時
Trading#trading-alerts10 秒30 分鐘

告警規則分為三個檔案:

  • container.rules.yml — 容器 OOM、異常重啟、CPU 飆高
  • node.rules.yml — 主機磁碟空間不足、記憶體壓力、CPU 持續高負載
  • trading.rules.yml — 交易服務專用(資料收集中斷、策略執行異常)

交易系統的告警有獨立路由,因為這些告警的時效性和重要性與基礎設施告警完全不同 — 錯過一個交易訊號的代價遠高於 CPU 飆到 80%。


Pillar 2:Logs — 用文字記錄發生了什麼

Metrics 告訴你「系統異常」,Logs 告訴你「異常的細節」。兩者的關係是:Metrics 觸發告警,Logs 定位根因。

收集架構

Promtail 從 Docker 容器的 JSON 日誌中收集,推送到 Loki:

Docker 容器 → JSON log driver → Promtail 讀取 → 解析 → Loki 儲存

四個 Scrape Job 的設計考量

Job目標特殊處理
docker-containers所有容器通用 JSON 解析,取得 container name 和 compose project
shioaji-logsShioaji 微服務群二次 JSON 解析提取 level label(結構化日誌)
ibapi-logsIBAPI 服務同上,提取 levelmessage
syslog主機系統日誌正則解析 syslog 格式

為什麼要分開?因為不同專案的日誌格式不同。Shioaji 和 IBAPI 都使用結構化 JSON 日誌,但欄位名稱和結構有差異。通用 job 只做第一層 Docker JSON 解析,專案 job 再做第二層應用程式 JSON 解析,並將 level 提取為 Loki label:

pipeline_stages: - json: expressions: level: level message: message - labels: level:

這樣在 Grafana 中就能用 {project="shioajipy", level="error"} 快速過濾錯誤日誌,而不用全文搜索。

為什麼不用 ELK?

Loki 的設計哲學是只索引 label,不索引日誌內容。這讓它在資源有限的環境中(單台 VM、8GB RAM)特別適用。相比 Elasticsearch 需要大量記憶體來維護全文索引,Loki 的記憶體佔用小得多。

在 35 個容器共享 8GB RAM 的環境中,每一個 MB 都很珍貴。


Pillar 3:Traces — 追蹤請求的完整旅程

一個 API 請求從進入系統到返回結果,中間可能經過多個微服務。Traces 回答的問題是:「這個請求經歷了什麼?在哪裡花了最多時間?」

收集架構

與 Metrics 和 Logs 的「pull 模式」不同,Traces 採用應用程式主動推送

ShioajiPy (OpenTelemetry SDK) → OTLP gRPC → Tempo

Tempo 支援多種協議接收,但我主要使用 OTLP gRPC:

協議端口用途
OTLP gRPC4317ShioajiPy 主要推送通道
OTLP HTTP4318備用(防火牆限制 gRPC 時使用)
Zipkin9411相容舊系統
Jaeger14268相容 Jaeger client

記憶體限制的考量

Tempo 被限制在 512MB 記憶體:

tempo: deploy: resources: limits: memory: 512M

這是刻意的 — 在資源受限的 VM 上,tracing 的優先級低於交易服務本身。512MB 足以處理我們的 trace 量,但不會搶佔交易策略需要的記憶體。

Trace 與 Log 的關聯

ShioajiPy 的結構化日誌中包含 TraceIdSpanId

{ "event": "查詢力道分析: contract=TX1, date=2026-03-30", "TraceId": "3522eb77b668b90a3ec81cb893cce6eb", "SpanId": "32d095024290a1b7", "Application": "shioaji-backend" }

在 Grafana 中,這讓你可以從一條日誌直接跳轉到對應的 trace,看到整個請求鏈的時序圖。這就是三支柱的價值 — 不是各自獨立運作,而是互相關聯。


獨立告警通道:監控的監控

上面三個支柱都依賴於監控基礎設施本身的健康。如果 Prometheus 掛了、Loki 掛了、Grafana 掛了 — 你的告警也跟著消失了。

這就是為什麼我額外建立了一個完全獨立的告警通道

health_monitor.py — 三層告警

Layer 1: Redis TCP 連線檢查 ↓ 失敗 → Telegram 告警 Layer 2: ShioajiPy HTTP Health Check(10 個對外服務) ↓ 失敗 → Telegram 告警 Layer 3: IBAPI Diagnostics API + 資料庫連線 ↓ 失敗 → Telegram 告警

這個腳本的設計原則:

原則說明
零依賴只使用 Python 標準庫,不需要 pip install 任何東西
零 token純 HTTP 檢查,不消耗 AI token
獨立運行不依賴 Docker、不依賴監控堆疊、不依賴任何微服務
直接推送透過 Telegram Bot API 直接發送,不經過任何中間層

為什麼排除 WS-Gateway?

最初的版本檢查了所有 11 個 Shioaji 服務,但 WS-Gateway 是內部服務,不對外暴露 HTTP 端口。從外部檢查它永遠會失敗,產生假陽性告警。移除後,告警準確率從 ~90% 提升到 100%。

告警的第一條規則:假陽性比漏報更危險。 當團隊習慣忽略告警後,真正的問題也會被忽略。


網路拓撲:跨專案的服務發現

35 個容器分屬不同的 Docker Compose 專案,需要透過外部網路互相連接:

網路用途連接的服務
infra-network監控元件互連Prometheus、Loki、Tempo、Grafana、Alertmanager
shioaji-networkShioaji 微服務 + 監控15 個微服務 + Prometheus + Tempo + Redis Exporter
ibapi-networkIBAPI 服務 + 入口API + Web + PostgreSQL + Homepage

Prometheus 同時加入 infra-networkshioaji-network,這樣它才能 scrape Shioaji 微服務的指標。同理,Tempo 也需要加入 shioaji-network 來接收 OTLP trace 推送。

networks: infra-network: name: infra-network shioaji-network: external: true # 由 ShioajiPy 的 docker-compose 建立 ibapi-network: name: ibapi-trading_default external: true # 由 IBAPI 的 docker-compose 建立

這種「監控基礎設施獨立部署,但加入應用網路」的模式,讓監控和應用的生命週期解耦。重啟監控不影響交易,重啟交易不影響監控。


Grafana Dashboard 設計

五個 Dashboard 各有明確職責:

Dashboard資料源核心用途
system-overviewPrometheus全局一覽:CPU、記憶體、磁碟、網路
container-metricsPrometheus + cAdvisor單一容器深入分析
resource-usagePrometheus資源使用趨勢,預測容量瓶頸
service-logsLoki按服務過濾日誌,關聯 TraceId
error-logsLoki只顯示 ERROR/FATAL 級別,快速定位問題

從告警到根因的完整路徑

一個典型的問題排查流程:

Alertmanager 發出告警:容器記憶體超過 90% ↓ system-overview:確認是哪台主機、哪段時間 ↓ container-metrics:找到是 shioaji-mud-strategy 佔用最多 ↓ service-logs:過濾該容器的 ERROR 日誌 ↓ 發現 OOM 前的 warning:「快照數量超過閾值」 ↓ error-logs:找到根因 — 夜盤跨日查詢返回了雙倍資料量 ↓ 透過 TraceId 跳轉到 Tempo,確認是哪個 API 呼叫觸發

這就是三支柱協同的威力 — 從一個指標告警,一路追蹤到具體的程式碼路徑。


實際案例:Tempo 重啟事件

以下是一個真實的排查案例,展示可觀測性架構如何幫助快速定位問題:

現象

系統健康檢查顯示全部服務正常,但 ShioajiPy 的日誌中出現間歇性的 warning:

Transient error StatusCode.UNAVAILABLE encountered while exporting traces to host.docker.internal:4317, retrying in 0.84s.

排查路徑

步驟觀察工具
1Warning 只出現在 ShioajiPy 的 backendLoki (service-logs)
2目標是 :4317 — Tempo 的 OTLP gRPC 端口對照架構圖
3Tempo 容器 uptime 只有 52 分鐘(其他 3-5 天)docker ps
4Tempo 重啟前後的記憶體使用接近 512MB 上限container-metrics
5重啟後 trace export 自動恢復ShioajiPy 日誌中 warning 停止

根因與處理

Tempo 因記憶體壓力被 OOM killed,Docker 的 restart: unless-stopped 自動重啟了它。期間 ShioajiPy 的 OpenTelemetry SDK 偵測到連線失敗,自動執行 exponential backoff(0.84s → 1.02s → 2.04s),等 Tempo 恢復後自動重連。

不需要人工介入。 但這個事件暴露了一個潛在風險:如果 VM 記憶體持續吃緊,Tempo 可能會反覆重啟。


資源約束下的取捨

在 8GB RAM 的 VM 上跑 35 個容器 + 完整監控堆疊,資源取捨是不可避免的:

決策理由
Loki 而非 Elasticsearch記憶體佔用小一個數量級
Tempo 限制 512MBTracing 重要但不能搶交易服務的記憶體
Prometheus 保留 15 天更長的保留需要更多磁碟,15 天足夠分析趨勢
health_monitor.py 用標準庫不額外消耗記憶體,不依賴 runtime
不裝 Jaeger UIGrafana 已經能查 Tempo 的 trace

記憶體分配現況

交易服務 (Shioaji + IBAPI + Redis) ~3.5 GB 監控堆疊 (Prometheus + Loki + Tempo + Grafana) ~1.2 GB 系統與其他服務 ~1.0 GB ───────────────────────────────────────────── 已用 ~5.7 GB / 8 GB Swap 使用 ~1.7 GB

Swap 被使用代表記憶體已接近飽和。對於交易系統,這是一個需要持續觀察的指標 — Swap I/O 比 RAM 慢 10-100 倍,可能導致策略執行出現延遲抖動。


經驗總結

1. 可觀測性的本質是關聯

三個支柱單獨運作時各有價值,但真正的威力在於它們之間的關聯。從 metric 到 log 到 trace 的一鍵跳轉,讓排查時間從小時級縮短到分鐘級。

2. 監控的監控不是多餘的

當你的告警系統依賴於監控基礎設施,你就需要一個不依賴它的備用通道。health_monitor.py 的價值不在於平時 — 在於 Prometheus 掛掉的那一天。

3. 假陽性是告警系統的頭號殺手

WS-Gateway 的案例證明了這一點。每一個假陽性都在消耗團隊對告警系統的信任。寧可少一個告警,也不要多一個假陽性。

4. 資源約束迫使你做更好的設計

8GB RAM 的限制讓我不得不選擇 Loki 而非 ELK、限制 Tempo 記憶體、精簡 Dashboard 數量。這些約束反而產生了一個更簡潔、更容易維護的架構。

5. 結構化日誌是基石

如果日誌不是結構化的,Promtail 無法提取 level label,Grafana 無法精確過濾,trace 關聯也無法實現。在系統設計初期就採用結構化日誌,後續的可觀測性才有可能。


下一步

目前的架構仍有幾個待改進的方向:

  • health_monitor.py 排程決策--loop 常駐模式 vs Task Scheduler 定時觸發
  • Tempo 記憶體優化:評估是否需要調整 trace 採樣率來降低記憶體壓力
  • IBAPI 的 trace 整合:目前只有 ShioajiPy 有 OpenTelemetry,IBAPI 的 FastAPI 服務尚未接入
  • 異常時自動觸發 Claude Code 自癒:讓 health_monitor.py 在偵測到問題時,自動觸發 /self-heal 流程
本文章以 CC BY 4.0 授權