文章

可观测性六维信号深度实战

可观测性六维信号深度实战

从 “告警响了” 到 “我知道问题出在哪,而且知道怎么修”的完整信号链。每维信号给出 Go 代码示例 + PromQL/LokiQL/TraceQL 查询模板 + K8s 配置。

一、Metrics 指标:从 RED/USE 到 PromQL 实战

1.1 Go 服务埋点——Prometheus Client

package metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "net/http"
    "time"
)

// RED 信号定义
var (
    // Rate — 以 Histogram 同时捕获计数和分布
    RequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request latency distributions.",
        Buckets: prometheus.DefBuckets, // [.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10]
    }, []string{"method", "endpoint", "status"})

    // Errors 通过 status 标签区分,不单独设 Counter
    // Duration 从 Histogram 计算 P50/P95/P99

    // 业务指标
    OrderCreated = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "orders_created_total",
        Help: "Total number of orders created.",
    }, []string{"source", "status"})

    // Go runtime 指标(进程级 USE)
    Goroutines = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "go_goroutines_current",
        Help: "Current number of goroutines.",
    })

    GCSummary = promauto.NewSummary(prometheus.SummaryOpts{
        Name: "go_gc_duration_seconds",
        Help: "GC pause duration summary.",
    })
)

// Middleware 方式自动记录
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(wrapped, r)
        duration := time.Since(start).Seconds()

        RequestDuration.WithLabelValues(
            r.Method,
            r.URL.Path,
            http.StatusText(wrapped.statusCode),
        ).Observe(duration)
    })
}

1.2 PromQL 十大实战查询

# 1. 服务可用性(SLI)
sum(rate(http_requests_total{status!~"5.."}[5m]))
  / sum(rate(http_requests_total[5m]))

# 2. P99 延迟
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, endpoint))

# 3. 错误率突增检测(同比 30m 前)
rate(http_requests_total{status=~"5.."}[5m])
  / rate(http_requests_total{status=~"5.."}[5m] offset 30m) > 2

# 4. CPU 饱和度(容器级)
rate(container_cpu_cfs_throttled_seconds_total[5m]) > 0

# 5. Pod 内存 OOM Kill 风险
container_memory_working_set_bytes{container!=""}
  / container_spec_memory_limit_bytes > 0.85

# 6. 慢查询 Top 10(按 P99,取 1h 窗口)
topk(10,
  histogram_quantile(0.99,
    sum(rate(http_request_duration_seconds_bucket[1h])) by (le, endpoint)))

# 7. Error Budget 剩余(月窗口)
(1 - (
  sum(increase(http_requests_total{status=~"5.."}[30d]))
    / sum(increase(http_requests_total[30d]))
)) / 0.999  # SLO=99.9%

# 8. 烧钱率(Burn Rate,6h 窗口 × 14.4x 阈值)
sum(rate(http_requests_total{status=~"5.."}[6h]))
  / sum(rate(http_requests_total[6h]))
  / (1 - 0.999) > 14.4

# 9. 服务间依赖调用量
sum(rate(http_request_duration_seconds_count[5m])) by (source, target)

# 10. Pod 重启频率(Rolling 窗口)
rate(kube_pod_container_status_restarts_total[1h]) > 0

1.3 Recording Rules——预计算降基数

# rules/availability.yml
groups:
  - name: slo_rules
    interval: 30s
    rules:
      # 先预聚合,再对外暴露。降低 Grafana 查询基数
      - record: job:http_requests_total:rate5m
        expr: sum(rate(http_requests_total[5m])) by (job)

      - record: job:http_errors_total:rate5m
        expr: sum(rate(http_requests_total{status=~"5.."}[5m])) by (job)

      - record: job:availability:ratio5m
        expr: |
          (sum(rate(http_requests_total{status!~"5.."}[5m])) by (job))
            / sum(rate(http_requests_total[5m])) by (job)

      # P50/P90/P99 延迟预聚合
      - record: job:latency:p50
        expr: |
          histogram_quantile(0.50,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))

      - record: job:latency:p99
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))

1.4 告警规则——Google SRE 多窗口 Burn Rate

# rules/alerts.yml
groups:
  - name: slo_alerts
    rules:
      # 严重:1h 窗口烧钱率 > 14.4x(等价于 2% 错误预算 1h 烧完)
      - alert: HighErrorRate_Critical
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[1h]))
              / sum(rate(http_requests_total[1h]))
          ) / (1 - 0.999) > 14.4
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "错误预算烧钱率严重(1h > 14.4x)"
          description: "{{ $labels.job }} 1h 内烧掉了年预算的 2%"

      # 警告:6h 窗口烧钱率 > 6x
      - alert: HighErrorRate_Warning
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[6h]))
              / sum(rate(http_requests_total[6h]))
          ) / (1 - 0.999) > 6
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "错误预算烧钱率警告(6h > 6x)"

  - name: infrastructure
    rules:
      # CPU 被 Throttle 超过 5% 时间
      - alert: CPUThrottlingHigh
        expr: |
          rate(container_cpu_cfs_throttled_seconds_total[5m])
            / rate(container_cpu_usage_seconds_total[5m]) > 0.05
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Pod {{ $labels.pod }} CPU throttling > 5%"

      # 内存使用超过 limit 90%(OOM 临近)
      - alert: MemoryNearLimit
        expr: |
          container_memory_working_set_bytes{container!=""}
            / container_spec_memory_limit_bytes > 0.90
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Pod {{ $labels.pod }} memory usage > 90% of limit"

[!tip] 告警原则

  • P0 (page at 3am):直接影响 SLO,必须人工介入。如 HighErrorRate_Critical
  • P1 (page during work hours):SLO 有风险但非紧急
  • P2 (ticket):趋势异常但不影响可用性
  • 告警必须可行动——如果凌晨接到告警不知道该做什么,这条告警就不该存在

1.5 Kube-Prometheus-Stack 部署

# kube-prometheus-stack values 关键配置
prometheus:
  prometheusSpec:
    # 外部标签——跨集群标识
    externalLabels:
      cluster: prod-k8s-01
      region: ap-guangzhou
    # 保留时间
    retention: 30d
    retentionSize: 80GB
    # 采集间隔
    scrapeInterval: 30s
    evaluationInterval: 30s
    # 远程写入——长期存储
    remoteWrite:
      - url: "http://mimir-distributor.mimir:9009/api/v1/push"
    # 高基数保护
    enforcedLabelLimit: 20
    enforcedLabelNameLengthLimit: 50
    enforcedLabelValueLengthLimit: 200

  # 额外的 ServiceMonitor
  additionalServiceMonitors:
    - name: care-mate-api
      selector:
        matchLabels:
          app: care-mate-api
      endpoints:
        - port: metrics
          interval: 30s
          path: /metrics

grafana:
  # 预置 Dashboard 数据源
  additionalDataSources:
    - name: Loki
      type: loki
      url: http://loki-gateway.loki:80
    - name: Tempo
      type: tempo
      url: http://tempo-query-frontend.tempo:3100
      jsonData:
        tracesToLogsV2:
          datasourceUid: loki
          tags: ['job', 'instance', 'pod', 'namespace']

二、Logs 日志:结构化规范 + Go 最佳实践

2.1 Go 结构化日志——Zap 封装

package logger

import (
    "context"
    "go.opentelemetry.io/otel/trace"
    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
    "os"
)

var Log *zap.Logger

func Init(env string) {
    config := zap.NewProductionEncoderConfig()
    config.TimeKey = "timestamp"
    config.EncodeTime = zapcore.ISO8601TimeEncoder
    config.EncodeLevel = zapcore.CapitalLevelEncoder

    var core zapcore.Core
    if env == "development" {
        // 本地开发:console 编码,debug 级别
        encoder := zapcore.NewConsoleEncoder(config)
        core = zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), zapcore.DebugLevel)
    } else {
        // 生产:JSON 编码,info 级别
        encoder := zapcore.NewJSONEncoder(config)
        core = zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), zapcore.InfoLevel)
    }

    Log = zap.New(core,
        zap.AddCaller(),
        zap.AddCallerSkip(1),       // 跳过 wrapper 层
        zap.AddStacktrace(zapcore.ErrorLevel),
        zap.Fields(
            zap.String("service", os.Getenv("SERVICE_NAME")),
            zap.String("version", os.Getenv("APP_VERSION")),
        ),
    )
}

// Ctx 从 context 提取 trace_id 自动注入日志
func Ctx(ctx context.Context) *zap.Logger {
    span := trace.SpanFromContext(ctx)
    if !span.SpanContext().IsValid() {
        return Log
    }
    return Log.With(
        zap.String("trace_id", span.SpanContext().TraceID().String()),
        zap.String("span_id", span.SpanContext().SpanID().String()),
    )
}

