CTO Plus技术服务栈

可观测性体系建设:OpenTelemetry + Grafana 生产级实践

从零搭建企业级可观测性平台:Metrics(Prometheus)、Logging(Loki)、Tracing(Tempo)三大支柱的整合实践。包含OpenTelemetry自动插桩、自定义指标暴露、告警规则配置和Grafana Dashboard设计。

SteveRocket
北京,中国
2 min read

从监控到可观测性

2020 年之前,大多数团队做的是”监控”——预先定义好要监控的指标,设好告警阈值,等着告警触发。这种方法的问题是:你只能发现你已经知道的问题

可观测性(Observability)更进一步——你可以探索性地理解系统的行为,即使遇到了从未见过的问题,也能通过 Metrics、Logging、Tracing 三者的关联快速定位。

我们团队从 2024 年开始全面迁移到 OpenTelemetry + Grafana 栈,过程中踩了不少坑。这篇文章把核心架构和实践经验整理出来。

可观测性的三大支柱

Metrics(指标)

回答”系统有没有出问题?”

  • 请求量、错误率、延迟(RED 方法)
  • CPU、内存、磁盘(USE 方法)
  • 业务指标(订单量、支付成功率)

Logging(日志)

回答”具体出了什么问题?”

  • 应用日志(错误、警告、调试信息)
  • 访问日志(请求路径、响应时间、状态码)
  • 审计日志(谁在什么时候做了什么)

Tracing(链路追踪)

回答”问题出在哪个环节?”

  • 分布式调用链(从前端到数据库的完整路径)
  • 每个 Span 的耗时
  • 错误和异常的传播路径

三者关系:

Metrics 告诉你"有异常"
  → Tracing 告诉你"在哪个服务/哪个方法"
    → Logging 告诉你"具体是什么错误"

没有 Tracing,你只能靠猜和 grep 日志。

技术选型:为什么选择 OTel + Grafana 栈?

行业标准对比

方案MetricsLoggingTracing成本
ELK StackElasticsearch
Datadog极高
Grafana 栈PrometheusLokiTempo低-中
阿里云 SLS

我们的选择:OpenTelemetry + Grafana 栈

理由:

  1. OpenTelemetry 是 CNCF 项目,已经成为可观测性的事实标准
  2. 不锁定厂商:数据可以发到任何支持 OTLP 的后端
  3. 自动插桩:主流语言的 SDK 都支持自动采集
  4. 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 万请求)为例:

组件存储计算月成本
Prometheus100GB(15天)4C8G¥300
Loki500GB(7天)2C4G¥200
Tempo200GB(7天)2C4G¥200
Collector-2C4G¥100
Grafana-2C4G¥100
合计800GB12C24G~¥900

相比之下,Datadog 同样规模的成本约为 $2000-5000/月。

总结

可观测性不是一个”装上就完了”的工具,而是一种工程文化。核心要点:

  1. 三大支柱缺一不可:Metrics 发现问题,Tracing 定位服务,Logging 定位原因
  2. OpenTelemetry 是标准:不要自建 SDK,用 OTel 统一采集
  3. 采样策略要合理:错误和高延迟请求全采,正常请求按比例采
  4. Context 传播是灵魂:断了的 Trace 等于没采
  5. 告警要可行动:每条告警配 Runbook
  6. Dashboard 要分层:不同角色看不同面板

Comments

Link copied to clipboard!