Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第28章 可观测性与安全(重点)

可观测性不是“多打日志”,安全也不是发布前跑一次扫描。两者都要求在设计阶段控制数据边界、资源上限和诊断入口。

生产 Go 服务至少需要四类信号:日志、指标、追踪和 profile。它们回答的问题不同:

信号适合回答
日志某次离散事件发生了什么
指标系统整体是否健康、趋势如何
追踪一次请求跨服务花在了哪里
Profile/Trace进程内部 CPU、内存、锁和调度为何异常

28.1 结构化日志

Go 1.21 的 log/slog 提供标准化结构化日志:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})
logger := slog.New(handler).With(
    slog.String("service", "orders"),
    slog.String("version", buildVersion),
)

logger.InfoContext(ctx, "order accepted",
    slog.String("order_id", order.ID),
    slog.Duration("latency", latency),
)

字段名应稳定并有明确类型。请求 ID、trace ID 等高基数值适合放日志而不适合做指标 label,但仍要有长度、格式和隐私约束。不要把完整请求体、Authorization、cookie、密码、token、私钥或个人敏感数据写入日志。

错误只记录一次。底层库返回带上下文的 error,HTTP/worker 边界统一记录状态、请求 ID 和错误链,避免每层重复打印同一个错误。

28.2 Context 与日志关联

Context 可携带请求级关联标识,但不要把 logger 当作隐式业务依赖。一个可控做法是保留少量 helper:

type traceIDKey struct{}

func WithTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceIDKey{}, traceID)
}

func Log(ctx context.Context, logger *slog.Logger) *slog.Logger {
    traceID, _ := ctx.Value(traceIDKey{}).(string)
    if traceID == "" {
        return logger
    }
    return logger.With(slog.String("trace_id", traceID))
}

库 API 仍显式接收 logger 或返回 error。不要让所有函数从 context 抽取任意依赖。

28.3 指标类型

Prometheus 常用四类指标:

  • Counter:单调递增事件,如请求数、错误数。
  • Gauge:可升可降的当前值,如队列长度、连接数。
  • Histogram:服务端按桶统计分布,可聚合计算分位数。
  • Summary:客户端计算分位数,跨实例通常不可正确聚合。

延迟通常使用 Histogram。桶要覆盖 SLO 边界,而不是机械使用默认桶:

requestDuration := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_server_request_duration_seconds",
        Help:    "Request handling latency.",
        Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5},
    },
    []string{"method", "route", "status_class"},
)

route 使用模板 /users/{id},不能使用真实 URL;状态码可按 2xx/4xx/5xx 聚合。

28.4 标签基数预算

时间序列数量近似为每个标签取值数量的乘积。以下值禁止直接作为 label:

  • user ID、order ID、request ID、trace ID
  • 原始 URL、SQL、错误消息
  • 无上限的容器名、文件名或对端地址

高基数值放日志或 trace。每个新指标在评审时写出预期 series 上限、生命周期和删除策略。

自定义 Collector 的 DescribeCollect 可能被并发调用,必须线程安全且快速。耗时外部查询应后台刷新缓存,Collect 只读取有界快照。

28.5 分布式追踪

OpenTelemetry 是 Go 生态常用的追踪 API/SDK。服务边界至少传播 W3C Trace Context:

  • 入站 HTTP 提取 traceparent/tracestate
  • 创建 server span,把 context 传入业务层。
  • 出站 HTTP、数据库、消息调用创建 child span 并注入上下文。
  • 错误记录语义状态,但不要把所有 4xx 都当内部错误。

Span 属性和事件同样受基数与隐私约束。采样决策应在入口统一,并明确 head sampling 与 tail sampling 的成本。不要为了“全链路”记录每个循环迭代或完整 payload。

28.6 Runtime Metrics

runtime/metrics 提供稳定程度高于直接解析 Runtime 私有结构的指标。读取前用 metrics.All() 确认名称和 Kind:

samples := []metrics.Sample{
	{Name: "/gc/heap/live:bytes"},
	{Name: "/gc/heap/goal:bytes"},
	{Name: "/sched/goroutines:goroutines"},
	{Name: "/sched/latencies:seconds"},
	{Name: "/sched/pauses/total/gc:seconds"},
	{Name: "/sync/mutex/wait/total:seconds"},
}
metrics.Read(samples)

常用观测组合:

问题指标或工具
GC CPU比较一段区间内 /cpu/classes/gc/total:cpu-seconds/cpu/classes/total:cpu-seconds 的增量
GC 暂停/sched/pauses/total/gc:seconds
堆目标与存活/gc/heap/goal:bytes/gc/heap/live:bytes
调度延迟/sched/latencies:seconds
Mutex 竞争/sync/mutex/wait/total:seconds
goroutine 数/sched/goroutines:goroutines

不要硬编码不存在的 /gc/cpu:percentage/gc/pause:ns/gc/pauses:seconds 在 Go 1.26 仍存在但已 deprecated,应使用内容相同的 /sched/pauses/total/gc:seconds。指标名称含单位;读取前检查 Value.Kind(),升级工具链时在测试中确认所需指标仍存在。

28.7 pprof 的生产边界

