cloud

為 Kubernetes 應用程式建立 OpenTelemetry 堆疊

將可觀測性後端放在 Kubernetes 之外,分別收集 application telemetry、Pod stdout 與 host logs。

English繁中
為 Kubernetes 應用程式建立 OpenTelemetry 堆疊

我的應用程式在 Kubernetes 裡執行,可觀測性後端則留在叢集之外。OpenTelemetry Collector、Prometheus、Loki、Tempo 與 Grafana 都以 Docker Compose 跑在 NUC 上;即使我正在重建叢集,仍然能打開 Grafana 查看已保存的資料。

Application telemetry、Pod stdout 與 host logs 分別由不同的 collector 收集:

application OTLP → OpenTelemetry Collector → Prometheus/Loki/Tempo
Pod stdout       → Alloy DaemonSet         → Loki
host file        → host Alloy              → Loki
                                             Grafana

Collector 健康不代表 Pod stdout 已被收集;Loki 顯示 ready,也不代表資料已送達。每條路徑都需要確認實際收到的資料。

為什麼後端放在 Kubernetes 之外

Docker Compose 並不必然比 Kubernetes 更適合執行 observability backend。這樣安排符合我的環境,是因為它拆開了兩個故障範圍:當 Kubernetes networking、storage 或 Argo CD 發生問題時,host 上的 Grafana 和歷史資料仍然可以用來排錯。

代價是我要自己管理 Compose volumes、備份、retention,以及叢集到 host 的網路。Collector endpoint 也是內部服務,不能因為沒有對外公開就忽略存取限制。

後端包含以下服務:

  • OpenTelemetry Collector 在 43174318 接收 OTLP,並於 9464 暴露 Prometheus metrics。
  • Prometheus 儲存 Collector 匯出的 metrics。
  • Loki 儲存 OTLP application logs,以及 Alloy 轉送的 Pod stdout。
  • Tempo 儲存 traces。
  • Grafana 查詢這三個 backend。

Argo CD 另外將 Grafana Alloy 安裝為 Kubernetes 裡的 DaemonSet。Alloy 是負責 Pod stdout 的 node-local collector,不是 Compose project 的一部分。

Compose project 另外有一個權限範圍較小的 Alloy service,專門處理一種 host log source;另一個 init container 則只負責準備 Tempo data volume。

讓 Collector 只負責 OTLP

以下是既有 Compose project 的 service 片段。啟動前需合併至同一個 services key、選定相容的固定 image 版本,並補上各 backend 設定檔與持久化 volumes。片段省略了 Prometheus scrape config、Loki/Tempo storage config 與 Grafana provisioning。

目前的 Collector 不再掛載 /var/log/pods。它只接收 OTLP,因此可以使用 non-root user 執行,設定檔也以唯讀方式掛載。Compose service 另外限制 CPU、memory 與 PID:

services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:<pinned-version>
    user: "10001:10001"
    restart: unless-stopped
    cpus: "1.0"
    mem_limit: 768m
    pids_limit: 256
    command:
      - --config=/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
      - "9464:9464"
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro

Container 的 memory limit 要高於 Collector 內部的限制,讓 processor 能在 runtime 終止 container 前先降低負載。

Prometheus 的 Compose service 設定 retention 與 query port:

prometheus:
  image: prom/prometheus:<pinned-version>
  command:
    - --config.file=/etc/prometheus/prometheus.yml
    - --storage.tsdb.path=/prometheus
    - --storage.tsdb.retention.time=14d
    - --storage.tsdb.retention.size=20GB
  ports:
    - "9090:9090"

Loki 與 Tempo 分別保存 logs 和 traces;以下是對應的 service 片段:

loki:
  image: grafana/loki:<pinned-version>
  ports:
    - "3100:3100"

tempo:
  image: grafana/tempo:<pinned-version>
  command:
    - -target=all
    - -config.file=/etc/tempo.yaml
  ports:
    - "3200:3200"
    - "4319:4317"

Grafana 從環境變數取得 admin password,並關閉 anonymous access:

grafana:
  image: grafana/grafana:<pinned-version>
  ports:
    - "3000:3000"
  environment:
    GF_SECURITY_ADMIN_USER: admin
    GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set a password}
    GF_AUTH_ANONYMOUS_ENABLED: "false"

接收並分流 application telemetry

Collector 同時提供兩種 OTLP transport:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

只有定義 receiver 或 processor 並不會啟用它;元件還必須列入 service 下的 pipeline。Collector configuration 文件對排查「設定看起來正確、資料卻沒經過 processor」特別有用。

我先以 resource processor 補上固定的環境名稱,再於 metrics 送往 Prometheus exporter 前,移除 dashboards 不需要的 process 細節,同時保留辨識每個 metric producer 的身分:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128

  resource:
    attributes:
      - key: deployment.environment.name
        value: production
        action: upsert

  resource/metrics:
    attributes:
      - key: process.pid
        action: delete
      - key: process.command_args
        action: delete

  batch:
    timeout: 5s

應保留 service.instance.id,或其他能唯一辨識 producer 的 attributes。未經聚合就移除身分,可能讓多個 replicas 將各自的 counter 寫進同一條 series,違反 metrics single-writer 原則。需要 service 總量時,在查詢端聚合;刪除身分 label 不等於聚合,也要確認 application SDK 確實提供不同的 instance identity。

Request ID、任意 URL path parameter、查詢 domain 與 client IP 若需保留,可放在 log body 或 trace attribute,避免成為無上限增長的 metric 或 Loki label。固定的 environment 或受控的 service names 則是不同情況。

三條 pipeline 分別將 signal 送到對應 backend:

exporters:
  prometheus:
    endpoint: 0.0.0.0:9464
    resource_to_telemetry_conversion:
      enabled: true
  otlp_grpc/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  otlp_http/loki:
    endpoint: http://loki:3100/otlp

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, tail_sampling, batch]
      exporters: [otlp_grpc/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, resource, resource/metrics, batch]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [otlp_http/loki]

Tempo 與 Loki 能使用簡短的 service name,是因為它們和 Collector 位於同一個 Compose network;這些名稱不是 Kubernetes Service DNS。

用 tail sampling 保留錯誤與慢速 traces

家用環境不需要保存每一條 trace,但只做隨機抽樣容易漏掉故障。下方 policy 從抵達 Collector 的資料中,選取含有 ERROR span 的 traces、超過一秒的 traces,再從其餘流量保留 10% 作為 baseline:

processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 5000
    expected_new_traces_per_sec: 20
    policies:
      - name: errors
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: slow-requests
        type: latency
        latency:
          threshold_ms: 1000
      - name: baseline
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

判斷依據是等待時間內收到的 spans。上游 head sampling、span 延遲、buffer 上限或傳送失敗,都可能讓 error trace 沒有被保留;這是選取規則,不是完整收集的保證。參考 OpenTelemetry sampling

Tail sampling 必須先等待 spans,再決定是否保留整條 trace,因此會使用額外 memory。請將 tail_sampling 合併到前面的 processors mapping,不要建立第二個同名的頂層 key。未來若增加多個 Collector replicas,同一條 trace 的 spans 必須進入一致的 sampling tier,否則可能得到不完整的判斷。Collector scaling guide說明了這項限制。

由 Alloy 收集 Pod stdout

Pod stdout 適合交給 node-local collector。Argo CD 將 Grafana Alloy 部署為 DaemonSet,掛載 node 的 /var/log,透過 Kubernetes API 找到 Pods,並只保留排程在同一個 node 上的 targets。

這裡的 HOSTNAME 必須透過 Downward API 取自 spec.nodeName,不能使用預設的 Pod hostname;否則 node filter 可能把所有 targets 都排除。Grafana Alloy Helm chart 已設定這個來源,自訂 DaemonSet 則需自行提供。

discovery.kubernetes "pods" {
  role = "pod"
}

discovery.relabel "pod_logs" {
  targets = discovery.kubernetes.pods.targets

  rule {
    source_labels = ["__meta_kubernetes_pod_node_name"]
    action        = "keep"
    regex         = sys.env("HOSTNAME")
  }

  rule {
    source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
    separator     = "/"
    target_label  = "__path__"
    replacement   = "/var/log/pods/*$1/*.log"
  }

  rule {
    source_labels = ["__meta_kubernetes_namespace"]
    target_label  = "namespace"
  }

  rule {
    source_labels = ["__meta_kubernetes_pod_container_name"]
    target_label  = "container"
  }
}

local.file_match "pod_logs" {
  path_targets = discovery.relabel.pod_logs.output
}

loki.source.file "pod_logs" {
  targets    = local.file_match.pod_logs.targets
  forward_to = [loki.process.pod_logs.receiver]
}

loki.process "pod_logs" {
  stage.cri {}
  stage.decolorize {}
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://<observability-host>:3100/loki/api/v1/push"
  }
}

local.file_match__path__ 裡的 glob 展開成實際檔案路徑,loki.source.file 才負責讀取檔案並送到 processing pipeline;只做 discovery 和 relabel 並不會開始讀 log。loki.source.file 文件說明了這兩個元件的銜接方式。

Relabel rules 將 namespace 與 container 保留為可查詢的 labels;以 __ 開頭的 discovery metadata 不會自動成為 Loki label。完整設定另加入受控的 app 與 job labels。Grafana 的 Kubernetes log collection guide說明 DaemonSet 應只收集所在 node 的 logs。

這條路徑會收集 container 寫到 stdout 或 stderr 的內容。應用程式也能以 OTLP 傳送 structured logs,但若同一個事件同時走兩條路徑,Loki 會出現重複資料。我會先決定每種 log source 的擁有者,而不是假設多一個 collector 就一定更完整。

收集單一 host log,但不索引無上限增長的欄位

AdGuard Home 將 DNS query log 寫在 host file,而不是 Kubernetes stdout。另一個 Alloy container 只取得該檔案的唯讀權限,並將讀取位置保存在 named volume。它從既有檔案尾端開始;restart 後沿用 position,不會重新回填整份歷史資料。

這個 container 移除所有不需要的 Linux capabilities,只保留穿越 root-owned log path 所需的能力。它無法存取 Docker socket,root filesystem 也是唯讀。送入 Loki 時,query type 與 protocol 可以成為有限集合的 labels;查詢 domain 與 client IP 保留在 JSON body,避免索引產生無上限的 cardinality。

設定 Kubernetes application

Pod 裡的 localhost 指向 Pod 自己,不是執行 Compose 的 host。OTLP endpoint 必須是叢集能連線的位址:

OTEL_SERVICE_NAME=example-api
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production,service.namespace=example
OTEL_EXPORTER_OTLP_ENDPOINT=http://<observability-host>:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_LOGS_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_TRACES_EXPORTER=otlp

若使用 OTLP gRPC,將 endpoint 改為 http://<observability-host>:4317、protocol 改為 grpc,其餘設定維持相同。

若這些設定由 Vault 與 External Secrets 提供,我會更新 Vault 裡的值,不把內部 endpoint 寫死在 application code。範例在受信任的內部網路使用 HTTP;遙測資料若會經過不受信任的網路,則需要 TLS 與經過驗證的 endpoint。

限制儲存量並版本控管 dashboards

Compose stack 將 Loki logs 與 Tempo traces 保留 60 天;Prometheus metrics 保留 14 天,或直到達到設定的容量上限。只有時間限制不足以處理 time series 或 log volume 突然增加,因此 Prometheus 另有 size limit,每個 container 也都有 resource limit。

Dashboards 透過納入版本控管的 JSON 和 provider files 建立。Kubernetes dashboards 查詢叢集內的 Prometheus data source;application dashboards 則關聯 Prometheus metrics、Loki logs 與 Tempo traces。重建 Grafana 後不必重新逐一匯入,才是把 dashboards 放進 Git 的實際價值。

驗證資料路徑,而不只是 processes

合併並驗證完整的 Compose 與 backend 設定後,在 Compose 目錄啟動 stack,再確認 backend readiness:

cd opentelemetry
docker compose up -d
docker compose ps
curl http://localhost:9090/-/ready
curl http://localhost:3100/ready
curl http://localhost:3200/ready

接著分別驗證 producer 與 backend:

  • 在 Prometheus 查詢 up,並確認 application metric series 的值持續變化,而不是只有舊 series 存在。
  • 在 Loki 以 {service_name=~".+"} 找 OTLP logs,再用 namespace label 查 Alloy 收集的 Pod logs。
  • 在 Tempo 依 service.name = example-api 搜尋,並刻意製造一次 error 和一次 slow request,確認 sampling policy 有保留它們。
  • 在 Kubernetes 確認每個預期 node 都有一個 Alloy Pod,且 Alloy logs 顯示成功寫入 Loki。

如果 Loki 已 ready 卻沒有資料,先確認查詢的 labels,再檢查 producer:Pod stdout 看 Alloy discovery 和 host path,structured logs 則看 application 的 OTLP exporter。Deployment 後 metrics 消失時,先檢查 resource labels 與 Collector exporter,再改 Grafana。這樣才能找出訊號消失的確切區段,而不是把每個空白 panel 都當成 dashboard 問題。

Tempo 跨 major version 升級後若反覆重啟,應先核對新版的 config 格式,再排查 Docker networking。

Host 儲存與網路的限制

Backend 仍然是單一 host 依賴。Docker volumes 需要備份,cluster-to-host 的 OTLP 與 Loki 路徑需要防火牆規則,retention 也不能取代容量監控。內部使用明文 HTTP 是這個網路下的選擇,不是通用建議。