Skip to main content

Command Palette

Search for a command to run...

OTel 戰地筆記:破解 Delta to Cumulative 的兩大夢魘 (ErrOutOfOrder & ErrOlderStart)

Updated
6 min read
OTel 戰地筆記:破解 Delta to Cumulative 的兩大夢魘 (ErrOutOfOrder & ErrOlderStart)

[OTel 深水區] 徹底拆解 deltatocumulative 的兩大夢魘:ErrOlderStart 與 ErrOutOfOrder

接續上一篇我們對 deltatocumulative Processor 的架構剖析,今天我們要進入「深水區」。

在生產環境中,你可能遇過這種情況:Metrics 莫名其妙開始 Drop,去查 deltatocumulative_datapoints 指標時,看到 error 標籤出現了 delta.ErrOutOfOrderdelta.ErrOlderStart

這不僅僅是報錯,這是 Processor 在告訴你:你的數據流違反了物理定律。本文將從 Stream Identity (身份識別) 的源碼層級出發,徹底拆解這兩個錯誤的根源。


1. 核心根源:Stream Identity (身份識別)

要讀懂錯誤,首先得搞懂 Processor 怎麼判定「這是同一條 Stream」。許多人誤以為只要 Metric Name 一樣就是同一條線,這是大錯特錯。

在源碼中,Stream Identity 是一個 Hash 值,其組成結構非常嚴謹:

Stream Identity = Hash of:
┌─────────────────────────────────────────────────────────────────────┐
│  Metric                                                             │
│  ├── Scope                                                          │
│  │   ├── Resource                                                   │
│  │   │   └── resource.attributes (hash)                             │
│  │   │       (關鍵!: service.name, host.name, k8s.pod.name)          │
│  │   ├── scope.name       (例: "otelcol/prometheus")                 │
│  │   ├── scope.version    (例: "v0.90.0")                           │
│  │   └── scope.attributes (hash)                                    │
│  ├── metric.name          (例: "http_requests_total")               │
│  ├── metric.unit          (例: "ms")                                │
│  ├── metric.type          (例: Sum, Histogram)                      │
│  ├── metric.monotonic     (true/false)                              │
│  └── metric.temporality   (Delta/Cumulative)                        │
└─────────────────────────────────────────────────────────────────────┘
      +
datapoint.attributes (hash)   (例: method="GET", status="200")

實驗室發現:身份判定的黃金法則

我們在單元測試 (TestStreamIdentity) 中驗證了以下關鍵行為,這直接決定了你會不會踩坑:

  1. 相同 Attributes = 相同 Stream: 即使 Timestamp 或 Value 不同,只要上述方框內的屬性完全一樣,它們就是同一條 Stream。這就是為什麼多個 Pod 如果沒有帶 pod.name 會導致 Collision(衝突)。
  2. Resource 不同 = 不同 Stream: 來自 host-1host-2 的數據,即使 Metric Name 一樣,也是兩條獨立的平行線,互不干擾。
  3. Timestamp 與 Value 不影響 Identity
欄位是否影響 Identity
datapoint.timestamp不影響
datapoint.start_timestamp不影響
datapoint.value不影響
datapoint.attributes影響

這解釋了為什麼同一條 Stream 的多個 Datapoints 會被聚合——因為它們共享同一個 Identity。


2. 決策樹:Processor 是怎麼思考的?

Processor 在處理數據時是有優先順序的:先查身世 (Start),再查時序 (Time)

理解了這個流程,我們就能針對兩個錯誤進行深度解析。


3. 第一道防線:ErrOlderStart (身份錯亂)

這是最嚴重、也最常被誤判的錯誤。

  • 錯誤代碼ErrOlderStart
  • 直白翻譯:「你明明比我年輕,為什麼出生日期比我還老?」
  • 觸發條件New.StartTimestamp < Stored.StartTimestamp

為什麼會發生? (The "Highlander" Rule)

這通常不是時間穿越,而是「身份撞車 (Identity Collision)」。Processor 預設一個 Stream(Identity)在同一時間只能有一個來源。

試想這個場景:你有兩個 Pod (A 和 B) 都在跑同一個 Service,但你忘記在 Metric Label 裡加上 pod_name

  1. Pod A (較新)StartTimestamp = 1000
  2. Pod B (較舊)StartTimestamp = 500

Processor 的視角:

  1. 收到 Pod A 數據 → 記住:「這個 Stream 是 1000 出生的」。
  2. 收到 Pod B 數據(500 出生)。
  3. 檢查:500 < 1000報錯 ErrOlderStart 並丟棄

診斷結論: 只要看到這個錯誤,90% 都是 Label 設定問題。你的數據來源不單純(Multiple Processes),有多個實例在搶同一個身份。


4. 第二道防線:ErrOutOfOrder (時光倒流)

如果通過了第一關,數據會來到第二關。

  • 錯誤代碼ErrOutOfOrder
  • 直白翻譯:「現在已經 12 點了,你怎麼還在送 11 點的數據?」
  • 觸發條件New.Timestamp <= Stored.Timestamp

為什麼會發生?

這通常是網路或傳輸層的問題,而非配置問題:

  1. 網路亂序:數據包迷路了,晚送出的反而早到。
  2. 重試風暴 (Retry):Prometheus Remote Write 因為發送失敗而重送舊數據。
  3. 時鐘不同步:不同機器上的時間沒對準。

5. 深度情境分析:那些詭異的組合

這部分是高手進階的關鍵,我們來看兩種特殊組合。

情境 A:有 ErrOlderStart 但「沒有」ErrOutOfOrder

這完全可能發生,而且是診斷「多實例衝突」的鐵證。

假設有兩個進程 A (新, Start=1000) 和 B (舊, Start=500) 交錯發送:

  1. 收到 A (Start=1000, Time=1100) → OK
  2. 收到 B (Start=500, Time=600) → Fail (因為 500 < 1000,第一關直接擋下)

結論:B 的數據在第一關就被擋下來了,根本沒機會觸發第二關的 ErrOutOfOrder。如果你只看到 ErrOlderStart,請直接檢查你的 Label 設定。

情境 B:有 ErrOutOfOrder 但「沒有」ErrOlderStart

這反而是好消息:這代表你的 Stream Identity 是乾淨的。

這表示沒有多個 Pod 在互搶身份,純粹是單一來源的數據傳輸有點顛簸(網路延遲、Batch Flush 順序或重送)。

結論

  • 資料來自同一個 series
  • StartTimestamp 完全一致(不可能觸發 ErrOlderStart)。
  • 問題僅在於傳輸順序。

6. 實戰除錯 SOP

當你在 Grafana 看到 deltatocumulative_datapoints 的 drop rate 飆高時,請依照以下步驟排查:

Step 1: 區分敵我

用這句 PromQL 撈撈看錯誤分佈:

sum by (error) (rate(otelcol_deltatocumulative_datapoints{error!=""}[5m]))

Step 2: 對症下藥

錯誤類型嫌疑犯 (Root Cause)處置行動 (Action Item)
ErrOlderStart配置架構問題
多個實例 (Pod/Process) 共享了相同的 Identity。
檢查 Resource/Attributes

查看 resource_detectionk8s_attributes processor。確保每個 Metric 都帶有能區分實例的標籤 (如 k8s.pod.name)。
ErrOutOfOrder環境傳輸問題

網路抖動、Client 重試。
通常無需過度介入

如果是持續發生,檢查發送端的 batch 設定或 NTP 時間同步。
Limit資源限制

Stream 總數爆了。
調整配置

調大 max_streams 上限(參考上一篇文章)。

7. 附錄:測試驗證結構

為了確保上述理論正確,我們參考了以下的測試案例結構,測試程式碼

Part 1: Stream Identity 測試

