Kubernetes 基础体系 · 第 68/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes 分布式追踪:OpenTelemetry、Context、采样和基础设施关联

分布式追踪(Distributed Tracing)用于回答一个具体问题:

一次用户请求经过哪些服务、每个服务耗时多少、在哪一步失败,以及这次请求运行在哪些 Kubernetes 资源上?

指标通常告诉我们“错误率升高了”,日志通常告诉我们“某个请求报错了”,而追踪把一次请求在多个进程、线程、Pod 和节点之间的传播过程串成一条因果链。

在 Kubernetes 中,追踪系统通常由以下部分组成:

flowchart LR
    C[客户端] --> A[API Gateway / Ingress]
    A --> S1[订单服务]
    S1 --> S2[库存服务]
    S1 --> DB[(数据库)]
    S2 --> MQ[(消息队列)]

    A -. trace context .-> S1
    S1 -. trace context .-> S2

    A -->|OTLP| O[OpenTelemetry Collector]
    S1 -->|OTLP| O
    S2 -->|OTLP| O
    O --> B[(Trace Backend)]

    S1 --> L[日志]
    S1 --> M[指标]
    L -. trace_id / span_id .-> B
    M -. exemplars .-> B

OpenTelemetry 负责生成、传播和导出遥测数据;Context 负责在一次调用链中携带当前 Span;采样决定哪些 Trace 被保留;Kubernetes 关联负责把 Trace 与 Namespace、Pod、容器、Node、Workload 等基础设施信息连接起来。

这几个概念必须放在一起理解。只有创建 Span 而不传播 Context,Trace 会断裂;只有 Trace 而没有采样策略,生产环境可能产生无法承受的数据量;只有业务字段而没有 Kubernetes 资源属性,出现问题时又难以定位到具体 Pod。


一、先建立追踪模型:Trace、Span 和因果关系

1.1 Trace 是一次完整请求的因果图

一个 Trace 表示一次逻辑操作的完整过程,例如:

用户请求
└── API Gateway
    └── order-service
        ├── inventory-service
        └── PostgreSQL 查询

Trace 通常包含:

  • 一个 trace_id:标识整条调用链;
  • 多个 Span:表示调用链中的局部操作;
  • Span 之间的父子关系或链接关系;
  • 每个 Span 的开始时间、结束时间、状态、属性和事件。

Span 是一个有开始和结束时间的操作区间:

duration(span)=end_timestart_timeduration(span) = end\_time - start\_time

如果父 Span 包含子 Span,则可以近似表示:

child.startparent.startchild.start \ge parent.start

child.endparent.endchild.end \le parent.end

但“包含”不意味着父 Span 的耗时等于所有子 Span 耗时之和。子 Span 可能并发执行,也可能存在没有被观测的本地计算。

例如:

order-service 总耗时:120 ms
├── inventory-service:80 ms
└── payment-service:70 ms

两个下游调用并发执行时,父 Span 仍然可能只耗时约 120 ms,而不是 80 + 70 = 150 ms。

1.2 Span 的常见字段

一个 Span 通常包含:

字段 含义
trace_id 整条 Trace 的标识
span_id 当前 Span 的标识
parent_span_id 父 Span 标识
start_timeend_time 操作时间范围
name 操作名称
kind SERVERCLIENTPRODUCERCONSUMERINTERNAL
status UNSETOKERROR
attributes HTTP、RPC、数据库、消息队列等属性
events 时间点事件,例如异常
links 与其他 Trace 或 Span 的关联

Span.kind 不是装饰字段。它帮助后端判断一个 Span 在调用关系中的角色。例如:

  • HTTP 服务端收到请求:SERVER
  • HTTP 客户端调用下游:CLIENT
  • 消息生产者发送消息:PRODUCER
  • 消费者异步处理消息:CONSUMER

1.3 追踪不是调用树的简单日志拼接

追踪需要表达因果关系,而不是单纯按时间排序。

同步 HTTP 调用通常是父子关系:

server span: order-service 接收请求
└── client span: order-service 调用 inventory-service
    └── server span: inventory-service 接收请求

异步消息通常需要区分两个时间点:

Trace A
└── PRODUCER: 订单服务发送消息

Trace B
└── CONSUMER: 库存服务消费消息
    └── LINK -> Trace A 的 PRODUCER Span

消费者处理可能在很久之后发生,也可能被多个消费者并行处理。此时强行把消费者 Span 作为生产者 Span 的子 Span,往往会制造不准确的树结构;使用 Span Link 更能表达“这个处理由那条消息触发”。


二、OpenTelemetry 负责什么,Kubernetes 不负责什么

2.1 OpenTelemetry 的边界

OpenTelemetry(OTel)是开放的可观测性框架,主要提供:

  1. API:应用如何创建 Span、设置属性和传播 Context;
  2. SDK:采样、处理、批量导出和资源识别;
  3. 自动插桩:为 HTTP、数据库、消息队列等库自动创建 Span;
  4. OTLP 协议:统一向 Collector 或后端发送数据;
  5. Collector:接收、处理、批量发送和路由遥测数据。

OpenTelemetry 不等于某一个 Trace 后端。Jaeger、Tempo、Zipkin、商业 APM 后端可以作为不同的存储和查询系统。

2.2 Kubernetes 的边界

Kubernetes 负责调度和管理容器,但不会自动把任意应用调用串成 Trace。应用必须:

  • 使用 OTel SDK 或自动插桩;
  • 正确传播 Context;
  • 配置 OTLP 导出;
  • 为运行环境提供资源属性;
  • 处理采样和故障。

Kubernetes 自身的一些组件或发行版可能提供追踪能力,但这属于具体组件和版本的实现能力,不能推导为“所有 Kubernetes 请求都有 Trace”。

例如,下面这些对象本身不会自动生成业务 Span:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service

Deployment 只描述期望状态。真正需要创建 Span 的是运行在 Pod 中的应用、代理或网关。


三、Context:Span 为什么能跨进程传播

3.1 Context 的定义

OpenTelemetry 中的 Context 是与当前执行流程关联的上下文容器。它通常包含:

  • 当前 Span;
  • 当前 Trace 的传播信息;
  • Baggage;
  • 取消、超时或框架上下文中的其他状态。

需要区分两件事:

  1. 进程内 Context:在同一个进程的函数调用、线程、协程或异步任务之间传递;
  2. 跨进程传播格式:把 Context 编码到 HTTP Header、RPC Metadata 或消息属性中。

Context 不会凭空穿过网络。必须执行:

进程 A 的 Context
    -- Inject -->
HTTP Header / RPC Metadata / Message Header
    -- 网络传输 -->
进程 B
    -- Extract -->
进程 B 的 Context

3.2 W3C Trace Context

最常见的 HTTP 传播格式是 W3C Trace Context,主要使用:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

格式可抽象为:

version-trace-id-parent-id-trace-flags

其中:

  • version:传播格式版本;
  • trace-id:32 个十六进制字符,标识整条 Trace;
  • parent-id:16 个十六进制字符,标识当前上游 Span;
  • trace-flags:目前主要包含 sampled 标志。

traceparent 中的 parent-id 不是下游 Span 的 ID,而是“传播者当前 Span 的 ID”。下游提取后会创建新的 Span,并把这个 parent-id 作为新 Span 的父 Span。

还可以使用:

tracestate: vendor1=value1,vendor2=value2

tracestate 用于携带供应商或实现相关的信息。应用不应随意修改未知字段。

3.3 Baggage 与 Trace Context 不同

Baggage 是任意键值对,例如:

baggage: tenant.id=tenant-42,region=cn-east

它可以跨服务传递业务上下文,但有重要风险:

  • 会增加每次请求的 Header 大小;
  • 可能把敏感数据传播到不应访问的服务;
  • 下游服务可能信任不可信的外部值;
  • 高频高基数字段会增加日志、指标或 Trace 成本。

因此,不应把密码、Token、身份证号等敏感信息放入 Baggage,也不应把所有用户字段都放入 Span 属性。

3.4 进程内异步代码中的 Context

下面是一个 Python 示例,展示显式创建 Span、读取当前 Context,并在异步任务中保留当前上下文:

import asyncio
from opentelemetry import trace

tracer = trace.get_tracer("demo")

async def query_inventory():
    current = trace.get_current_span()
    print("inventory parent span:", current.get_span_context().span_id)
    await asyncio.sleep(0.01)

async def handle_order():
    with tracer.start_as_current_span("handle_order") as span:
        span.set_attribute("order.id", "order-1001")
        await query_inventory()

asyncio.run(handle_order())

在支持的 Python 异步模型中,OpenTelemetry 使用运行时上下文机制保存当前 Span。常见错误是把 Span 放到一个全局变量:

# 错误示例
CURRENT_SPAN = span

并发请求会覆盖这个全局变量,导致请求 A 的 Span 被请求 B 使用。正确做法是使用框架支持的 Context 机制,或者使用 OTel API 提供的当前 Span 访问方式。

不同语言的运行时行为不同:

  • Java 通常依赖线程上下文和异步框架集成;
  • Go 通常显式传递 context.Context
  • Python 依赖 contextvars 和异步框架适配;
  • Node.js 通常依赖 AsyncLocalStorage

因此,“已经创建 Span”不等于“Context 会自动在所有并发边界中正确传播”。线程池、协程、消息回调、定时任务和自定义执行器都需要验证。


四、HTTP 和消息队列中的传播过程

4.1 HTTP 请求的完整时序

一次 HTTP 调用可以拆成以下步骤:

sequenceDiagram
    participant U as Client
    participant A as order-service
    participant B as inventory-service
    participant C as Collector

    U->>A: HTTP request + traceparent
    A->>A: Extract parent Context
    A->>A: Create SERVER Span
    A->>A: Create CLIENT Span
    A->>B: HTTP request + new traceparent
    B->>B: Extract Context
    B->>B: Create SERVER Span
    B-->>A: HTTP response
    A-->>U: HTTP response
    A->>C: OTLP spans
    B->>C: OTLP spans

关键点是:A 不应把收到的 traceparent 原样转发给 B。A 应先创建自己的客户端 Span,再把该客户端 Span 作为当前 Span 注入下游。

如果 A 直接原样转发:

U -> A -> B

那么 B 可能错误地把 U 的原始 Span 当成自己的父 Span,丢失 A 作为中间调用方的节点。

4.2 手动传播的伪代码

不同语言 API 名称略有不同,但生命周期应当一致:

ctx = propagator.extract(incoming_headers)

with tracer.start_span(
    name="HTTP GET /orders/{id}",
    context=ctx,
    kind=SERVER
) as server_span:

    with tracer.start_span(
        name="HTTP GET inventory",
        context=current_context(),
        kind=CLIENT
    ) as client_span:

        outgoing_headers = {}
        propagator.inject(current_context(), outgoing_headers)
        response = http_client.get(url, headers=outgoing_headers)

错误处理也属于 Span 生命周期的一部分:

try:
    response = call_downstream()
    if response.status >= 500:
        span.set_status(ERROR)
except Exception as exc:
    span.record_exception(exc)
    span.set_status(ERROR)
    raise

通常不应把完整请求体、响应体或异常堆栈全部塞入属性。异常事件可以包含堆栈,但必须考虑敏感信息和数据量。

4.3 消息传播

消息系统没有统一的 HTTP Header,但通常提供消息属性或 Header:

message.headers["traceparent"] = inject(current_context())
producer.send(message)

消费者收到消息后:

parent_context = extract(message.headers)

with start_consumer_span(
    "consume order.created",
    context=parent_context,
    kind=CONSUMER
):
    process(message)

实际生产中还需要考虑:

  • 消息重试是否创建新的 Consumer Span;
  • 死信队列是否保留原始传播字段;
  • 同一消息多次消费是否产生多个 Span;
  • 批量消费时一个 Span 是否覆盖多个消息;
  • 消费处理是否跨越较长时间,导致父子关系不适合表达。

五、一个可运行的最小 OpenTelemetry 应用

下面用 Python Flask 展示最小的 HTTP 服务端和客户端插桩。示例假设本机有一个 OTLP gRPC 接收端监听 localhost:4317,可以是本地 Collector 或测试后端。

安装依赖:

python -m venv .venv
. .venv/bin/activate

pip install \
  flask \
  requests \
  opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-exporter-otlp-proto-grpc \
  opentelemetry-instrumentation-flask \
  opentelemetry-instrumentation-requests

创建 app.py

import os
import requests
from flask import Flask, jsonify

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor

resource = Resource.create({
    "service.name": os.getenv("OTEL_SERVICE_NAME", "order-service"),
    "service.version": "1.0.0",
    "deployment.environment": os.getenv("OTEL_ENVIRONMENT", "dev"),
})

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(
            endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"),
            insecure=True,
        )
    )
)
trace.set_tracer_provider(provider)

app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()

@app.get("/orders/<order_id>")
def get_order(order_id):
    response = requests.get(
        os.getenv("INVENTORY_URL", "http://localhost:8081/inventory/") + order_id,
        timeout=2,
    )
    return jsonify({
        "order_id": order_id,
        "inventory_status": response.status_code,
    })

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

启动:

export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
export OTEL_SERVICE_NAME=order-service
python app.py

调用:

curl -i http://localhost:8080/orders/order-1001

预期结果是 Flask 自动创建一个服务端 Span,Requests 自动创建一个客户端 Span。若 inventory-service 也正确提取 traceparent,它会创建属于同一 trace_id 的服务端 Span。

这里有几个生命周期细节:

  • TracerProvider 应在应用启动阶段初始化;
  • BatchSpanProcessor 会异步发送 Span,减少请求线程的导出开销;
  • 进程退出时应调用 SDK 的 shutdown 或 flush,避免最后一批 Span 丢失;
  • exporter 不可用时,业务请求不应无限等待;
  • OTLP endpoint、协议和 TLS 配置必须与 Collector 接收器一致。

自动插桩能覆盖常见库,但不能保证覆盖所有自定义线程池、RPC 框架、消息客户端或数据库封装。需要通过实际 Trace 验证父子关系是否正确,而不能只根据“安装成功”判断。


六、OpenTelemetry Collector:接收、处理和导出

6.1 Collector 的数据流

Collector 通常包含四类组件:

Receiver -> Processor -> Exporter
                 |
              Pipeline
  • Receiver:接收 OTLP、Jaeger、Zipkin 等协议;
  • Processor:批量、限流、补充属性、采样、过滤;
  • Exporter:发送到 Trace 后端;
  • Extension:健康检查、认证、负载均衡等辅助能力。

一个最小 OTLP Collector 配置:

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

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
  batch:
    timeout: 5s
    send_batch_size: 512

exporters:
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]

4317 是常见的 OTLP gRPC 端口,4318 是常见的 OTLP HTTP 端口。端口本身不是 Kubernetes 规范要求,应用和 Collector 必须使用相同协议和地址。

启动方式取决于发行版和安装包。例如使用 Collector 二进制时:

otelcol-contrib --config collector.yaml

不同 Collector 构建版本包含的组件可能不同。otelcolotelcol-contrib 的组件集合不完全相同,配置中的 receiver、processor 或 exporter 必须存在于实际构建中。

debug exporter 适合确认数据是否进入 Collector,不适合生产持久化。生产环境通常将其替换为后端对应的 OTLP exporter,例如:

exporters:
  otlp:
    endpoint: tempo.monitoring.svc.cluster.local:4317
    tls:
      insecure: true

是否允许明文、是否需要认证、后端是否要求特定协议,取决于具体产品和部署方式。生产环境应优先使用 TLS 和认证,而不是照搬 insecure: true

6.2 Collector 在 Kubernetes 中的部署模式

常见部署模式有两种。

DaemonSet Agent:

每个 Node 一个 Collector
Pod -> 本机 Agent -> Gateway / Backend