// 中间件自动注入 trace_id 到所有请求日志
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        logger := Ctx(ctx)

        logger.Info("request",
            zap.String("method", r.Method),
            zap.String("path", r.URL.Path),
            zap.String("user_agent", r.UserAgent()),
            zap.String("client_ip", r.RemoteAddr),
        )

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

2.2 日志规范——必须包含的字段

[!important] 每条生产日志必备

字段说明示例
timestampISO 8601 格式2026-08-04T12:00:00Z
levelDEBUG/INFO/WARN/ERROR/FATALERROR
service服务名care-mate-api
trace_idOTel TraceID0af7651916...
message人类可读描述order creation failed
errorerror.Error() 原文(仅 ERROR)dial tcp 10.0.1.5:3306: timeout

反模式

  • log.Printf("user %d created order") ——无结构、无 trace_id
  • 到处打 Info("processing...") ——信息量为零
  • 错误日志不带上下文(哪个用户、哪个请求)

2.3 Loki 部署与查询

# Loki single-binary 模式(< 10GB/day 适用)
loki:
  auth_enabled: false
  storage:
    type: s3
    s3:
      endpoint: oss-cn-guangzhou.aliyuncs.com
      bucketnames: loki-data
      region: cn-guangzhou
  limits_config:
    # 限制单条日志大小
    max_entries_limit_per_query: 5000
    # 限制查询时间范围
    max_query_length: 721h      # 30d
    max_query_parallelism: 32

# Promtail 采集配置
promtail:
  config:
    clients:
      - url: http://loki-gateway.loki/loki/api/v1/push
    scrape_configs:
      - job_name: kubernetes-pods
        kubernetes_sd_configs:
          - role: pod
        pipeline_stages:
          # 从 Pod 注解提取 pipeline
          - cri: {}
          # 添加 k8s 元数据标签
          - labels:
              namespace: ""
              pod: ""
              container: ""
# LogQL 十大查询

# 1. 按 trace_id 查找全链路日志
{service="care-mate-api"} |= "0af7651916cd43dd8448eb211c80319c"

# 2. ERROR 级别日志(最近 1h)
{service=~"care-mate-.*"} | level="ERROR" | line_format "{{.timestamp}} {{.message}}"

# 3. 慢请求日志(> 3s)
{service="care-mate-api"} | json | duration > 3000

# 4. 统计每分钟 ERROR 数
sum(count_over_time({service="care-mate-api"} | level="ERROR" [1m]))

# 5. 某 Pod 的错误日志聚合(按错误信息分组)
sum by (error) (
    count_over_time({pod="care-mate-api-7d4f-abc"} | level="ERROR" | json error="error" [15m])
)

# 6. 某个端点的请求量趋势
sum(count_over_time({service="care-mate-api"} |= "/api/orders" [5m]))

# 7. 搜索特定错误消息
{service=~"care-mate-.*"} |~ "(?i)connection.*(refused|timeout)"

# 8. 去掉健康检查噪音
{service="care-mate-api"} != "/health" != "/metrics"

# 9. 只看数据库相关日志
{namespace="production"} |= "mysql" or "postgres"

# 10. 关联 Metrics:按 trace_id 定位到 Tempo trace
# 在 Grafana 中日志面板自动提供 "Tempo" 链接按钮

三、Traces 链路追踪:OTel Go SDK 完整接入

3.1 Go 服务端初始化

package telemetry

import (
    "context"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
    "time"
)

func InitTracer(ctx context.Context, serviceName, otelEndpoint string) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint(otelEndpoint),
        otlptracegrpc.WithInsecure(), // 内网用 insecure
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter,
            sdktrace.WithMaxExportBatchSize(512),
            sdktrace.WithBatchTimeout(5*time.Second),
        ),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion("1.0.0"),
            semconv.DeploymentEnvironment("production"),
            semconv.K8SNamespaceName("production"),
        )),
        // 采样策略——见 3.3 节
        sdktrace.WithSampler(sdktrace.ParentBased(
            sdktrace.TraceIDRatioBased(0.10), // 10% 采样
        )),
    )

    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    return tp, nil
}

3.2 HTTP/gRPC 自动埋点

import (
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
    "net/http"
)

