2026-04-22 · architecture / observability
可观测性工程
从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。

























可观测性(Observability)工程在过去五年里经历了一次范式迁移:从「每个后端一套 SDK」到「一套信号采集标准 + 多个后端」。这场迁移的主角是云原生计算基金会(CNCF)孵化的 OpenTelemetry(简称 OTel)。它不仅统一了链路追踪(Tracing)、指标(Metrics)、日志(Logs)三类信号的数据模型、协议和语义,还通过 Collector 将「采集层」从应用代码里彻底解耦出来。
本文面向已经落地过 Prometheus、ELK 或 Jaeger 的 SRE 与平台工程师,讲清楚 OTel 的来龙去脉、协议细节、SDK 生命周期、Collector 流水线、语义约定(Semantic Conventions)以及在国内厂商(阿里云 ARMS、观测云、夜莺)上落地时会踩的坑。文章假设读者理解本系列前面几篇关于指标、日志、链路的文章,因此在信号本身的概念上不做过多铺陈,而是聚焦在「把 OTel 塞进一个已有的、跑在生产上的可观测性体系里」这件事上。
2016 年 10 月,OpenTracing 项目正式进入 CNCF
孵化。它的目标非常克制:只定义链路追踪的
API,不定义数据协议,也不提供 SDK
实现。换句话说,OpenTracing 是一组接口(Interface),具体的
Tracer、Span、SpanContext、Propagator
都交由各个厂商实现。
OpenTracing 的优点是「中立」。从 Jaeger、Zipkin 到 New Relic、Datadog,每家厂商都可以提供符合 OpenTracing 接口的适配器,应用代码只依赖 API。但它的问题也正出在「只做 API」上:
Span 上的
tags、logs(后来叫
events)字段语义由各实现自行解释。Zipkin B3、Jaeger Uber-Trace-Id、W3C traceparent
等多套头字段。几乎在 OpenTracing 成形的同时,Google 与 Microsoft
联合推出了 OpenCensus。OpenCensus 的出发点是「一套 SDK
同时处理 Traces 与 Metrics」。它不止定义
API,还给出了参考实现、导出器(Exporter)以及一个小型的代理
ocagent。
OpenCensus 的设计相对完整,尤其是指标部分,直接继承了 Google 内部 Census 项目多年的打磨成果。但它也有局限:
到 2018 年底,企业里经常同时出现三种埋点:
每个信号一套
SDK,导致应用代码里充斥着互不相通的抽象;换后端要改代码,「供应商锁定」(Vendor
Lock-in)事实上并没有被解除,只是从「接 Datadog」变成了「接
Jaeger + Prometheus + ELK」。更严重的是,同一次请求的
trace_id
很难在指标和日志里关联上,因为没有跨信号的统一
Resource 概念。
2019 年 5 月,OpenTracing 与 OpenCensus 两个项目的 TOC(Technical Oversight Committee)共同宣布合并为 OpenTelemetry,定位是「统一的可观测性数据采集标准」。关键时间线如下:
合并带来的直接收益是:API、SDK、协议、语义约定四者统一在同一个项目下演进,信号之间通过共享的
Resource 与 Context 天然关联。
OTel 的架构可以抽象为五层:
Tracer、Meter、Logger
接口,不含任何实现。应用代码、第三方库只依赖 API。应用只感知 API 层与 Instrumentation Library;SDK、Exporter、Collector 都可以替换或横向扩展,不影响业务代码。
OTLP 是 OpenTelemetry Protocol 的缩写,是 OTel 项目唯一「官方推荐且长期维护」的协议。它有两种传输方式:
OTLP/gRPC:基于 HTTP/2 的 gRPC 流,默认端口
4317。OTLP/HTTP:基于 HTTP/1.1 的
POST,请求体可以是
application/x-protobuf 或
application/json,默认端口
4318。两者共用同一套 protobuf schema,只是传输层不同。生产环境里建议默认用 gRPC,只有在以下场景退化到 HTTP:
SDK
的内部生命周期可以概括为:Provider → Processor → Exporter。
TracerProvider /
MeterProvider /
LoggerProvider):全局单例,管理
Resource、Sampler、Processor 列表。SpanProcessor /
MetricReader /
LogRecordProcessor):决定数据在什么时机、以什么方式交给
Exporter。整个流水线是同步构造、异步执行的:应用线程只做
Tracer.Start() / Span.End() /
Counter.Add(),这些调用只往内存缓冲区写数据;真正的网络
IO 发生在后台批处理线程里。这是 OTel
对性能的基本承诺——埋点不阻塞业务。
OTLP 的 protobuf 定义存放在
opentelemetry-proto 仓库中,核心是三个
Service:
service TraceService {
rpc Export(ExportTraceServiceRequest) returns (ExportTraceServiceResponse);
}
service MetricsService {
rpc Export(ExportMetricsServiceRequest) returns (ExportMetricsServiceResponse);
}
service LogsService {
rpc Export(ExportLogsServiceRequest) returns (ExportLogsServiceResponse);
}每个请求都是「资源—作用域—数据」的三级结构。以 Traces 为例:
message ResourceSpans {
Resource resource = 1;
repeated ScopeSpans scope_spans = 2;
string schema_url = 3;
}
message ScopeSpans {
InstrumentationScope scope = 1;
repeated Span spans = 2;
string schema_url = 3;
}
message Span {
bytes trace_id = 1; // 16 字节
bytes span_id = 2; // 8 字节
bytes parent_span_id = 4;
string name = 5;
SpanKind kind = 6;
fixed64 start_time_unix_nano = 7;
fixed64 end_time_unix_nano = 8;
repeated KeyValue attributes = 9;
repeated Event events = 11;
repeated Link links = 13;
Status status = 15;
}三个关键点:
Resource 是进程级别的共享属性(例如
service.name、host.name、k8s.pod.uid),在同一个
batch 里只序列化一次,大幅节省带宽。InstrumentationScope 描述「谁埋的点」(比如
opentelemetry-instrumentation-http/0.45b0),用于区分不同埋点库的数据。trace_id 与 span_id
是二进制而不是字符串,节省空间也方便后端直接按字节建索引。Metrics 的 schema 更复杂,因为要表达
Sum、Gauge、Histogram、ExponentialHistogram、Summary
五种数据点,并区分 Cumulative 与
Delta 两种时间性。Logs 的 schema
最简单,基本就是
timestamp + severity + body + attributes。
| 维度 | OTLP/gRPC | OTLP/HTTP+protobuf |
|---|---|---|
| 端口 | 4317 | 4318 |
| 帧 | HTTP/2 流式 | HTTP/1.1 或 HTTP/2 单次 POST |
| 复用连接 | 天然多路复用 | 需靠 keep-alive |
| 压缩 | gzip、zstd、snappy | gzip |
| Web 支持 | 基本不支持 | 原生支持 |
| 流量观测性 | 需要 gRPC 感知的网关 | 标准 HTTP 可观测 |
| 常见吞吐 | 更高,心跳低 | 取决于连接复用 |
| 错误码 | gRPC Status | HTTP 状态码 + protobuf error |
一个常见的生产选择是:节点内 Agent → Gateway 用 gRPC;业务从应用到 Collector 也用 gRPC;只在 Web 前端与第三方集成场景走 HTTP。
OTLP 客户端普遍暴露以下参数,以下以 Go SDK 为例:
exp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector.observability:4317"),
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithCompressor("gzip"),
otlptracegrpc.WithTimeout(10*time.Second),
otlptracegrpc.WithRetry(otlptracegrpc.RetryConfig{
Enabled: true,
InitialInterval: 1 * time.Second,
MaxInterval: 30 * time.Second,
MaxElapsedTime: 5 * time.Minute,
}),
otlptracegrpc.WithHeaders(map[string]string{
"x-tenant-id": "payments",
}),
)几个经验值:
gzip,在 Go/Java/Python SDK
上都有稳定实现;大量低基数字符串属性的场景下,压缩率可以达到
5 倍以上。MaxElapsedTime 不能太长,否则一次
Collector 抖动会导致 SDK 内存里堆积大量 span,触发
OOM。生产上常见 2~5 分钟。BatchSpanProcessor)和
Collector 侧(batch processor)都存在,通常 SDK
侧 batch 间隔小一些(1 秒),Collector 侧大一些(5~10
秒)。OTLP 定义了「部分成功」(Partial Success)机制:服务端在
ExportTraceServiceResponse 里返回
partial_success.rejected_spans 计数和
error_message。客户端遇到
4xx(InvalidArgument、PermissionDenied)通常不应重试,5xx(Unavailable、DeadlineExceeded、ResourceExhausted)应该按指数回退重试。这一点在
Collector 的 exporter/retry_on_failure
配置里也有对应开关。
语义约定是 OTel
项目里最「不性感」但最关键的产出之一。它定义了「什么属性应该叫什么名字、值应该是什么类型」。没有它,http.method、http.verb、httpMethod
三种写法会同时存在,后端无法做统一查询和面板。
语义约定的工程意义有三层:
opentelemetry-instrumentation-*
库都按约定发出属性,用户无需再改代码。从 v1.21(2023 年起)开始,语义约定独立为
opentelemetry-semantic-conventions
仓库,采用自己的版本号,并通过 schema_url
字段在数据里显式声明版本,方便后端做字段迁移。
资源属性描述「谁在产生数据」,常见字段:
| 属性 | 含义 | 示例 |
|---|---|---|
service.name |
服务名,必填 | order-service |
service.namespace |
服务所在逻辑域 | payments |
service.version |
发布版本 | 2026.04.22-a1b2c3d |
service.instance.id |
实例唯一 ID | pod-uid 或 hostname-pid |
deployment.environment |
环境 | prod、staging、canary |
host.name |
主机名 | node-01 |
host.arch |
CPU 架构 | amd64、arm64 |
os.type |
操作系统类型 | linux |
process.pid |
进程 PID | 12345 |
telemetry.sdk.name |
SDK 名称 | opentelemetry |
telemetry.sdk.language |
SDK 语言 | go、java、python |
telemetry.sdk.version |
SDK 版本 | 1.27.0 |
其中 service.name
是唯一一个规范标注为「必填」的字段——缺失时 Collector
会把它填为
unknown_service:<process.executable.name>,这是线上最常见的「数据到了但没人认领」的原因。
HTTP 属性在 v1.20(2023-03)做过一次重大重命名,从
http.method 改为
http.request.method,从 http.url
拆成 url.scheme / url.full /
url.path。v1.23 起旧字段标记为
deprecated,但 SDK 为了兼容仍会双写。升级
Collector 时要留意这一点。
新版字段(稳定版):
| 属性 | 示例 |
|---|---|
http.request.method |
GET、POST |
http.response.status_code |
200、500 |
http.route |
/api/v1/orders/:id |
url.scheme |
https |
url.full |
https://api.example.com/v1/orders/42?debug=1 |
url.path |
/v1/orders/42 |
url.query |
debug=1 |
server.address |
api.example.com |
server.port |
443 |
user_agent.original |
Go-http-client/1.1 |
把 http.route 作为指标维度是高基数(High
Cardinality)控制的经典做法——它是模板而非具体
path,/api/v1/orders/:id 永远只有一个取值。
db.system = "postgresql" | "mysql" | "redis" | "mongodb" | "clickhouse"
db.connection_string
db.user
db.name = 业务库名
db.statement = 完整 SQL(注意脱敏)
db.operation = "SELECT" | "INSERT" | "UPDATE" | "DELETE"
db.sql.table = 主表名
db.statement 是敏感字段——上线前务必配合
Collector 的 transform processor 或 SDK 的
SpanProcessor 做参数化(例如把
WHERE id = 12345 改成
WHERE id = ?),否则慢查询报告会把密码、手机号之类的敏感数据带进
trace 后端。
messaging.system = "kafka" | "rabbitmq" | "rocketmq" | "pulsar"
messaging.destination.name
messaging.operation = "publish" | "receive" | "process"
messaging.message.id
messaging.kafka.consumer.group
messaging.kafka.partition
messaging.batch.message_count
消息队列埋点里最容易漏的是
messaging.operation——没有它,就无法区分一个
span
是「发送」还是「消费」,面板上会把两类耗时搅在一起。
K8s 资源属性一般由 Collector 的
k8sattributes processor 自动注入,不需要 SDK
关心:
k8s.cluster.name
k8s.namespace.name
k8s.pod.name
k8s.pod.uid
k8s.pod.start_time
k8s.deployment.name
k8s.statefulset.name
k8s.node.name
k8s.container.name
container.image.name
container.image.tag
注入的方式是 Collector 监听 Kubernetes API,根据 IP 或
pod.uid 把 pod 元数据补上。一个实用技巧:让
Collector 以 DaemonSet 形式运行,k8sattributes
只 watch 本节点的 pod,减轻 API Server 压力。
假设一家公司有 300
个微服务,每个服务需要做三件事:「按环境过滤」「按版本做灰度(Canary)对比」「按
pod
定位问题」。没有语义约定时,每个业务团队各自选字段名,导致平台
Grafana
面板必须为每个服务单独写查询;有语义约定后,所有面板只需要
service.name、deployment.environment、service.version、k8s.pod.name
这几个通用变量,面板复用率极高。这是 OTel
项目在大规模组织里能跑通的核心原因之一。
SDK 里分别有
TracerProvider、MeterProvider、LoggerProvider
三个 Provider,它们是「工厂」而不是「数据源」。Provider
的生命周期等于进程生命周期:启动时构造,关闭前调用
Shutdown()。
一个典型的 Go 初始化流程:
package otelsetup
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
sdklog "go.opentelemetry.io/otel/sdk/log"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func Init(ctx context.Context, serviceName, version, env string) (func(context.Context) error, error) {
res, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
resource.WithHost(),
resource.WithProcessPID(),
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion(version),
semconv.DeploymentEnvironment(env),
),
)
if err != nil {
return nil, fmt.Errorf("resource: %w", err)
}
traceExp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithCompressor("gzip"),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
sdktrace.WithBatcher(traceExp,
sdktrace.WithBatchTimeout(2*time.Second),
sdktrace.WithMaxExportBatchSize(512),
sdktrace.WithMaxQueueSize(4096),
),
)
otel.SetTracerProvider(tp)
metricExp, err := otlpmetricgrpc.New(ctx,
otlpmetricgrpc.WithEndpoint("otel-collector:4317"),
otlpmetricgrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
mp := metric.NewMeterProvider(
metric.WithResource(res),
metric.WithReader(metric.NewPeriodicReader(metricExp,
metric.WithInterval(15*time.Second))),
)
otel.SetMeterProvider(mp)
logExp, err := otlploggrpc.New(ctx,
otlploggrpc.WithEndpoint("otel-collector:4317"),
otlploggrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
lp := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)),
)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))
return func(ctx context.Context) error {
_ = tp.Shutdown(ctx)
_ = mp.Shutdown(ctx)
_ = lp.Shutdown(ctx)
return nil
}, nil
}几个容易被忽略的点:
resource.WithFromEnv() 会读取
OTEL_RESOURCE_ATTRIBUTES 环境变量,把
k=v,k2=v2 格式的键值对合并到资源里。这是 K8s
下由 Downward API 注入 pod 信息最常用的通道。Shutdown 要给足超时(5~10
秒)。生产里别直接 ctx.Done()
就走,否则批量里的数据全部扔掉。SimpleSpanProcessor: 每结束一个 Span 立刻调用 Exporter
BatchSpanProcessor: 攒 Queue,定时或定量批量导出
SimpleSpanProcessor
只适合测试和开发;生产永远用 Batch,否则每条 span 一次
gRPC,延迟敏感服务会被埋点压垮。otel.sdk.span.processor.queue_size、otel.sdk.span.processor.dropped_spans。这两个指标务必接入告警。Exporter 只有两个方法:Export(batch) 与
Shutdown()。这个极简的设计让替换非常容易。生产里常见的
Exporter:
otlp(首选,gRPC 或 HTTP)。stdout(调试用,别上生产)。prometheus(让应用直接暴露
/metrics,被 Prometheus Pull)。zipkin、jaeger(过渡期用)。Propagator 负责把 SpanContext
注入和抽取到/从外部载体(Carrier)。OTel 默认注册的
Propagator 是 W3C Trace Context(traceparent /
tracestate)加 W3C
Baggage。国内生产环境里会遇到的历史格式:
X-B3-TraceId、X-B3-SpanId、X-B3-Sampled,Zipkin
使用。b3: {traceId}-{spanId}-{sampled}-{parentId}。uber-trace-id: {traceId}:{spanId}:{parentId}:{flags}。ARMS:历史上存在私有头,现已兼容
W3C。迁移策略:在 Propagator 里同时注册
traceparent 和 b3
两套,发送侧同时写、接收侧按优先级读,这样新旧服务可以灰度共存。
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
b3.New(b3.WithInjectEncoding(b3.B3MultipleHeader)),
))Java 通常用 BOM(Bill of Materials)拉依赖,推荐
opentelemetry-bom 与
opentelemetry-instrumentation-bom-alpha
搭配:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>1.42.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>初始化:
Resource resource = Resource.getDefault().merge(
Resource.create(Attributes.of(
ResourceAttributes.SERVICE_NAME, "order-service",
ResourceAttributes.SERVICE_VERSION, "2026.04.22",
ResourceAttributes.DEPLOYMENT_ENVIRONMENT, "prod"
)));
SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(
OtlpGrpcSpanExporter.builder()
.setEndpoint("http://otel-collector:4317")
.setCompression("gzip")
.build())
.setMaxQueueSize(4096)
.setScheduleDelay(Duration.ofSeconds(2))
.build())
.setResource(resource)
.setSampler(Sampler.parentBased(Sampler.traceIdRatioBased(0.1)))
.build();
OpenTelemetrySdk sdk = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.setMeterProvider(meterProvider)
.setLoggerProvider(loggerProvider)
.setPropagators(ContextPropagators.create(
TextMapPropagator.composite(
W3CTraceContextPropagator.getInstance(),
W3CBaggagePropagator.getInstance())))
.buildAndRegisterGlobal();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
sdk.getSdkTracerProvider().shutdown().join(10, TimeUnit.SECONDS);
sdk.getSdkMeterProvider().shutdown().join(10, TimeUnit.SECONDS);
sdk.getSdkLoggerProvider().shutdown().join(10, TimeUnit.SECONDS);
}));Python 的 API/SDK 包分得比较细,需要各自安装:
pip install opentelemetry-api==1.27.0 \
opentelemetry-sdk==1.27.0 \
opentelemetry-exporter-otlp==1.27.0 \
opentelemetry-instrumentation==0.48b0初始化:
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator
resource = Resource.create({
"service.name": "recommend-service",
"service.version": "2026.04.22",
"deployment.environment": "prod",
})
tp = TracerProvider(resource=resource)
tp.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(
endpoint="otel-collector:4317", insecure=True)))
trace.set_tracer_provider(tp)
mp = MeterProvider(resource=resource, metric_readers=[
PeriodicExportingMetricReader(OTLPMetricExporter(
endpoint="otel-collector:4317", insecure=True),
export_interval_millis=15000)])
metrics.set_meter_provider(mp)
set_global_textmap(CompositePropagator([
TraceContextTextMapPropagator(), W3CBaggagePropagator()
]))OTel 的上下文传播走的是 W3C traceparent
头:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
└┬┘ └──────────────┬──────────────┘ └──────┬──────┘ └┬┘
│ │ │ │
version trace-id span-id flags
flags 最低位是
sampled:01
表示上游决定采样,00
表示上游决定不采样。下游通常遵循「父亲说啥就啥」的
ParentBased 采样策略,避免出现「一半 span
有数据、一半没有」的断裂链路。
对于 Kafka、RocketMQ
等消息中间件,traceparent 可以注入到消息
headers;RabbitMQ 可以放在
properties.headers;HTTP 自然走请求头;gRPC
通过 metadata 传递。跨异步调用时一定要手动调
propagator.inject(ctx, carrier),否则链路会断。
Collector 的配置是一个显式的「数据流图」,由六类组件组成:
spanmetrics connector 把 trace 转成 RED
指标。file_storage(持久化队列)。traces/metrics/logs
三类有向图。receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
max_recv_msg_size_mib: 32
http:
endpoint: 0.0.0.0:4318
prometheus:
config:
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs: [{ role: pod }]
filelog:
include: [/var/log/pods/*/*/*.log]
start_at: end
include_file_path: true
operators:
- type: container
id: container-parser
processors:
memory_limiter:
check_interval: 2s
limit_percentage: 75
spike_limit_percentage: 15
batch:
send_batch_size: 8192
timeout: 5s
send_batch_max_size: 16384
k8sattributes:
auth_type: serviceAccount
passthrough: false
extract:
metadata: [k8s.pod.name, k8s.pod.uid, k8s.namespace.name, k8s.node.name,
k8s.deployment.name, k8s.container.name]
labels:
- from: pod
key_regex: app.kubernetes.io/(.*)
tag_name: $$1
pod_association:
- sources: [{ from: resource_attribute, name: k8s.pod.ip }]
- sources: [{ from: connection }]
resource:
attributes:
- key: deployment.environment
value: prod
action: upsert
attributes/redact:
actions:
- key: db.statement
action: hash
- key: http.request.header.authorization
action: delete
transform/http_route:
trace_statements:
- context: span
statements:
- set(name, attributes["http.route"]) where attributes["http.route"] != nil
tail_sampling:
decision_wait: 10s
num_traces: 200000
expected_new_traces_per_sec: 2000
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: random-1pct
type: probabilistic
probabilistic: { sampling_percentage: 1 }
- name: always-for-payments
type: string_attribute
string_attribute:
key: service.name
values: [payments-service, order-service]
exporters:
otlp/tempo:
endpoint: tempo-distributor:4317
tls: { insecure: true }
sending_queue: { enabled: true, queue_size: 10000, num_consumers: 10 }
retry_on_failure: { enabled: true, initial_interval: 5s, max_elapsed_time: 5m }
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
resource_to_telemetry_conversion: { enabled: true }
external_labels: { cluster: shanghai-prod }
loki:
endpoint: http://loki:3100/loki/api/v1/push
kafka:
brokers: [kafka-0:9092, kafka-1:9092, kafka-2:9092]
topic: otlp_spans
encoding: otlp_proto
producer: { compression: snappy, max_message_bytes: 1000000 }
clickhouse:
endpoint: tcp://clickhouse:9000?dial_timeout=10s
database: otel
ttl: 720h
extensions:
health_check: { endpoint: 0.0.0.0:13133 }
pprof: { endpoint: 0.0.0.0:1777 }
zpages: { endpoint: 0.0.0.0:55679 }
file_storage:
directory: /var/lib/otelcol/queue
timeout: 1s
service:
extensions: [health_check, pprof, zpages, file_storage]
telemetry:
logs: { level: info }
metrics: { address: 0.0.0.0:8888 }
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, resource, attributes/redact,
transform/http_route, tail_sampling, batch]
exporters: [otlp/tempo, kafka, clickhouse]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, k8sattributes, resource, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp, filelog]
processors: [memory_limiter, k8sattributes, resource, batch]
exporters: [loki, clickhouse]这个配置在实际生产里经受过 10 万 span/s 的压力测试,有几个关键细节值得单独说:
memory_limiter 一定放在 batch
之前。顺序反了,Collector OOM 前来不及回压,SDK
侧也不会减速。k8sattributes 放在 resource
之前,确保后续的 resource processor 能在 pod
信息基础上再加 overlay。tail_sampling 必须放到 batch
之前,否则 batch 会把同一个 trace 的 span
拆到不同批次里,尾采样拿不到完整 trace。sending_queue 的 queue_size 与
num_consumers
是调吞吐的主要旋钮,别忘了还要配合 file_storage
做持久化,否则重启丢数据。| Processor | 场景 |
|---|---|
memory_limiter |
内存防护,必配 |
batch |
批处理,几乎必配 |
resource |
全局资源属性注入 |
attributes |
属性的增删改、哈希、脱敏 |
filter |
按条件过滤数据 |
transform |
OTTL 表达式做字段级别的转换,v0.80+ 新一代主力 |
k8sattributes |
K8s 元数据注入 |
tail_sampling |
尾采样 |
probabilistic_sampler |
头采样 |
spanmetrics(connector) |
从 trace 派生 RED 指标 |
servicegraph(connector) |
从 trace 派生服务依赖图 |
routing |
按属性把数据路由到不同 exporter |
groupbytrace |
将同 trace span 聚合,供尾采样使用 |
OTel Collector 有两种典型部署形态:
两者组合成「Agent → Gateway → Backend」的两级流水线:
App ── OTLP ── Agent(DaemonSet) ── OTLP ── Gateway(Deployment) ── {Tempo, Prom, Loki}
Agent 层关心「少丢、少漏」,Gateway 层关心「正确采样、正确路由」。
Metrics 和 Logs pipeline 是无状态的,可以直接基于 CPU/内存 HPA 扩容。Traces 的尾采样是「按 trace_id 聚合」的有状态操作,不能随便分片。解决方案:
loadbalancing
exporter,按 trace_id 哈希路由,保证同一 trace
的所有 span 落到同一个 Gateway 实例:exporters:
loadbalancing:
protocol:
otlp:
tls: { insecure: true }
resolver:
dns:
hostname: otel-gateway.observability.svc.cluster.local
port: 4317
routing_key: traceIDgroupbytrace + tail_sampling
的内存占用与 num_traces × 平均 span
数成正比,扩容要提前预估。一个 spanmetrics connector 把 trace pipeline
派生出一个 metrics pipeline 的典型配置:
connectors:
spanmetrics:
histogram:
explicit:
buckets: [5ms, 10ms, 50ms, 100ms, 500ms, 1s, 5s]
dimensions:
- name: service.name
- name: http.route
- name: http.response.status_code
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo, spanmetrics]
metrics/spanmetrics:
receivers: [spanmetrics]
exporters: [prometheusremotewrite]这样一份 trace 流量同时产出原始 span 数据与 RED 指标,后者直接接进 Prometheus,Grafana 面板可以立刻用上。
Java 的零代码埋点是 OTel 生态里最成熟的一环,使用
opentelemetry-javaagent.jar:
wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
java -javaagent:./opentelemetry-javaagent.jar \
-Dotel.service.name=order-service \
-Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
-Dotel.traces.sampler=parentbased_traceidratio \
-Dotel.traces.sampler.arg=0.1 \
-Dotel.resource.attributes=service.version=2026.04.22,deployment.environment=prod \
-jar app.jarjavaagent 使用 ByteBuddy 在类加载时注入字节码,覆盖 100 多种框架(Spring、Tomcat、Jetty、Netty、JDBC、Redis、Kafka、gRPC、OkHttp、HttpClient 等)。它的价值是「老系统零改动接入」,代价是:
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
OTEL_SERVICE_NAME=recommend-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=0.1 \
opentelemetry-instrument python app.pyopentelemetry-bootstrap
会扫描当前已安装的依赖,把对应的
opentelemetry-instrumentation-*
包自动装上,省去一个个挑库的麻烦。
Go 由于编译型语言的特性,没有真正的「运行时自动埋点」。现阶段有两条路:
go.opentelemetry.io/contrib/instrumentation/...
系列库,比如 otelhttp.NewHandler 包装 HTTP
Server、otelgrpc.UnaryServerInterceptor 包装
gRPC Server。odigos、beyla、opentelemetry-go-instrumentation(基于
uprobes,仍在 Alpha)。Go 生产里主流仍是「在框架入口手动接一次」+ 「HTTP Client、DB Client、Kafka Client 都走已埋点的封装库」,代码侵入度不高。
npm install @opentelemetry/api @opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc
node --require @opentelemetry/auto-instrumentations-node/register app.jsNode.js 自动埋点基于 require hook / ESM loader,覆盖 Express、Koa、Fastify、gRPC、MongoDB、Redis 等。
| 维度 | 自动埋点 | 手动埋点 |
|---|---|---|
| 接入速度 | 分钟级 | 天到周 |
| 业务语义 | 只到框架层 | 可到业务事件级别 |
| 升级成本 | 跟 agent 走 | 跟代码仓库走 |
| 性能控制 | 不易细调 | 可精细裁剪 |
| 与自研框架兼容 | 可能缺埋点 | 完整覆盖 |
实操经验:先让自动埋点覆盖 80% 的 HTTP/DB/MQ 链路,再用手动埋点补业务关键节点(下单、风控决策、鉴权)。两种不冲突,可并存。
trace_id
概率。优点是开销低、决策本地;缺点是无法感知「这条链路有没有错」。上文 YAML 里已经有完整示例,这里拆解核心参数:
decision_wait:Collector 等多久才对一条
trace 作出决策。通常设为「链路最长耗时 +
安全余量」,生产常用 10~30 秒。num_traces:同时在内存里保留的 trace
数上限,内存吃紧时首先调低它。expected_new_traces_per_sec:辅助 Collector
做内存预估。policies:策略列表,按顺序判断;命中任一策略即保留。策略类型包括:latency、status_code、numeric_attribute、string_attribute、rate_limiting、probabilistic、composite、and、trace_state、ottl_condition。
尾采样要求同 trace 的 span 落在同一个 Collector
实例,必须在上游(Agent 或第一层 Gateway)用
loadbalancing exporter 做 trace_id
哈希。第二层 Gateway 才跑
tail_sampling。下图是工程上普遍使用的三层:
App ─ OTLP ─ Agent(DaemonSet)
│ batch + resource
▼
Gateway-L1(LB) ── trace_id hash ──▶ Gateway-L2(tail_sampling) ─▶ Tempo
num_traces × 平均 span 数 × 平均 span 大小。10
万 trace × 30 span × 2 KB ≈ 6 GB,Gateway
要预留足够内存。decision_wait
秒,实时排障体验变差。composite 组合少量高价值规则。一个务实的策略组合:100% 错误 + 100% > P99 延迟 + 10% 关键服务 + 1% 其它服务。能把存储量压到头采样的 1/5,同时保留绝大部分有价值的链路。
平台团队常常要给多个业务方提供统一 Collector 集群,要求:
租户信息的常见来源:
X-Scope-OrgID(Loki/Tempo/Mimir
原生约定)或自定义 x-tenant-id。service.namespace、deployment.environment、tenant.id。推荐统一用 Resource 属性 tenant.id,由 SDK
侧通过
OTEL_RESOURCE_ATTRIBUTES=tenant.id=payments
注入;Collector 用 routing processor/connector
分流。
connectors:
routing:
default_pipelines: [traces/default]
error_mode: ignore
table:
- context: resource
condition: attributes["tenant.id"] == "payments"
pipelines: [traces/payments]
- context: resource
condition: attributes["tenant.id"] == "risk"
pipelines: [traces/risk]
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes]
exporters: [routing]
traces/payments:
receivers: [routing]
processors: [batch]
exporters: [otlp/tempo-payments]
traces/risk:
receivers: [routing]
processors: [batch]
exporters: [otlp/tempo-risk]
traces/default:
receivers: [routing]
processors: [batch]
exporters: [otlp/tempo-shared]memory_limiter
是进程级的,不能按租户限。groupbyattrs + ratelimit(社区
processor)可以按 tenant.id
做速率限制,但目前还处于 Beta。后端也要支持多租户才有意义:
X-Scope-OrgID
划分租户,Collector 在 otlp exporter
里注入这个头。external_labels + 多实例方案,或者换成
Mimir/Thanos。OTel 规范对每个组件标注稳定性:
| 信号 | Stable 时间 | 当前状态 |
|---|---|---|
| Traces API/SDK | 2021-02 | Stable |
| OTLP Traces | 2021-02 | Stable |
| Metrics API/SDK | 2023-02 | Stable |
| OTLP Metrics | 2023-02 | Stable |
| Logs Bridge API | 2024-04 | Stable |
| OTLP Logs | 2024-04 | Stable |
| Profiles | 进入 Development | Experimental |
| Events API | 依附 Logs Bridge | Beta |
Collector 有自己独立的版本体系,当前(2026
年)主线已经进入 v0.9x → v1.0
的冲刺。v1.0 标志着配置格式与内建组件 API
冻结,但大量 contrib
组件仍保持各自的稳定性级别。升级时要重点看
CHANGELOG 中 [breaking] 标记。
近三年升级踩过的坑:
resourcedetection
重命名配置字段;Dockerfile 里固化旧名会启动失败。attributes processor 部分动作迁移到
transform(OTTL),旧写法 Deprecated。http.method 改为
http.request.method,Grafana
面板同时支持两个字段至少半年。logging exporter 更名为
debug,老配置直接报错。Cumulative →
Delta
转换规则微调,需要确认后端计算是否兼容。schema_url
在数据里显式带上,后端通过
OpenTelemetry Schema Transformations
做跨版本翻译,不强依赖 SDK 版本对齐。ARMS 从 2022 年起原生兼容 OTLP。接入方式有两种:
OTEL_EXPORTER_OTLP_ENDPOINT 指到
https://tracing-analysis-dc-hz.aliyuncs.com/...:4317,并通过
Header 带 Authentication: {LicenseKey}。otlp 到
ARMS。这样可以在出口做脱敏、采样,并避免每个业务服务持有
LicenseKey。ARMS
对语义约定兼容得比较完整,service.name、http.route、db.statement
都能正常出现在调用链界面;deployment.environment
会映射成 ARMS 的「环境」筛选项。坑点:ARMS 对
service.name 有长度限制(64
字符),团队里统一用
<business>-<module>
的命名风格时要注意。
观测云(Dataflux/Guance)原生支持 OTLP 接入,既可以通过其
DataKit Agent 转发(DataKit 里集成了 OTel
Collector),也可以直接走原生 OTLP。它在 Metrics 上把 OTLP
的
Sum/Gauge/Histogram
翻译成其自有模型,跨信号关联依赖 service.name +
trace_id。生产里如果已经在跑 OTel
Collector,建议让 DataKit 只接 logs,traces/metrics 从
Collector 直接出 OTLP 到观测云的入口,运维链路更清晰。
夜莺(Nightingale)v6 起把 OTLP 作为一等入口,内置了兼容 Jaeger 和 OTLP 的接收端。对中小团队来说,它提供的「一套前端同时看指标 + 日志 + 链路」能力吸引力很强。但其 Tracing 存储(Jaeger/Tempo)仍要自建或对接外部方案,OTLP Collector 仍然是标配。
字节跳动公开分享过的经验(参见 2023 年 ArchSummit、2024 年 QCon 议题)可以概括为几条:
trace_id 做 ORDER BY
存储。OTel 语义约定统一用 .
作为分隔符。但部分后端(尤其是基于 Elasticsearch
字段扁平化的方案)会把 .
当成嵌套路径,导致字段爆炸。应对:
transform processor
在出口前把 . 替换为 _:processors:
transform/underscore:
log_statements:
- context: resource
statements:
- replace_all_patterns(attributes, "key", "\\.", "_")service.name 缺失或错误前文提到:缺失时 Collector 填
unknown_service,实际上更常见的错是「填错」,例如所有
Sidecar 容器写成了
envoy、所有副本写成同一个镜像名。建议:
OTEL_SERVICE_NAME
强制覆盖。transform 规则,检测
service.name == "unknown_service"
时直接告警。前面列过的
http.method → http.request.method、logging → debug
都是典型。最佳实践:
直接把
user.id、request.id、session.id
打到指标维度是最经典的自杀操作。识别与治理:
count by (__name__) (count by (__name__, label) (metric))
找高基数维度。attributes/drop:processors:
attributes/drop_highcard:
actions:
- key: user.id
action: delete
- key: request.id
action: deleteCollector 的内存压力主要来自:
memory_limiter 时,OOM 无感知。tail_sampling 与 groupbytrace
的 num_traces 设置过大。batch 队列与 sending_queue
叠加。filelog 一次读入过大日志文件,没设
start_at: end 时尤其危险。调优顺序:先开 memory_limiter + Linux cgroup
硬限;监控
otelcol_process_runtime_heap_alloc_bytes 与
otelcol_exporter_queue_size;按需调
batch/queue/num_traces。
Kafka/RocketMQ 消费是异步的,极易漏传
traceparent:
propagator.inject(ctx, kafkaHeaders)。ctx = propagator.extract(kafkaHeaders),随后用
tracer.Start(ctx, ...) 开新的子 span。此外在「批量消费」场景下,一条 Consumer span 可能对应多个
Producer span,这时应该使用 Links 而不是
parent,以表达「多对一」的关系:
ctx, span := tracer.Start(ctx, "consume.batch",
trace.WithLinks(linksFromHeaders(msgs)...))日志接入 OTel 有两条路:
LogRecord,进入
LoggerProvider 流水线。trace_id 与 span_id,文件落盘后由
Collector filelog receiver 采集。两种都能让日志与 trace 在后端相关联,但要求日志里必须带
trace_id。最常见的错误是:模板里写了
%X{traceId} 却没在 Logback 里挂上 OTel MDC
Instrumentation。
OTLP 的时间戳使用 unix_nano(64
位纳秒)。应用机时钟漂移超过 decision_wait
的一半时,尾采样可能认为 trace
还没结束而无限等待;最终在内存里积压至 OOM。务必上
NTP/chrony 并监控
node_timex_offset_seconds。
生产里 SDK → Collector 走 mTLS
是常见要求,但不少团队会一开始用 WithInsecure()
跑起来之后再补。补的时候会发现:
opentelemetry-java-instrumentation 的 TLS
配置来自 OTEL_EXPORTER_OTLP_CERTIFICATE
等环境变量,容易被忽视。credentials.TransportCredentials。otlp receiver tls
字段必须同时配 cert_file 与
key_file,否则加载失败。建议从 Day 1 就统一用 cert-manager 签发内部证书,SDK 与 Collector 都走 mTLS。
| 场景 | 建议 |
|---|---|
| 全新项目,后端未定 | 直接上 OTel SDK,避免绑定 |
| 后端已选 Jaeger/Tempo/ARMS/观测云 | OTel SDK 全量接,后端走 OTLP |
| 后端已选 Datadog/NewRelic/Dynatrace 且无迁移计划 | 可以继续用厂商 Agent,但新服务仍推荐 OTel,厂商 Agent 基本都支持 OTLP ingest |
| 仅需指标 | Prometheus Client 也可,若三信号都要再上 OTel |
| 嵌入式/极低资源环境 | 评估 SDK 内存(Java ≈ 50 MB,Go ≈ 10 MB) |
service.name、service.version、deployment.environment
三个资源属性的来源(CI 里注入为宜)。memory_limiter
必配,sending_queue 必配
file_storage。tail_sampling 与 loadbalancing
一起上,避免错过错误链路。attributes/drop。trace_id、span_id;指标通过
Exemplars(v1.27+)带
trace_id。X-Scope-OrgID;或直接上阿里云
ARMS/观测云等商业化多租户后端。OTEL_SDK_DISABLED=true),Collector
配置 config hot-reload。otelcol_* 指标接入
Prometheus,关键告警为
queue_size、refused_spans、exporter_send_failed_requests。AlwaysOff +
显式打开。上一篇:Traces:Jaeger、Tempo、Zipkin、SkyWalking 与采样传播
下一篇:持续性能分析(Profiling):pprof、Pyroscope、Parca、async-profiler、JFR
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-22 · architecture / observability
从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。
2026-04-22 · architecture / observability
从控制论到云原生:拆解可观测性的五大信号支柱,对比监控与可观测性的本质区别,梳理开源/商业/SaaS 分类,以及国内互联网公司三大支柱落地现状与典型工程坑点。
2026-04-22 · architecture / observability
监控与可观测性不是新旧迭代,而是认知模型的根本转换。本文梳理从 1999 年 Nagios 到 2019 年 OpenTelemetry 的二十年演进时间线,对比 push/pull 模型、数据模型差异,以及国内从 Zabbix 到 Prometheus 再到 OTel 的典型迁移路径与工程坑点。
2026-04-22 · architecture / observability
USE 方法论适用于资源,RED 方法论适用于请求,Golden Signals 适用于服务——三套方法论各有其适用对象。本文从 Brendan Gregg、Tom Wilkie、Google SRE 的原始定义出发,构建覆盖资源→服务→业务的完整指标体系,并给出 Prometheus 命名规范、基数治理策略与可抄的指标清单。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。