优点是网络路径短,适合收集本节点 Pod 的遥测;缺点是每个节点都需要资源,配置和升级数量较多。

Deployment Gateway:

多个 Pod -> Collector Gateway -> Trace Backend

优点是集中处理、统一出口和统一采样;缺点是需要处理负载均衡、故障转移和跨节点网络。

常见组合是:

应用或 Sidecar -> DaemonSet Agent -> Gateway -> Backend

如果使用尾部采样(Tail Sampling),同一条 Trace 的 Span 必须尽量被送到同一个做决策的 Collector 实例,否则一个实例只看到 Trace 的一部分,可能错误地把失败 Trace 当成成功 Trace。


七、把 Collector 部署到 Kubernetes

下面的配置用于演示 OTLP 接收和 Kubernetes 元数据关联。它不是完整生产清单,但可以说明关键权限和数据流。

7.1 ServiceAccount、RBAC 和配置

apiVersion: v1
kind: ServiceAccount
metadata:
  name: otel-collector
  namespace: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: otel-collector
rules:
  - apiGroups: [""]
    resources: ["pods", "namespaces", "nodes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["replicasets", "deployments"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: otel-collector
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: otel-collector
subjects:
  - kind: ServiceAccount
    name: otel-collector
    namespace: observability
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector
  namespace: observability
data:
  collector.yaml: |
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318

    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 512
      k8sattributes:
        auth_type: serviceAccount
        passthrough: false
        extract:
          metadata:
            - k8s.namespace.name
            - k8s.pod.name
            - k8s.pod.uid
            - k8s.node.name
            - k8s.deployment.name
            - k8s.replicaset.name
            - k8s.container.name
        pod_association:
          - sources:
              - from: resource_attribute
                name: k8s.pod.uid
          - sources:
              - from: resource_attribute
                name: k8s.pod.ip
          - sources:
              - from: connection

      batch:
        timeout: 5s
        send_batch_size: 512

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

    exporters:
      debug:
        verbosity: basic

    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [debug]

这里的关键点不是 YAML 的数量,而是以下因果关系:

  1. k8sattributes 需要访问 Kubernetes API;
  2. ServiceAccount 本身不自动拥有权限;
  3. ClusterRole 和 ClusterRoleBinding 让 Collector 能读取 Pod、Namespace、Node 等对象;
  4. Collector 根据资源属性、Pod IP 或连接信息关联 Pod;
  5. 关联成功后,Processor 把 Kubernetes 属性写入 Resource;
  6. 导出到后端后,可以按 Namespace、Pod 或 Deployment 查询。

k8sattributes 的关联成功率取决于 Collector 是否能看到正确的资源信息。某些网络拓扑、代理转发或自定义 exporter 会丢失 Pod IP 和连接信息,因此不能假定仅部署 Processor 就一定能关联成功。

7.2 Collector Deployment 和 Service

apiVersion: apps/v1
kind: Deployment
metadata:
  name: otel-collector
  namespace: observability
spec:
  replicas: 2
  selector:
    matchLabels:
      app: otel-collector
  template:
    metadata:
      labels:
        app: otel-collector
    spec:
      serviceAccountName: otel-collector
      containers:
        - name: collector
          image: otel/opentelemetry-collector-contrib:0.123.0
          args:
            - "--config=/etc/otelcol/collector.yaml"
          ports:
            - name: otlp-grpc
              containerPort: 4317
            - name: otlp-http
              containerPort: 4318
          volumeMounts:
            - name: config
              mountPath: /etc/otelcol
          readinessProbe:
            httpGet:
              path: /
              port: 13133
      volumes:
        - name: config
          configMap:
            name: otel-collector
---
apiVersion: v1
kind: Service
metadata:
  name: otel-collector
  namespace: observability
spec:
  selector:
    app: otel-collector
  ports:
    - name: otlp-grpc
      port: 4317
      targetPort: 4317
    - name: otlp-http
      port: 4318
      targetPort: 4318

上面的 Deployment 使用了示例版本标签。部署前应确认该镜像标签在目标仓库存在,并根据组织的镜像镜像审计、漏洞扫描和升级策略固定版本。不要在生产环境无审计地使用 latest

配置中的 readiness 探针端口 13133 还需要 Collector 启用 health check extension;如果没有启用,该探针不会正常工作。一个可用配置应补充:

extensions:
  health_check:
    endpoint: 0.0.0.0:13133

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes, resource, batch]
      exporters: [debug]

部署和验证:

kubectl create namespace observability
kubectl apply -f collector.yaml

kubectl -n observability get pods
kubectl -n observability get svc otel-collector
kubectl -n observability logs deploy/otel-collector

如果应用配置:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc.cluster.local:4317

则它会通过 Kubernetes Service 访问 Collector。若使用 OTLP gRPC,应用端应配置 gRPC exporter;若使用 OTLP HTTP,则通常使用 http://...:4318,具体是否需要 /v1/traces 由 SDK exporter 实现决定。


八、Resource 属性和 Kubernetes 基础设施关联

8.1 Resource 与 Span Attribute 的区别

Resource 描述“产生遥测数据的实体”,例如:

service.name=order-service
service.version=1.4.2
k8s.namespace.name=commerce
k8s.pod.name=order-service-7d8b9c6f5d-x7abc
k8s.node.name=node-03

Span Attribute 描述“某一次操作”,例如:

http.request.method=GET
http.route=/orders/{id}
server.address=inventory
db.operation.name=SELECT

判断原则是:

  • 对同一进程或同一 Pod 中大多数 Span 都相同的字段,优先放 Resource;
  • 每次请求可能不同的字段,放 Span Attribute;
  • 不能把 user.idorder.id 等高基数字段随意变成指标标签。

8.2 Kubernetes 关联的实际数据流

典型流程如下:

应用 SDK
  └── service.name、service.version、Pod 相关环境变量
      ↓
OTLP
  ↓
Collector k8sattributes processor
  ├── 根据 k8s.pod.uid 关联
  ├── 或根据 k8s.pod.ip 关联
  ├── 或根据连接地址关联
  └── 查询 Kubernetes API
      ↓
补充 Namespace、Pod、Node、Deployment 等 Resource
      ↓
Trace Backend

Kubernetes Downward API 可以把部分信息注入环境变量,例如:

env:
  - name: POD_NAME
    valueFrom:
      fieldRef:
        fieldPath: metadata.name
  - name: POD_NAMESPACE
    valueFrom:
      fieldRef:
        fieldPath: metadata.namespace

但应用自行设置:

k8s.pod.name=$(POD_NAME)

并不等于该值已经被 Kubernetes 认证。外部请求可以伪造 Header,应用也可能配置错误。生产环境通常让 Collector 通过 Kubernetes API 进行关联,并限制可访问权限。

8.3 Pod 会变化,Workload 身份更稳定

Pod 名称和 UID 在重建后会变化:

order-service-7d8b9c6f5d-x7abc
order-service-7d8b9c6f5d-k9pqr

但 Deployment 名称和 service.name 通常更适合长期聚合。查询时可以按层次使用:

service.name=order-service
k8s.namespace.name=commerce
k8s.deployment.name=order-service
k8s.pod.name=具体实例

不要只按 Pod 名称做长期告警维度,否则滚动发布会产生大量新时间序列或 Trace 查询条件失效。


九、采样:为什么不能保存所有 Trace

9.1 采样的基本定义

采样是决定一个请求的 Span 是否进入后续处理和存储的过程。

设请求到达率为 RR,每条 Trace 平均 Span 数为 SS,每个 Span 平均大小为 BB,采样率为 pp,则粗略数据量为:

D=R×S×B×pD = R \times S \times B \times p

例如:

  • 每秒 1000 条请求;
  • 每条 Trace 平均 8 个 Span;
  • 每个 Span 平均 1 KB;
  • 采样率 10%。

则:

D=1000×8×1KB×0.1=800KB/sD = 1000 \times 8 \times 1KB \times 0.1 = 800KB/s

这只是原始 Span 数据,不包含索引、压缩、网络协议和存储副本开销。

9.2 Head Sampling

Head Sampling 在 Trace 开始时就做决定,常见实现包括:

  • 固定比例采样;
  • Parent-based sampling;
  • 按服务或环境配置不同采样率。

优点:

  • 决策简单;
  • 不需要等待整条 Trace;
  • 资源占用较小;
  • 适合在应用 SDK 或入口 Collector 提前削减流量。

缺点是决定时还不知道请求最终是否失败。例如一条请求刚开始时看起来正常,后面却发生数据库超时;如果最初没有采样,这条错误 Trace 可能完全不存在。

9.3 Parent-based Sampling

Parent-based Sampling 使用上游采样决定:

上游 sampled=true  -> 下游通常继续采样
上游 sampled=false -> 下游通常不采样

这能保证同一条 Trace 大体一致。否则可能出现:

Gateway 保留了 Trace
order-service 没保留
inventory-service 又保留了一个孤立 Span

但 Parent-based Sampling 不是安全边界。外部客户端可以构造 traceparent,所以不能无条件信任外部传入的 sampled 标志。入口网关通常需要重新建立信任边界,并根据自身策略决定是否接受上游采样决策。

9.4 Tail Sampling

Tail Sampling 等整条 Trace 的一部分或全部 Span 到达后再决策:

1. 收集 Trace 的 Span
2. 按 trace_id 暂存
3. 等待 decision_wait
4. 查看错误、延迟、属性等
5. 决定保留或丢弃

它可以实现:

错误 Trace:100% 保留
延迟超过 1 秒:100% 保留
正常 Trace:随机保留 5%

概念配置如下:

processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 10000
    expected_new_traces_per_sec: 100
    policies:
      - name: keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: keep-slow
        type: latency
        latency:
          threshold_ms: 1000
      - name: sample-normal
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

这里的 decision_wait 是等待时间,不是无限等待。若某些 Span 到达过晚,Collector 可能已经作出决定,导致 Trace 不完整。

Tail Sampling 的容量至少受到以下因素影响:

memoryN×S×Bmemory \approx N \times S \times B

其中:

  • NN:同时处于等待状态的 Trace 数;
  • SS:每条 Trace 的 Span 数;
  • BB:每个 Span 占用的内存;
  • 还应加上 Collector 内部索引、队列和运行时开销。

在多个 Collector 副本之间,必须考虑 Trace 路由一致性。普通 Kubernetes Service 的随机负载均衡不能天然保证同一个 trace_id 总是到达同一个 Tail Sampling 实例。

9.5 采样不是只保留“成功”和“失败”

如果只按错误采样,可能丢失:

  • 具有特定租户或区域的异常;
  • 只在某个 Pod 上发生的延迟;
  • 业务成功但性能严重下降的请求;
  • 采样决策自身造成的空洞。

合理策略通常需要组合:

错误 Trace 100%
慢 Trace 100%
关键业务操作较高比例
正常流量低比例
开发环境按需提高

但“错误 100% 保留”也不代表绝对不会丢失。Collector 崩溃、网络断开、后端限流、内存保护和应用进程退出,都可能导致数据丢失。


十、采样与 Context 的关系

采样决定不仅影响后端是否收到 Span,还会影响传播标志。

一个常见流程是:

入口 Span 创建
  ↓
Sampler 作出记录决策
  ↓
trace_flags.sampled 设置
  ↓
向下游注入 traceparent
  ↓
下游依据 Parent-based 策略继续或停止记录

因此,采样决策应在调用下游之前确定。如果应用在创建下游客户端 Span 后才改变采样状态,下游可能已经收到不一致的传播信息。

还要区分:

  • recording:SDK 是否记录 Span 内容;
  • sampled:是否建议将 Trace 发送到后端;
  • exported:是否实际成功发送到后端。

一个 Span 可能被创建但没有导出,也可能因为导出失败而丢失。不能仅凭应用日志中出现“Span created”判断后端一定能查询到 Trace。


十一、Trace、日志、指标和 SLO 如何关联

11.1 日志关联

日志中通常加入:

trace_id=4bf92f3577b34da6a3ce929d0e0e4736
span_id=00f067aa0ba902b7

这样可以从一条错误日志跳转到 Trace,也可以从 Trace 中定位同一时间段的日志。

但日志中的 trace_id 必须来自当前 Context,而不是应用启动时生成的固定值。以下做法是错误的:

应用启动时生成一个 trace_id,所有日志都使用它

这种日志看似具有关联字段,实际会把所有请求错误地归为同一条 Trace。

Kubernetes 容器日志通常由容器运行时写入标准输出,再由 Fluent Bit、Vector、Filebeat 或其他 Agent 收集。日志 Agent 不会自动知道某条日志属于哪个 Trace;应用需要将字段写入日志,或者使用能够读取 OTel Context 的日志集成。

11.2 指标关联和 Exemplars

指标适合聚合,例如:

http_server_request_duration_seconds
http_server_requests_total

Trace 适合分析单次请求。Prometheus 指标不应把 trace_id 作为普通 Label,因为 Trace ID 几乎每次都不同,会制造极高基数。

Exemplar 是指标样本与某条 Trace 的关联,例如:

latency histogram bucket
  exemplar:
    trace_id=4bf92f3577b34da6a3ce929d0e0e4736

这样可以从延迟直方图中的异常点跳转到具体 Trace。Exemplar 需要:

  • SDK 或指标库支持;
  • 指标导出链路保留 exemplar;
  • Prometheus 及查询界面支持;
  • Trace 后端能够按 Trace ID 查询。

因此,“用了 Prometheus 就能从指标跳 Trace”并不是 Kubernetes 或 Prometheus 单独保证的能力。

11.3 Metrics、Trace 和 SLO 的分工

以延迟 SLO 为例:

99% 的订单请求延迟小于 500 ms

Prometheus 适合计算:

满足延迟目标的请求比例
错误预算消耗速度
按服务、区域、版本聚合的延迟

Trace 适合分析一次不满足目标的请求:

Gateway:20 ms
order-service:35 ms
inventory-service:410 ms
数据库查询:380 ms

SLO 不应直接依赖所有 Trace。Trace 可能被采样,不能代替完整的请求计数和延迟分布指标。


十二、与 Service Mesh 的关系:Sidecar、mTLS 和遥测

Service Mesh 通常通过 Sidecar、节点代理或环境内代理处理:

  • 服务间流量转发;
  • mTLS;
  • 重试、超时和流量治理;
  • 部分网络指标和访问日志;
  • 可选的代理级 Span。

12.1 mTLS 不等于 Trace 传播

mTLS 解决的是:

通信双方身份认证 + 传输加密

Trace Context 解决的是:

请求因果关系传播

连接使用 mTLS,并不意味着代理会自动转发 traceparent。反过来,HTTP Header 中有 traceparent 也不代表连接经过加密。

12.2 Sidecar 可能产生重复 Span

如果应用和 Sidecar 都生成 HTTP Span,一次调用可能出现:

应用 SERVER Span
应用 CLIENT Span
Sidecar 出站 Span
Sidecar 入站 Span
下游 Sidecar 出站 Span
下游应用 SERVER Span

这不是必然错误,但必须理解每个 Span 的边界。否则一次 10 ms 的调用可能被误解为多个串行调用。

应明确:

  • 应用负责业务语义和数据库、缓存、内部函数等 Span;
  • 代理负责网络层、路由、重试和连接级遥测;
  • 哪一方负责传播 Header;
  • 是否需要在后端隐藏或折叠某些代理 Span;
  • 重试是否被显示为多个尝试。

12.3 重试会改变因果图

假设客户端第一次调用超时,Sidecar 自动重试一次:

业务请求
├── 第一次网络尝试:失败
└── 第二次网络尝试:成功

如果只看业务服务端 Span,可能只看到成功的一次;如果只看客户端 Span,可能看到两次尝试。诊断网络问题时需要代理遥测,但计算业务请求耗时时,应避免把重试次数误当成业务请求数。