// HTTP 服务端:包裹 Handler
mux := http.NewServeMux()
mux.Handle("/api/orders", otelhttp.NewHandler(orderHandler, "CreateOrder"))

// HTTP 客户端:自动传播 trace context
client := &http.Client{
    Transport: otelhttp.NewTransport(http.DefaultTransport),
}

// gRPC 服务端
s := grpc.NewServer(
    grpc.StatsHandler(otelgrpc.NewServerHandler()),
)

// gRPC 客户端
conn, _ := grpc.Dial(target,
    grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)

// MySQL/Redis 自动埋点
import (
    "github.com/uptrace/opentelemetry-go-extra/otelgorm"
    "github.com/go-redis/redis/extra/redisotel/v9"
)
// GORM: db.Use(otelgorm.NewPlugin())
// Redis: rdb.AddHook(redisotel.NewTracingHook())

3.3 采样策略——四层决策

请求到达 → AlwaysSample(如:带 "debug=true" header)
          → NeverSample(如:/health /metrics)
          → Head-based:TraceIDRatioBased(10%)
          → Tail-based:OTel Collector 端判断(慢/错 → 保留)
# OTel Collector tail-based sampling
processors:
  tail_sampling:
    decision_wait: 30s          # 等 30s 让所有 span 到齐
    num_traces: 50000           # 内存中保持的 trace 数量
    policies:
      # 策略 1:所有错误的 trace 保留
      - name: errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      # 策略 2:延迟 > 1s 的 trace 保留
      - name: slow
        type: latency
        latency: {threshold_ms: 1000}
      # 策略 3:其余按 10% 采样
      - name: default
        type: probabilistic
        probabilistic: {sampling_percentage: 10}

3.4 自定义 Span——业务关键路径

func (s *OrderService) Create(ctx context.Context, req *CreateOrderReq) (*Order, error) {
    // Top-level span
    ctx, span := otel.Tracer("order-service").Start(ctx, "OrderService.Create")
    defer span.End()

    span.SetAttributes(
        attribute.String("order.source", req.Source),
        attribute.Int("order.item_count", len(req.Items)),
    )

    // 校验库存——子 Span
    if err := s.checkInventory(ctx, req.Items); err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, "inventory check failed")
        return nil, err
    }

    // 扣减库存
    ctx, deductSpan := otel.Tracer("order-service").Start(ctx, "InventoryService.Deduct")
    defer deductSpan.End()

    if err := s.inventory.Deduct(ctx, req.Items); err != nil {
        deductSpan.RecordError(err)
        deductSpan.SetStatus(codes.Error, "deduct failed")
        return nil, err
    }
    deductSpan.SetAttributes(attribute.Int("deduct.duration_ms", 45))

    // 创建订单——Event 记录关键节点
    order, err := s.repo.Save(ctx, req)
    if err != nil {
        span.RecordError(err)
        return nil, err
    }
    span.AddEvent("order.created", trace.WithAttributes(
        attribute.String("order.id", order.ID),
    ))

    return order, nil
}

3.5 Tempo 查询——TraceQL

# TraceQL 查询模板

# 1. 按服务名 + 错误状态查找慢 trace
{ resource.service.name = "care-mate-api" && status = error }

# 2. 查找延迟 > 2s 的 trace
{ span.duration > 2s && name =~ "OrderService.*" }

# 3. 查找调用某个下游的所有 trace
{ resource.service.name = "care-mate-api" && span.http.target = "/api/orders" }

# 4. 查找包含特定属性的 trace
{ span.db.system = "mysql" && span.db.operation = "select" }

# 5. 链路结构查询——找出调用链含 4 层以上的
{ span.service.name = "care-mate-api" } | select( count() > 4 )

# 6. 比对两次发布之间的 P99 延迟变化
{ span.http.route = "/api/orders" && span.startTime > "2026-08-04T10:00:00Z" }
  | rate() by (span.http.status_code)

四、Profiles 持续剖析

4.1 Go pprof 生产环境接入

import (
    "net/http"
    _ "net/http/pprof"
    "runtime"
    "github.com/felixge/fgprof"
)

// 启动 pprof HTTP 端点(建议内网端口)
go func() {
    http.DefaultServeMux.Handle("/debug/fgprof", fgprof.Handler())
    // runtime.SetMutexProfileFraction(1)  // 开启 mutex profiling
    // runtime.SetBlockProfileRate(1)       // 开启 block profiling
    http.ListenAndServe(":6060", nil)
}()

