小服务也需要知道自己发生了什么
可观测性听起来像大系统话题,容易让初学者联想到复杂的指标平台、分布式追踪和日志集群。其实对一个 Go 小服务来说,最基础的可观测性很朴素:服务是否还活着?请求进来了多少?哪些请求失败?外部调用耗时多久?启动时用了什么配置?
如果这些信息都没有,服务出问题时只能靠猜。Google SRE 提出了可观测性的三大支柱:Metrics(指标)、Logs(日志)和 Traces(链路追踪)。三者从不同维度提供服务运行状态的全景视图。
Go 标准库已经足够做出基础版本:健康检查接口、请求日志、简单计数器、错误日志和启动摘要。等项目变大,再接 Prometheus 、OpenTelemetry 或日志平台也不迟。
这篇文章从标准库基础开始,逐步深入到 Prometheus 指标、结构化日志和 OpenTelemetry 链路追踪,提供一条渐进式的可观测性建设路径。
健康检查与存活探测
健康检查是最基础的可观测性能力。Kubernetes 等容器编排平台通过健康检查决定流量是否路由到某个实例。
基础存活检查(liveness):
package main
import (
"encoding/json"
"net/http"
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func readinessHandler(w http.ResponseWriter, r *http.Request) {
// 检查数据库连接等依赖
if !checkDatabase() {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"status": "not ready",
"reason": "database unavailable",
})
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ready",
})
}
func checkDatabase() bool {
// 实际检查数据库连接状态
return true
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
mux.HandleFunc("/readyz", readinessHandler)
http.ListenAndServe(":8080", mux)
}
健康检查应该轻量,不要每次都做昂贵操作。基础存活检查返回 ok 即可。liveness 失败意味着进程应该重启,readiness 失败意味着流量不应该路由到这个实例。两者职责不同,应该分开。
结构化请求日志
日志是从一行行文本开始的,但随着规模增长,结构化日志变得至关重要。
标准库的请求日志中间件:
package main
import (
"log/slog"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request completed",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
slog.String("remote_addr", r.RemoteAddr),
slog.String("user_agent", r.UserAgent()),
)
})
}
}
日志字段要稳定。即使用普通 log.Printf,也可以写成 key=value 格式,方便搜索。结构化日志的优势在于:可以精确搜索某个字段、可以做日志聚合分析、可以与监控系统联动。
Prometheus Metrics 指标设计
Prometheus 是云原生时代最常用的指标采集系统。Go 社区有成熟的 Prometheus 客户端库。
package main
import (
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
requestCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
},
[]string{"method", "path", "status"},
)
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path"},
)
activeConnections = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "http_active_connections",
Help: "Number of active HTTP connections.",
},
)
)
func init() {
prometheus.MustRegister(requestCounter, requestDuration, activeConnections)
}
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
activeConnections.Inc()
defer activeConnections.Dec()
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
duration := time.Since(start).Seconds()
requestCounter.WithLabelValues(r.Method, r.URL.Path, http.StatusText(rec.status)).Inc()
requestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
})
}
func main() {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8080", mux)
}
常用指标类型及其适用场景:
| 类型 | 用途 | 示例 |
|---|---|---|
| Counter | 只增不减的计数器 | 请求总数、错误总数 |
| Gauge | 可增可减的瞬时值 | 活跃连接数、队列长度 |
| Histogram | 分布统计 | 请求耗时分布 |
| Summary | 分位数统计 | P99 延迟 |
指标设计原则:
- 基数控制:标签值组合不要太多,避免高基数问题
- 命名规范:
namespace_subsystem_metric_name - 单位明确:
_total、_seconds、_bytes后缀 - Help 字段:清晰说明指标含义
使用 zap 实现结构化日志
zap 是 Uber 开发的高性能结构化日志库,在 Go 社区广泛使用。
package main
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewLogger() (*zap.Logger, error) {
config := zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
config.EncoderConfig.StacktraceKey = "stacktrace"
logger, err := config.Build(
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
)
if err != nil {
return nil, err
}
return logger, nil
}
func main() {
logger, _ := NewLogger()
defer logger.Sync()
// 基础日志
logger.Info("service started",
zap.String("version", "1.0.0"),
zap.String("env", "production"),
zap.Int("port", 8080),
)
// 错误日志带上下文
logger.Error("database connection failed",
zap.String("host", "db.example.com"),
zap.Int("port", 5432),
zap.String("database", "myapp"),
zap.Error(err),
)
}
zap 的核心优势是高性能(零分配 JSON 编码)和强类型字段。与标准库 slog 相比,zap 在性能上更优,slog 则随 Go 标准库附带,无需额外依赖。
OpenTelemetry Tracing 接入
分布式链路追踪帮助理解请求在多个服务间的流转过程。OpenTelemetry 是当前行业标准。
package main
import (
"context"
"fmt"
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("example.com/myapp")
func initTracer() (*sdktrace.TracerProvider, error) {
exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
provider := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("my-service"),
semconv.ServiceVersion("1.0.0"),
)),
sdktrace.WithBatcher(exporter),
)
otel.SetTracerProvider(provider)
return provider, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "handle-request",
trace.WithAttributes(attribute.String("path", r.URL.Path)))
defer span.End()
// 模拟数据库查询
if err := queryDatabase(ctx); err != nil {
span.RecordError(err)
http.Error(w, "database error", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "ok")
}
func queryDatabase(ctx context.Context) error {
_, span := tracer.Start(ctx, "query-database",
trace.WithAttributes(attribute.String("db.table", "users")))
defer span.End()
// 实际查询逻辑
return nil
}
OpenTelemetry 的优势在于统一了日志、指标和追踪的 API,支持多种后端(Jaeger、Zipkin、Tempo 等)。
Grafana 看板配置基础
采集了指标和日志后,需要可视化展示。Grafana 是最常用的开源监控看板工具。一个基础的 HTTP 服务看板通常包含以下面板:
- 请求速率(每秒请求数)
- 错误率(错误请求占比)
- 延迟分布(P50、P95、P99)
- 活跃连接数
- CPU / 内存使用率
PromQL 查询示例:
# 每秒请求速率
rate(http_requests_total[5m])
# 错误率
rate(http_requests_total{status=~"5.."}[5m])
# P95 延迟
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
# 各路径请求量
topk(10, sum by (path) (rate(http_requests_total[5m])))
告警规则设计
采集了指标之后,需要配置合理的告警规则。告警规则应该遵循少而精的原则,避免告警疲劳。
推荐的告警:
| 名称 | 规则 | 优先级 |
|---|---|---|
| 服务不可用 | 健康检查失败连续 2 分钟 | P0 |
| 高错误率 | 5 分钟错误率超过 5% | P1 |
| 高延迟 | P99 延迟超过 2s | P1 |
| 请求量异常 | 请求量较昨日同时段下降 50% | P2 |
| 资源使用高 | CPU > 80% 或 内存 > 85% | P2 |
Alertmanager 配置示例:
groups:
- name: http_alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
for: 2m
labels:
severity: warning
annotations:
summary: "High error rate detected"
description: "Error rate is above 5% for 2 minutes"
分布式链路追踪实战
在微服务架构中,单个请求可能经过多个服务。链路追踪可以帮助理解请求的全生命周期。
请求传播上下文的核心是 Trace ID 和 Span ID:
- Trace ID:标识一个完整的请求链路
- Span ID:标识链路中的一个操作片段
- Parent Span ID:标识当前片段的父片段
在 HTTP 请求头中传递追踪上下文:
// 发送请求时注入追踪上下文
func callService(ctx context.Context, url string) error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
// 注入追踪头到 HTTP 请求
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// 接收请求时提取追踪上下文
func receiveRequest(w http.ResponseWriter, r *http.Request) {
ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
ctx, span := tracer.Start(ctx, "receive-request")
defer span.End()
// 处理请求...
}
渐近式可观测性建设路径
可观测性建设不需要一步到位,可以根据项目阶段逐步完善:
阶段一:基础监控(上线初期)
- 健康检查接口(/healthz、/readyz)
- 基础请求日志(方法、路径、状态码、耗时)
- 简单的计数器指标(请求量、错误数)
- 进程级别的 CPU / 内存监控
阶段二:结构化增强(稳定运行期)
- 替换为结构化日志(zap / slog)
- 引入 Prometheus 指标
- 设置基础 Grafana 看板
- 配置关键告警规则
阶段三:深度可观测(规模化阶段)
- 接入 OpenTelemetry Tracing
- 分布式链路追踪
- 日志聚合(ELK / Loki)
- 自定义业务指标
- SLO / SLI 定义与监控
小结
Go 小服务的可观测性可以从四件事开始:健康检查、请求日志、简单指标、启动摘要。它们不复杂,但能显著降低排查成本。等项目复杂后,再引入结构化日志、Prometheus 指标和分布式追踪。
可观测性三支柱的适用范围:
| 支柱 | 解决的问题 | Go 方案 |
|---|---|---|
| Metrics | 系统整体趋势和异常 | Prometheus + Grafana |
| Logs | 请求级事件的详细信息 | zap / slog / ELK |
| Traces | 请求在分布式系统中的流转路径 | OpenTelemetry |
可观测性不是上线后补救才做的事情。你在写第一个 HTTP 服务时,就可以把这些基础点放进去,让服务从一开始就更容易理解和维护。记住一个原则:如果生产环境出了问题,你应该能在 5 分钟内定位到故障原因,这就是可观测性的价值所在。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。