12.4 Service Mesh 与 Collector 的连接方式

常见连接方式包括:

应用 SDK -> Collector
Sidecar -> Collector
Sidecar 产生的 Span -> Collector

如果应用和 Sidecar 都向 Collector 发送 Trace,Collector 可以统一批处理和导出;但采样策略必须协同,否则可能出现:

  • 应用采样后没有业务 Span,Sidecar 仍产生孤立 Span;
  • Sidecar 在入口丢弃了请求,应用无法保留错误 Trace;
  • 多级代理重复注入或覆盖传播 Header。

对于跨信任边界的流量,入口代理应验证和清理外部传播字段,而不是无条件相信客户端传入的 traceparent 和 Baggage。


十三、Kubernetes 中最容易被忽略的后台任务

分布式追踪常被设计成“HTTP 请求进来、HTTP 请求出去”,但 Kubernetes 应用还有大量非请求触发的工作:

  • CronJob 定时任务;
  • Deployment 启动和优雅退出;
  • 消费消息;
  • 控制器 Reconcile;
  • 异步任务队列;
  • Leader Election 后执行的任务;
  • 预热缓存和数据库迁移。

这些任务没有自然的上游 HTTP Context。可以创建新的根 Trace:

CronJob execution
└── reconcile inventory
    ├── database query
    └── publish message

对于 Kubernetes Controller,还要避免把一次长期运行的 Reconcile 生命周期错误地绑定到某个已经结束的请求 Context。Context 的取消和超时应符合控制器的实际工作边界。

Pod 终止时,SDK 和 Collector 需要有时间发送缓冲数据。Kubernetes 的 terminationGracePeriodSeconds 只是给进程退出的宽限时间;应用必须在收到终止信号后停止接收新请求、flush exporter,并在超时后退出。


十四、故障路径:Trace 丢失时如何定位

14.1 应用没有 Trace

检查顺序:

kubectl -n commerce exec deploy/order-service -- printenv \
  | grep '^OTEL_'

确认:

  • OTEL_SERVICE_NAME 是否存在;
  • OTLP endpoint 是否指向正确的 Service;
  • gRPC 和 HTTP 协议是否匹配;
  • TLS 配置是否匹配;
  • 自动插桩是否真的加载;
  • 应用是否创建了 TracerProvider
  • exporter 是否在进程退出前 flush。

然后检查 DNS 和端口:

kubectl -n commerce exec deploy/order-service -- \
  getent hosts otel-collector.observability.svc.cluster.local

若容器内没有 getent,可使用临时诊断 Pod。不要因为业务容器缺少诊断工具就修改生产镜像。

14.2 Collector 收到数据但后端没有

检查:

kubectl -n observability logs deploy/otel-collector
kubectl -n observability describe pod -l app=otel-collector

重点观察:

  • exporter 连接错误;
  • TLS 或认证错误;
  • 后端限流;
  • memory_limiter 丢弃;
  • exporter queue 满;
  • Collector 重启;
  • 配置加载失败。

可以暂时使用 debug exporter 验证 Collector 是否收到 Trace。如果 Debug exporter 有输出,而正式 exporter 没有,问题在后端连接、认证、协议或导出队列。

14.3 Trace 断裂

Trace 断裂通常不是 Kubernetes Service 的问题,而是传播链问题:

A 有 trace_id
B 没有 trace_id

检查:

  1. A 是否注入 traceparent
  2. Service Mesh、Ingress 或 API Gateway 是否删除或覆盖 Header;
  3. B 是否提取正确的 Header;
  4. B 是否创建了新的根 Span;
  5. HTTP 客户端是否被自动插桩;
  6. 异步任务是否在 Context 之外执行;
  7. 消息队列是否保留消息属性。

可以在测试环境记录非敏感的传播字段进行比对:

A outgoing traceparent
B incoming traceparent
B created span trace_id

不要在生产日志中无控制地打印完整 Baggage 或请求 Header。

14.4 Kubernetes 属性缺失

如果 Trace 有 service.name,但没有 k8s.pod.name,检查:

  • Collector 是否启用了 k8sattributes
  • ServiceAccount 是否有读取权限;
  • ClusterRole 是否包含需要的资源;
  • 应用是否发送了可关联的资源属性;
  • Collector 是否在正确的网络和集群中;
  • Pod IP 是否因代理或网络转换而不可见。

查看权限:

kubectl auth can-i \
  --as=system:serviceaccount:observability:otel-collector \
  get pods --all-namespaces

kubectl auth can-i \
  --as=system:serviceaccount:observability:otel-collector \
  list nodes

输出 yes 只能证明 RBAC 允许访问,不代表属性关联一定成功;还要在最终 Trace 中验证属性是否出现。


十五、常见误解与反例

15.1 “有 trace_id 就代表 Trace 完整”

反例:

Gateway: trace_id=T, span_id=A
order-service: trace_id=T, span_id=B
inventory-service: trace_id=T, span_id=C

虽然三者的 trace_id 相同,但如果父子关系错误、Span 时间不合理或中间节点缺失,依然不能还原真实调用链。

15.2 “采样率 10% 就一定保留 10% 的错误”

错误。随机 Head Sampling 下,错误 Trace 也只有约 10% 被保留。若需要保留错误 Trace,应使用入口分类、SDK 策略或 Tail Sampling。

15.3 “把 trace_id 放进 Prometheus Label 就能关联”

这会导致近似每个请求产生一个新的 Label 值:

http_requests_total{trace_id="..."}

时间序列数量会迅速膨胀。应使用 Exemplars 或从日志、Trace 后端跳转。

15.4 “Service Mesh 已经生成 Trace,应用不需要插桩”

代理能观察网络调用,但通常不知道:

  • 业务订单号;
  • 数据库查询;
  • 业务阶段;
  • 下游调用的业务语义;
  • 应用内部的异步工作。

代理遥测和应用遥测是互补关系,不是简单替代关系。

15.5 “Collector 是可靠队列”

Collector 通常是内存中的处理和转发组件。进程崩溃、节点故障、网络中断或队列耗尽都可能丢失数据。磁盘队列、持久化缓冲、后端重试和多副本部署能降低风险,但也会增加磁盘、延迟和运维复杂度。

15.6 “所有 Kubernetes 属性都应该放到 Span 上”

k8s.pod.name 写到每个 Span 上并不一定错误,但会重复数据。Resource 属性更适合描述遥测来源;Span 属性用于描述具体操作。后端如何索引和展示 Resource 也存在实现差异,因此要用目标后端验证查询语法。


十六、生产取舍:可靠性、成本和隐私

16.1 不要让追踪阻塞业务请求

导出失败时,应用不应因为 Trace 后端不可用而无限等待。常见控制手段包括:

  • BatchSpanProcessor;
  • exporter 超时;
  • 有界队列;
  • 内存限制;
  • 丢弃低优先级 Trace;
  • Collector 与业务进程解耦;
  • 后端限流和重试。

观测系统的故障应尽量降级为“看不到数据”,而不是变成“业务请求全部失败”。

16.2 属性设计必须控制基数

适合作为 Span Attribute 的字段:

http.route=/orders/{id}
rpc.method=GetInventory
db.system=postgresql
k8s.namespace.name=commerce

需要谨慎的字段:

user.id
order.id
session.id
raw.url
exception.stacktrace

高基数属性可以帮助单次诊断,但可能提高索引和存储成本;同一个字段是否可搜索、是否参与聚合,取决于后端实现。

16.3 脱敏和信任边界

Trace、日志和 Baggage 都可能携带敏感信息。应明确:

  • 哪些 Header 允许传播;
  • 哪些外部 Header 必须清理;
  • 哪些属性需要哈希或截断;
  • 是否记录 SQL 参数;
  • 是否记录请求体;
  • 后端访问权限和保留期限。

尤其不能把认证 Token、Cookie、密码或完整 Authorization Header 当作普通 Span 属性。


十七、一个可验证的端到端检查标准

部署后,至少应验证以下链路,而不是只检查 Pod 是否为 Running:

kubectl -n observability get pods
kubectl -n observability logs deploy/otel-collector
kubectl -n commerce get pods -o wide

然后发起一次请求:

curl -v http://order-service.example/orders/order-1001

验证结果:

  1. 入口服务生成一个 SERVER Span;
  2. 下游调用生成一个 CLIENT Span;
  3. 下游服务生成一个 SERVER Span;
  4. 这些 Span 具有相同的 trace_id
  5. 父子关系符合真实调用方向;
  6. Collector 能接收 OTLP;
  7. Trace 后端能查询到该 Trace;
  8. Trace 包含 service.name
  9. Trace 能关联到 Namespace、Deployment 或 Pod;
  10. 错误请求的 Span 状态和异常事件符合预期;
  11. 应用关闭或 Pod 终止时,最后一批 Span 不会大量丢失;
  12. 指标、日志和 Trace 的跳转关系经过实际验证。

可以构造一个确定失败的测试请求,例如让下游服务返回 HTTP 500,然后检查:

order-service CLIENT Span: status=ERROR 或记录错误事件
inventory-service SERVER Span: status=ERROR
Trace Backend: 能按 trace_id 查询完整链路
日志: 包含相同 trace_id

需要注意,HTTP 500 是否自动设置 Span 状态为 ERROR,取决于具体插桩库和版本;不能仅凭 HTTP 状态码假设所有 SDK 行为完全一致,应在目标语言和版本中验证。


结语:把追踪看成一条有信任边界的因果数据流

Kubernetes 中的分布式追踪不是“安装一个 Collector”这么简单,它至少包含四条必须闭合的链路:

Span 创建
  -> Context 在进程内正确传播
  -> traceparent 跨进程正确传播
  -> Collector 接收、采样和导出
  -> Trace 与日志、指标、Kubernetes 资源关联

其中:

  • OpenTelemetry 定义和实现遥测 API、SDK、插桩与 OTLP;
  • Context 决定当前 Span 能否跨函数、线程、协程和进程传播;
  • 采样决定可观测性成本与异常保留能力之间的取舍;
  • Kubernetes 关联把抽象的服务调用映射到具体的 Namespace、Workload、Pod 和 Node;
  • Service Mesh 可以补充网络层遥测,但 mTLS、流量治理和 Trace Context 传播解决的是不同问题。

真正可靠的追踪系统,不是拥有最多 Span,而是在发生故障时,能够以可接受的成本恢复正确的因果关系,并进一步回答:哪次请求、经过哪个服务、在哪个 Pod、由哪个版本、在什么基础设施条件下失败。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。