导入 _ "net/http/pprof" 会把诊断 handler 注册到 DefaultServeMux。生产服务不要把它随业务端口公开到互联网。

推荐方案:

  • 独立监听 loopback 或管理网络。
  • 使用专用 ServeMux,显式注册需要的 handler。
  • 在网关层做强认证和访问审计。
  • 限制并发 profile 请求和采集时长。
  • 不记录 profile URL 中的凭据。

CPU profile、trace、mutex/block profile 会增加不同程度开销。采集前记录基线和持续时间,采集后及时关闭提高的采样率。

Heap 和 goroutine profile 可能包含业务字符串、URL、路径和调用参数线索,按敏感诊断数据存储和传输。

28.8 Flight Recorder

Go 1.25 的 runtime/trace.FlightRecorder 可在内存中保留最近一段执行 trace,在异常发生时写出:

recorder := trace.NewFlightRecorder(trace.FlightRecorderConfig{
    MinAge:   30 * time.Second,
    MaxBytes: 32 << 20,
})
if err := recorder.Start(); err != nil {
    return err
}
defer recorder.Stop()

// 发生诊断事件时:recorder.WriteTo(destination)

它适合捕捉偶发调度停顿,但同一时刻至多启用一个 FlightRecorder。MaxBytes 控制保留窗口而非进程总内存或 WriteTo 输出大小的硬上限;仍要限制诊断触发频率、目标文件容量和访问权限。

28.9 健康检查

区分三类端点:

  • liveness:进程是否需要重启,只检查无法自愈的内部停滞。
  • readiness:是否应接收新流量,可包含关键依赖和排空状态。
  • startup:慢启动服务是否已完成初始化。

liveness 不要依赖所有下游,否则一次数据库故障会触发整个服务集群重启。readiness 失败应有去抖与明确恢复条件。

28.10 输入与资源上限

安全的第一层是让每个不可信输入有上限:

  • HTTP header/body、multipart 数量和解压后大小。
  • JSON/XML 嵌套深度和数组长度。
  • 正则表达式与用户控制查询复杂度。
  • goroutine、队列、连接池和重试预算。
  • 文件路径、符号链接和归档展开路径。

超时不是资源上限的替代品。一个请求在超时前仍可能分配大量内存或启动大量 goroutine。

28.11 文件与路径安全

不要用简单字符串前缀判断路径是否仍在根目录,符号链接和 .. 会绕过。Go 1.24 的 os.Root 为受限文件树提供文件操作:

root, err := os.OpenRoot(uploadDir)
if err != nil {
    return err
}
defer root.Close()

file, err := root.Open(userSuppliedName)

仍需限制文件类型、大小、数量和权限。os.Root 防止路径和符号链接逃出目录树,但不隔离 Unix 挂载点、bind mount、/proc 特殊文件或设备文件;高风险场景还需要容器/用户权限和文件系统挂载策略。上传文件名不应直接成为最终存储路径。

28.12 TLS、随机数与秘密

  • 安全 token 使用 crypto/rand,不用 math/rand
  • 使用当前 Go 默认 TLS 配置,除非有明确兼容要求,不重新启用旧协议和弱套件。
  • 证书验证错误不能通过 InsecureSkipVerify 永久绕过。
  • Secret 来自受控运行时注入,不写入源码、镜像、日志和 panic 页面。
  • 固定长度的 MAC/token 可用 subtle.ConstantTimeCompare;长度本身敏感或编码复杂时优先使用协议库提供的完整验证函数,因为长度不等时该函数会立即返回。
  • 密码只存成熟 KDF 的哈希,参数和迁移策略一并管理。

28.13 依赖与供应链

基础检查:

go mod verify
go vet ./...
govulncheck ./...

还应做到:

  • 审阅新增直接依赖及其维护状态、许可和发布记录。
  • 固定 CI action、工具和容器镜像版本;高风险环境固定到 commit/digest。
  • 保留构建使用的 Go 版本、模块图、VCS revision 和脏工作区状态。
  • 使用 -buildvcs=true 保留可追踪构建信息。
  • 定期升级,不把漏洞修复等到大版本迁移一起做。
  • 私有模块配置 GOPRIVATE,凭据不进入 go.mod 或构建日志。

扫描结果需要结合可达性和部署环境处置,但不能因“当前没调用”无限期忽略高危依赖。

28.14 事件响应最小闭环

  1. 告警包含服务、版本、环境、SLO 影响和 runbook。
  2. 用指标确定影响范围和开始时间。
  3. 用 trace/log 定位请求与依赖边界。
  4. 用 profile、runtime trace 或 flight recorder 验证进程内假设。
  5. 先止损,再保留证据和时间线。
  6. 修复后增加测试、指标或限制,避免只写事故总结。

本章小结

  • 日志、指标、追踪和 profile 解决不同问题,必须用稳定关联 ID 串联而不是相互替代。
  • 指标设计的核心是语义、单位和基数预算;请求 ID 等无界值不能做 label。
  • runtime/metrics 应使用真实存在且带单位的名称,pprof 与 trace 必须按敏感管理面保护。
  • 输入、并发、队列、重试和文件操作都要有资源上限。
  • govulncheck、模块校验、可追踪构建和持续升级共同构成供应链基础。

进一步阅读: