第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 的 Describe 和 Collect 可能被并发调用,必须线程安全且快速。耗时外部查询应后台刷新缓存,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 事件响应最小闭环
- 告警包含服务、版本、环境、SLO 影响和 runbook。
- 用指标确定影响范围和开始时间。
- 用 trace/log 定位请求与依赖边界。
- 用 profile、runtime trace 或 flight recorder 验证进程内假设。
- 先止损,再保留证据和时间线。
- 修复后增加测试、指标或限制,避免只写事故总结。
本章小结
- 日志、指标、追踪和 profile 解决不同问题,必须用稳定关联 ID 串联而不是相互替代。
- 指标设计的核心是语义、单位和基数预算;请求 ID 等无界值不能做 label。
- runtime/metrics 应使用真实存在且带单位的名称,pprof 与 trace 必须按敏感管理面保护。
- 输入、并发、队列、重试和文件操作都要有资源上限。
govulncheck、模块校验、可追踪构建和持续升级共同构成供应链基础。
进一步阅读: