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

[OTel 深水區] 徹底拆解 deltatocumulative 的兩大夢魘:ErrOlderStart 與 ErrOutOfOrder
接續上一篇我們對 deltatocumulative Processor 的架構剖析,今天我們要進入「深水區」。
在生產環境中,你可能遇過這種情況:Metrics 莫名其妙開始 Drop,去查 deltatocumulative_datapoints 指標時,看到 error 標籤出現了 delta.ErrOutOfOrder 或 delta.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) 中驗證了以下關鍵行為,這直接決定了你會不會踩坑:
- 相同 Attributes = 相同 Stream:
即使 Timestamp 或 Value 不同,只要上述方框內的屬性完全一樣,它們就是同一條 Stream。這就是為什麼多個 Pod 如果沒有帶
pod.name會導致 Collision(衝突)。 - Resource 不同 = 不同 Stream:
來自
host-1和host-2的數據,即使 Metric Name 一樣,也是兩條獨立的平行線,互不干擾。 - 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。
- Pod A (較新):
StartTimestamp = 1000 - Pod B (較舊):
StartTimestamp = 500
Processor 的視角:
- 收到 Pod A 數據 → 記住:「這個 Stream 是 1000 出生的」。
- 收到 Pod B 數據(500 出生)。
- 檢查:
500 < 1000?報錯ErrOlderStart並丟棄。
診斷結論: 只要看到這個錯誤,90% 都是 Label 設定問題。你的數據來源不單純(Multiple Processes),有多個實例在搶同一個身份。
4. 第二道防線:ErrOutOfOrder (時光倒流)
如果通過了第一關,數據會來到第二關。
- 錯誤代碼:
ErrOutOfOrder - 直白翻譯:「現在已經 12 點了,你怎麼還在送 11 點的數據?」
- 觸發條件:
New.Timestamp <= Stored.Timestamp
為什麼會發生?
這通常是網路或傳輸層的問題,而非配置問題:
- 網路亂序:數據包迷路了,晚送出的反而早到。
- 重試風暴 (Retry):Prometheus Remote Write 因為發送失敗而重送舊數據。
- 時鐘不同步:不同機器上的時間沒對準。
5. 深度情境分析:那些詭異的組合
這部分是高手進階的關鍵,我們來看兩種特殊組合。
情境 A:有 ErrOlderStart 但「沒有」ErrOutOfOrder
這完全可能發生,而且是診斷「多實例衝突」的鐵證。
假設有兩個進程 A (新, Start=1000) 和 B (舊, Start=500) 交錯發送:
- 收到 A (Start=1000, Time=1100) → OK
- 收到 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_detection 或 k8s_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_NotAffectedBy | timestamp/value 不影響 identity |
Part 2: Delta Aggregate 錯誤測試
| 測試案例 | 驗證內容 |
TestDeltaAggregate_FirstSample | 第一筆數據直接存入 state |
TestDeltaAggregate_NormalSequence | 正常數據序列不報錯 |
TestDeltaAggregate_ErrOutOfOrder | 驗證 ErrOutOfOrder 觸發條件 |
TestDeltaAggregate_ErrOlderStart | 驗證 ErrOlderStart 觸發條件 |
TestDeltaAggregate_ErrorPriority | ErrOlderStart 優先於 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...
總結
ErrOlderStart 和 ErrOutOfOrder 雖然都會導致丟包,但意義截然不同。
ErrOlderStart 是你的設定錯了(撞名),ErrOutOfOrder 是網路環境亂了。
下次看到數據掉點,別急著重啟 Collector,先看看 error label 告訴你什麼故事。
為解決 deltatocumulative Processor 中 ErrOlderStart 錯誤而設計的 k8s_attributes 實踐配置。
核心策略:唯一性 (Uniqueness)
要根除 ErrOlderStart,我們必須確保來自不同 Pod 的數據流擁有不同的 Stream Identity。根據上一篇的分析,Resource Attributes 是 Identity 的重要組成部分。
因此,我們的目標是:強勢注入 k8s.pod.name 和 k8s.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 Processor | k8s_attributes Processor |
| RBAC 權限 | ✅ 不需要 (由 K8s 自身注入) | ❌ 需要 (List/Watch Pods) |
| API Server 負載 | ✅ 零負載 (靜態變數) | ⚠️ 有負載 (需建立 Watch 連線) |
| 適用場景 | Sidecar 模式首選 | DaemonSet / Gateway 模式首選 |
| 即時性 | 啟動即生效,無延遲 | 可能有啟動延遲 (需等待 List 完成) |
| 資料豐富度 | 受限於 Downward API 支援的欄位 | 可抓取所有 Labels 與 Annotations |
點評:為什麼這也能防 ErrOlderStart?
deltatocumulative 並不在乎標籤是「誰」貼上去的。它只在乎當它計算 Hash 時,Resource 裡有沒有這兩個欄位:
k8s.pod.name: 區分了 Pod A 和 Pod B。k8s.pod.uid: 區分了 Pod A (昨天死的) 和 Pod A (今天重啟的)。
透過 Downward API 注入這兩個值,Stream ID 就會變成全宇宙唯一,徹底解決身份衝突與 ErrOlderStart 問題,而且完全不需要跟 K8s API Server 打交道,效能更好、安全性更高。