// 手动采集 heap profile
func dumpHeap() {
    f, _ := os.Create(fmt.Sprintf("heap_%d.pprof", time.Now().Unix()))
    runtime.GC()
    pprof.WriteHeapProfile(f)
    f.Close()
}

4.2 Pyroscope eBPF 零侵入(推荐)

# Pyroscope agent DaemonSet
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: pyroscope-agent
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: pyroscope-agent
  template:
    metadata:
      labels:
        app: pyroscope-agent
    spec:
      hostPID: true
      containers:
        - name: agent
          image: grafana/pyroscope:latest
          args:
            - "ebpf"
            - "--application-cache-size=500"
          env:
            - name: PYROSCOPE_SERVER_ADDRESS
              value: "http://pyroscope-server:4040"
          securityContext:
            privileged: true
          volumeMounts:
            - name: sys
              mountPath: /sys
      volumes:
        - name: sys
          hostPath:
            path: /sys

[!tip] 何时用 Profiles

  • CPU 飙升但 metrics 看不出具体哪里 → Flamegraph
  • 内存持续增长→ heap profile 找泄漏对象
  • Goroutine 数量异常 → goroutine profile 找阻塞/泄漏
  • GC 频繁 → alloc profile 找高频分配点

五、Events 变更事件

5.1 K8s Events Watcher(Go)

import (
    "context"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
)

func WatchEvents(clientset *kubernetes.Clientset) {
    watcher, _ := clientset.CoreV1().Events("").Watch(context.Background(), metav1.ListOptions{})

    for event := range watcher.ResultChan() {
        ev := event.Object.(*corev1.Event)

        // 只关注 Warning 级别的事件
        if ev.Type != "Warning" {
            continue
        }

        logger.Log.Error("k8s event",
            zap.String("namespace", ev.Namespace),
            zap.String("name", ev.InvolvedObject.Name),
            zap.String("kind", ev.InvolvedObject.Kind),
            zap.String("reason", ev.Reason),
            zap.String("message", ev.Message),
            zap.Time("first_timestamp", ev.FirstTimestamp.Time),
        )
    }
}

5.2 变更事件采集矩阵

事件类型采集方式字段
代码部署CI/CD webhook → Lokideploy_id, git_commit, service, timestamp
配置变更ConfigMap/Secret 变更事件configmap_name, old_hash, new_hash, timestamp
扩缩容HPA/KEDA 事件deployment, old_replicas, new_replicas, reason
基础设施变更Terraform state / IaCresource, action(create/update/delete), timestamp
依赖变更服务发现变更(Nacos/K8s Service)service, old_endpoints, new_endpoints

[!important] 变更关联排障 70% 的故障由变更引入。Grafana 中用 Annotations 功能把部署事件标记在监控面板上——看到延迟尖峰对应的一条 deploy 线,问题就定位了一半。


六、RUM 真实用户监控

6.1 前端接入 Grafana Faro

<script src="https://cdn.jsdelivr.net/npm/@grafana/faro-web-sdk/dist/faro-web-sdk.iife.js"></script>
<script>
  GrafanaFaroWebSdk.initializeFaro({
    url: 'https://faro-collector.example.com/collect',
    app: {
      name: 'care-mate-web',
      version: '1.2.3',
      environment: 'production',
    },
    sessionTracking: {
      enabled: true,
      samplingRate: 0.1,
    },
    instrumentations: [
      new GrafanaFaroWebSdk.WebVitalsInstrumentation(),
      new GrafanaFaroWebSdk.ErrorsInstrumentation(),
      new GrafanaFaroWebSdk.ConsoleInstrumentation({ disabledLevels: ['debug'] }),
    ],
    // 把前端 trace 和后端 trace 关联
    propagateTraceHeaderCorsUrls: [/.*/],
  });
</script>

6.2 RUM 核心指标

指标含义SLO 建议
LCP (Largest Contentful Paint)最大内容渲染< 2.5s
FID/INP (Interaction to Next Paint)交互延迟< 200ms
CLS (Cumulative Layout Shift)布局偏移< 0.1
TTFB (Time to First Byte)首字节时间< 800ms
JS Error RateJS 报错率< 0.1%
API Error RateAPI 调用错误率< 0.5%

相关笔记