Skip to main content

Command Palette

Search for a command to run...

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

Updated
6 min readView as Markdown
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 read241
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 read109
工程師的 Claude Code 實戰指南:從零開始到高效開發
M

MicroFIRE

73 posts