測試案例驗證內容
TestStreamIdentity_SameAttributes_SameStream相同 attrs = 相同 stream
TestStreamIdentity_DifferentAttributes_DifferentStream不同 attrs = 不同 stream
TestStreamIdentity_Collision_MultiplePodsWithoutInstanceLabel展示 identity collision
TestStreamIdentity_Components各組成部分對 identity 的影響
TestStreamIdentity_NotAffectedBytimestamp/value 不影響 identity

Part 2: Delta Aggregate 錯誤測試

測試案例驗證內容
TestDeltaAggregate_FirstSample第一筆數據直接存入 state
TestDeltaAggregate_NormalSequence正常數據序列不報錯
TestDeltaAggregate_ErrOutOfOrder驗證 ErrOutOfOrder 觸發條件
TestDeltaAggregate_ErrOlderStart驗證 ErrOlderStart 觸發條件
TestDeltaAggregate_ErrorPriorityErrOlderStart 優先於 ErrOutOfOrder
TestDeltaAggregate_RealWorldScenario_MultiplePodsCollision模擬多 Pod collision
TestDeltaAggregate_RealWorldScenario_NetworkDelay模擬網路延遲亂序

關鍵測試日誌輸出範例

ErrOlderStart 場景:

Pod B rejected: dropped sample with start_time=...0000005..., 
because series only starts at start_time=...000001... 
consider checking for multiple processes sending the exact same series

ErrOutOfOrder 場景:

Delayed data rejected: out of order: dropped sample from time=...0000011..., 
because series is already at time=...0000012...

總結

ErrOlderStartErrOutOfOrder 雖然都會導致丟包,但意義截然不同。 ErrOlderStart 是你的設定錯了(撞名),ErrOutOfOrder 是網路環境亂了。

下次看到數據掉點,別急著重啟 Collector,先看看 error label 告訴你什麼故事。


為解決 deltatocumulative Processor 中 ErrOlderStart 錯誤而設計的 k8s_attributes 實踐配置。

核心策略:唯一性 (Uniqueness)

要根除 ErrOlderStart,我們必須確保來自不同 Pod 的數據流擁有不同的 Stream Identity。根據上一篇的分析,Resource Attributes 是 Identity 的重要組成部分。

因此,我們的目標是:強勢注入 k8s.pod.namek8s.pod.uid 到每一筆數據的 Resource 中。


1. Collector 配置範例 (config.yaml)

這份配置適用於以 DaemonSet 方式運行的 OTEL Collector Agent。這是最推薦的部署模式,因為 Agent 可以直接透過來源 IP 關聯到 Pod。

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  # 1. Kubernetes 屬性增強 (關鍵步驟)
  k8sattributes:
    auth_type: "serviceAccount"
    passthrough: false
    filter:
      node_from_env_var: K8S_NODE_NAME # 只處理本節點的 Pod,提升效能

    # 這裡定義要抓取哪些 K8s Metadata 塞進 Resource Attribute
    extract:
      metadata:
        - k8s.pod.name       # <--- 絕對必要:區分不同 Pod
        - k8s.pod.uid        # <--- 強烈建議:區分同名 Pod 重啟前後
        - k8s.deployment.name
        - k8s.namespace.name
        - k8s.node.name

    # 定義如何透過網路連線找到對應的 Pod
    pod_association:
      - sources:
          - from: connection # 透過來源 IP 自動關聯

  # 2. 記憶體限制 (標準配置)
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 15

  # 3. 轉化為 Cumulative (我們的主角)
  deltatocumulative:
    max_streams: 100000 # 記得根據上一篇建議調整
    max_stale: 5m

exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"

service:
  pipelines:
    metrics:
      receivers: [otlp]
      # 注意順序:先加標籤 (k8sattributes),再做聚合 (deltatocumulative)
      processors: [memory_limiter, k8sattributes, deltatocumulative] 
      exporters: [prometheus]

2. 為什麼這樣配能解決 ErrOlderStart?

讓我們回到源碼層級的 Hash 公式:

修正前 (Collision):

Stream ID = Hash(
  Resource: { service.name="payment" },  <-- 所有 Pod 都一樣
  Metric:   { name="http_requests" }
)
==> 結果:Pod A 和 Pod B 產生相同的 Hash,Processor 以為它們是同一個 Stream,導致時間戳衝突。

修正後 (Unique):

Stream ID (Pod A) = Hash(
  Resource: { service.name="payment", k8s.pod.name="payment-zx9q", k8s.pod.uid="uuid-1" },
  Metric:   { name="http_requests" }
)

Stream ID (Pod B) = Hash(
  Resource: { service.name="payment", k8s.pod.name="payment-ab12", k8s.pod.uid="uuid-2" },
  Metric:   { name="http_requests" }
)
==> 結果:Hash 不同,Processor 視為兩條平行線,ErrOlderStart 消失。

特別說明:為什麼需要 k8s.pod.uid

雖然 k8s.pod.name 解決了不同 Pod 的衝突,但如果同一個 Pod 被殺掉重建(Recreated),名稱可能不變(例如 StatefulSet 或某些 Deployment 策略)。加上 uid 可以確保即使是「同名的轉世」也會被視為全新的 Stream,徹底杜絕舊數據干擾。


如果你不想賦予 Collector 複雜的 K8s RBAC 權限(讀取 Pods/Nodes),或者你的 Collector 是以 Sidecar 模式運作,「K8s Downward API + Resource Processor」 是完全可以取代 k8s_attributes 的最佳替代方案。

這個組合拳的邏輯是:「與其讓 Collector 去問 API Server 我是誰,不如我在啟動時直接把身分證塞進它的口袋。」

以下是完整的配置範例:


1. K8s YAML 修改 (注入身份)

首先,我們需要利用 K8s 的 Downward API,將 Pod 的 metadata 變成環境變數 (Environment Variables) 注入到 Collector 的容器中。

修改你的 Deployment 或 DaemonSet:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-sidecar
spec:
  template:
    spec:
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:latest
          env:
            # --- 關鍵:利用 Downward API 注入變數 ---
            - name: K8S_POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: K8S_POD_UID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.uid
            - name: K8S_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: K8S_NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            # -------------------------------------
          volumeMounts:
            - mountPath: /etc/otelcol
              name: otel-config

2. OTel Collector Config (讀取身份)

接下來,在 Collector 的配置中,我們不使用 k8s_attributes,而是改用 resource processor (注意:不是 attributes processor)。

為什麼是 resource processor? 因為上一篇提到,Stream Identity 的判定依賴於 Resource Attributes。普通的 attributes processor 預設是修改 DataPoint 屬性,這對 Stream Identity 的修正無效。我們必須修改 Resource 層級。

processors:
  # 使用 Resource Processor 抓取環境變數
  resource:
    attributes:
      - key: k8s.pod.name
        value: "${env:K8S_POD_NAME}"  # 讀取 K8s 注入的變數
        action: upsert
      - key: k8s.pod.uid
        value: "${env:K8S_POD_UID}"   # 解決 ErrOlderStart 的核心
        action: upsert
      - key: k8s.namespace.name
        value: "${env:K8S_NAMESPACE}"
        action: upsert
      - key: k8s.node.name
        value: "${env:K8S_NODE_NAME}"
        action: upsert

  deltatocumulative:
    max_streams: 100000

service:
  pipelines:
    metrics:
      receivers: [otlp]
      # 順序很重要:先 Resource (貼標籤) -> 再 Delta (計算)
      processors: [resource, deltatocumulative] 
      exporters: [prometheus]

3. 這個方案 vs k8s_attributes 的比較

這兩種方法都能達到讓 Stream Identity 唯一化的目的,消除 ErrOlderStart

特性Downward API + Resource Processork8s_attributes Processor
RBAC 權限不需要 (由 K8s 自身注入)需要 (List/Watch Pods)
API Server 負載零負載 (靜態變數)⚠️ 有負載 (需建立 Watch 連線)
適用場景Sidecar 模式首選DaemonSet / Gateway 模式首選
即時性啟動即生效,無延遲可能有啟動延遲 (需等待 List 完成)
資料豐富度受限於 Downward API 支援的欄位可抓取所有 Labels 與 Annotations

點評:為什麼這也能防 ErrOlderStart

deltatocumulative 並不在乎標籤是「誰」貼上去的。它只在乎當它計算 Hash 時,Resource 裡有沒有這兩個欄位:

  1. k8s.pod.name: 區分了 Pod A 和 Pod B。
  2. k8s.pod.uid: 區分了 Pod A (昨天死的) 和 Pod A (今天重啟的)。

透過 Downward API 注入這兩個值,Stream ID 就會變成全宇宙唯一,徹底解決身份衝突與 ErrOlderStart 問題,而且完全不需要跟 K8s API Server 打交道,效能更好、安全性更高。

More from this blog

Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

稟告主公:此乃司馬懿進呈之兵書,詳解如何以 OpenTelemetry 陣法,令臥龍神算之一舉一動盡在掌握,知糧草消耗、察兵器效能、辨戰報異常,使主公運籌帷幄於大帳之中。 為何需要斥候情報? 司馬懿稟告主公: 臥龍神算(Claude Code)乃當世利器,然若無斥候回報,主公便如蒙眼行軍——兵器耗損幾何、糧草消費幾許、哪路斥候出了差錯,一概不知。臣以為,此乃兵家大忌。 無情報之弊,有四: 軍

Feb 19, 202610 min read163
Claude Code 監控秘錄:OpenTelemetry(OTel/OTLP)實戰指南

工程師的 Claude Code 實戰指南:從零開始到高效開發

工程師的 Claude Code 實戰指南:從零開始到高效開發 本文整合 Anthropic 官方 Best Practices 與社群實戰 Tips,帶你由淺入深掌握 Claude Code。 什麼是 Claude Code?為什麼值得學? 如果你還在用「複製程式碼貼到 ChatGPT,再複製答案貼回去」的工作流程,Claude Code 會讓你大開眼界。 Claude Code 是 Anthropic 推出的命令列工具,它直接活在你的 terminal 裡,能夠讀懂你的整個 codeb...

Feb 18, 20265 min read75
工程師的 Claude Code 實戰指南:從零開始到高效開發

System Design Interview Ch 12 Digital Wallet

確立問題與設計範疇 角色對話內容 面試者我們應該只關注兩個數位錢包之間的餘額轉帳操作嗎?我們是否需要擔心其他功能? 面試官讓我們只關注餘額轉帳操作。 面試者該系統需要支援多少 TPS(每秒交易次數)? 面試官讓我們假設是 1,000,000 TPS (每秒 100 萬次交易)。 面試者數位錢包對正確性有嚴格的要求。我們可以假設事務保證 就足夠了嗎? 面試官聽起來不錯。 面試者我們需要證明正確性嗎? 面試官這是一個很好的問題。正確性(Correctness)通常只有在交...

Feb 2, 202610 min read190
System Design Interview Ch 12 Digital Wallet

Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦

在現代 AI 輔助開發中,我們不僅需要 AI 寫程式,更需要它懂規則、記性好,並且能自動處理那些繁瑣的雜事。透過 Claude Code Hooks 機制,我們可以介入 AI 的思考與執行迴圈,實現真正的「人機協作自動化」。 一、 動機與痛點:為什麼你需要介入 AI 的生命週期? 在預設狀態下,Claude Code 雖然強大,但它是「被動」且「無狀態」的,這導致了開發者常遇到以下痛點: 記憶重置 (Session Amnesia): 痛點:每次重啟終端機,AI 就像失憶一樣。 解法:你...

Jan 24, 20266 min read455
Claude Code 利用 Event-Driven Hooks 打造自動化開發大腦
M

MicroFIRE

71 posts