可观测性体系建设:OpenTelemetry + Grafana 生产级实践
从零搭建企业级可观测性平台:Metrics(Prometheus)、Logging(Loki)、Tracing(Tempo)三大支柱的整合实践。包含OpenTelemetry自动插桩、自定义指标暴露、告警规则配置和Grafana Dashboard设计。
从监控到可观测性
2020 年之前,大多数团队做的是”监控”——预先定义好要监控的指标,设好告警阈值,等着告警触发。这种方法的问题是:你只能发现你已经知道的问题。
可观测性(Observability)更进一步——你可以探索性地理解系统的行为,即使遇到了从未见过的问题,也能通过 Metrics、Logging、Tracing 三者的关联快速定位。
我们团队从 2024 年开始全面迁移到 OpenTelemetry + Grafana 栈,过程中踩了不少坑。这篇文章把核心架构和实践经验整理出来。
可观测性的三大支柱
Metrics(指标)
回答”系统有没有出问题?”
- 请求量、错误率、延迟(RED 方法)
- CPU、内存、磁盘(USE 方法)
- 业务指标(订单量、支付成功率)
Logging(日志)
回答”具体出了什么问题?”
- 应用日志(错误、警告、调试信息)
- 访问日志(请求路径、响应时间、状态码)
- 审计日志(谁在什么时候做了什么)
Tracing(链路追踪)
回答”问题出在哪个环节?”
- 分布式调用链(从前端到数据库的完整路径)
- 每个 Span 的耗时
- 错误和异常的传播路径
三者关系:
Metrics 告诉你"有异常"
→ Tracing 告诉你"在哪个服务/哪个方法"
→ Logging 告诉你"具体是什么错误"
没有 Tracing,你只能靠猜和 grep 日志。
技术选型:为什么选择 OTel + Grafana 栈?
行业标准对比
| 方案 | Metrics | Logging | Tracing | 成本 |
|---|---|---|---|---|
| ELK Stack | ❌ | Elasticsearch | ❌ | 高 |
| Datadog | ✅ | ✅ | ✅ | 极高 |
| Grafana 栈 | Prometheus | Loki | Tempo | 低-中 |
| 阿里云 SLS | ✅ | ✅ | ❌ | 中 |
我们的选择:OpenTelemetry + Grafana 栈
理由:
- OpenTelemetry 是 CNCF 项目,已经成为可观测性的事实标准
- 不锁定厂商:数据可以发到任何支持 OTLP 的后端
- 自动插桩:主流语言的 SDK 都支持自动采集
- Grafana 是开源世界最好的可视化工具
架构总览
应用层
├── OpenTelemetry SDK(自动/手动插桩)
│ ├── Metrics → OTLP → Prometheus
│ ├── Traces → OTLP → Tempo
│ └── Logs → OTLP → Loki
│
采集层
├── OpenTelemetry Collector(接收、处理、导出)
│
存储层
├── Prometheus(Metrics,TSDB)
├── Loki(Logs,对象存储)
└── Tempo(Traces,对象存储)
│
展示层
└── Grafana(统一查询和可视化)
│
告警层
└── Grafana Alerting / Alertmanager
实战:Go 服务接入 OpenTelemetry
第一步:初始化 SDK
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func initOpenTelemetry(ctx context.Context) (*trace.TracerProvider, *metric.MeterProvider, error) {
// 定义服务资源
res := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("user-service"),
semconv.ServiceVersion("1.0.0"),
semconv.DeploymentEnvironment("production"),
)
// 初始化 Trace Provider
traceExporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExporter),
trace.WithResource(res),
trace.WithSampler(trace.AlwaysSample()),
)
otel.SetTracerProvider(tp)
// 初始化 Meter Provider
metricExporter, err := otlpmetricgrpc.New(ctx,
otlpmetricgrpc.WithEndpoint("otel-collector:4317"),
otlpmetricgrpc.WithInsecure(),
)
if err != nil {
return nil, nil, err
}
mp := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExporter)),
metric.WithResource(res),
)
otel.SetMeterProvider(mp)
return tp, mp, nil
}
第二步:自动插桩(最省力)
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
// HTTP 自动插桩
http.Handle("/api", otelhttp.NewHandler(apiHandler, "api-handler"))
// gRPC 自动插桩
conn, _ := grpc.Dial(
"service:50051",
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
)
// Gin 自动插桩
router := gin.Default()
router.Use(otelgin.Middleware("user-service"))
第三步:自定义 Span
func GetUser(ctx context.Context, userId string) (*User, error) {
tracer := otel.Tracer("user-service")
// 创建 Span
ctx, span := tracer.Start(ctx, "GetUser")
defer span.End()
// 添加属性
span.SetAttributes(
attribute.String("user.id", userId),
)
// 查询数据库(自动记录为子 Span)
user, err := db.QueryUser(ctx, userId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "查询用户失败")
return nil, err
}
span.SetStatus(codes.Ok, "成功")
return user, nil
}
第四步:自定义 Metrics
var (
requestCounter, _ = meter.Int64Counter(
"http_requests_total",
metric.WithDescription("HTTP 请求总数"),
metric.WithUnit("1"),
)
requestDuration, _ = meter.Float64Histogram(
"http_request_duration_seconds",
metric.WithDescription("HTTP 请求耗时"),
metric.WithUnit("s"),
)
)
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 包装 ResponseWriter 获取状态码
wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(wrapped, r)
duration := time.Since(start).Seconds()
// 记录指标
requestCounter.Add(r.Context(), 1,
metric.WithAttributes(
attribute.String("method", r.Method),
attribute.String("path", r.URL.Path),
attribute.Int("status_code", wrapped.statusCode),
),
)
requestDuration.Record(r.Context(), duration,
metric.WithAttributes(
attribute.String("method", r.Method),
attribute.String("path", r.URL.Path),
),
)
})
}
OpenTelemetry Collector 配置
Collector 是数据管道的中枢,负责接收、处理和导出数据:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
attributes:
actions:
- key: environment
value: production
action: upsert
exporters:
prometheusremotewrite:
endpoint: "http://prometheus:9090/api/v1/write"
otlp/tempo:
endpoint: "tempo:4317"
tls:
insecure: true
loki:
endpoint: "http://loki:3100/loki/api/v1/push"
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [loki]
Grafana Dashboard 设计
黄金信号 Dashboard
每个服务都应该有一个标准的 RED(Rate, Errors, Duration)Dashboard:
{
"panels": [
{
"title": "请求速率",
"targets": [
{
"expr": "rate(http_requests_total[5m])",
"legendFormat": "{{method}} {{path}}"
}
]
},
{
"title": "错误率",
"targets": [
{
"expr": "rate(http_requests_total{status_code=~\"5..\"}[5m]) / rate(http_requests_total[5m])",
"legendFormat": "5xx Error Rate"
}
]
},
{
"title": "P50/P95/P99 延迟",
"targets": [
{
"expr": "histogram_quantile(0.50, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "P50"
},
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "P95"
},
{
"expr": "histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))",
"legendFormat": "P99"
}
]
}
]
}
SLO Dashboard
# 错误预算剩余
(1 - (sum(rate(http_requests_total{status_code=~"5.."}[28d]))
/ sum(rate(http_requests_total[28d]))))
/ (1 - 0.999) # 99.9% SLO 目标
告警规则设计
告警分层
| 级别 | 含义 | 响应时间 | 通知方式 |
|---|---|---|---|
| P0 Critical | 用户可感知的服务中断 | 5 分钟 | 电话 + 群通知 |
| P1 Warning | 即将影响用户 | 15 分钟 | 群通知 |
| P2 Info | 需关注的趋势 | 1 小时 | 日报 |
Prometheus 告警规则
# alerting-rules.yml
groups:
- name: service-alerts
rules:
# P0:错误率超过 1%
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status_code=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 0.01
for: 2m
labels:
severity: critical
annotations:
summary: "服务 {{ $labels.service }} 错误率过高"
description: "过去5分钟错误率 {{ $value | humanizePercentage }}"
# P1:P99 延迟超过 1 秒
- alert: HighLatency
expr: |
histogram_quantile(0.99,
rate(http_request_duration_seconds_bucket[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "服务 {{ $labels.service }} P99 延迟过高"
# P2:内存使用率超过 80%
- alert: HighMemoryUsage
expr: |
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.8
for: 10m
labels:
severity: info
我们的实践经验
经验一:采样策略很关键
全量采集 Trace 数据量太大,必须采样:
// 生产环境推荐配置
tp := trace.NewTracerProvider(
trace.WithSampler(trace.ParentBased(
trace.TraceIDRatioBased(0.1), // 10% 采样率
)),
)
采样策略建议:
- 正常请求:10% 采样
- 错误请求:100% 采样(最重要!)
- 高延迟请求:100% 采样
经验二:日志要结构化
# 不要这样写日志
log.Info("用户 %s 登录成功", userID)
# 要这样写
logger.Info("用户登录成功",
zap.String("user_id", userID),
zap.String("ip", clientIP),
zap.Duration("duration", elapsed),
)
结构化的日志才能被 Loki 有效索引和查询。在 Grafana 里你可以直接写 {service="user-service"} | logfmt | user_id="xxx"。
经验三:Context 传播
这是最容易被忽略但最重要的一点。如果 Context 没有正确传播,Tracing 就断了:
// 正确:传递 context
func HandleRequest(ctx context.Context, req *Request) {
user, _ := GetUser(ctx, req.UserID) // ✅ context 传递
orders, _ := GetOrders(ctx, user.ID) // ✅
ProcessOrders(ctx, orders) // ✅
}
// 错误:创建新的 context.Background()
func HandleRequest(ctx context.Context, req *Request) {
user, _ := GetUser(context.Background(), req.UserID) // ❌ Trace 断了!
}
经验四:Dashboard 要分层
不要做一个”万能 Dashboard”,信息过载等于没有信息:
- 服务概览:RED 指标 + 错误日志(每个人都要看)
- 资源详情:CPU/内存/网络/磁盘(运维看)
- 业务指标:订单量/支付率/转化率(产品和业务看)
- 依赖拓扑:服务间调用关系和延迟(排查问题用)
经验五:告警要”可行动”
每一条告警规则,团队成员应该能回答:“收到这条告警后,我第一步做什么?”
如果答案是”不知道”,那这条告警就是噪音。我们团队有一条铁律:每条告警规则必须有对应的 Runbook。
成本估算
以中型团队(20-30 个微服务,日均 1000 万请求)为例:
| 组件 | 存储 | 计算 | 月成本 |
|---|---|---|---|
| Prometheus | 100GB(15天) | 4C8G | ¥300 |
| Loki | 500GB(7天) | 2C4G | ¥200 |
| Tempo | 200GB(7天) | 2C4G | ¥200 |
| Collector | - | 2C4G | ¥100 |
| Grafana | - | 2C4G | ¥100 |
| 合计 | 800GB | 12C24G | ~¥900 |
相比之下,Datadog 同样规模的成本约为 $2000-5000/月。
总结
可观测性不是一个”装上就完了”的工具,而是一种工程文化。核心要点:
- 三大支柱缺一不可:Metrics 发现问题,Tracing 定位服务,Logging 定位原因
- OpenTelemetry 是标准:不要自建 SDK,用 OTel 统一采集
- 采样策略要合理:错误和高延迟请求全采,正常请求按比例采
- Context 传播是灵魂:断了的 Trace 等于没采
- 告警要可行动:每条告警配 Runbook
- Dashboard 要分层:不同角色看不同面板