《Go 语言编程入门》16.3 健康检查与指标端点

给 TaskAPI 做规范的健康检查与指标端点:区分 liveness 与 readiness、用 atomic 计数与计时中间件暴露 Prometheus 文本格式的 /metrics、设计 /healthz 与 /readyz 的语义边界,并给出探针配置与安全暴露的取舍。

本节把 TaskAPI 推进到「能被编排系统管理」:补上 /healthz、/readyz、/metrics 三个端点,让 Kubernetes 探针知道何时重启、何时放流量,让监控系统能画出请求量与耗时曲线。
适用版本:Go 1.27(实测 go1.27.0)。

16.3 健康检查与指标端点

pprof 与 expvar 面向「出问题时人工排查」。编排系统和监控系统需要的则是机器可读的固定契约:探针按固定间隔访问健康端点决定是否重启或放流量,监控按固定格式抓取指标。本节把这三个契约端点做进 TaskAPI。

16.3.1 liveness 与 readiness 不是一回事

初学者常把两者混成一个 /health。它们回答的是不同问题:

端点问题失败后果依赖检查
/healthz(liveness)进程还活着吗?编排系统重启容器只检查自身,不查依赖
/readyz(readiness)现在能收流量吗?从负载均衡摘除,不重启检查数据库等关键依赖

关键区别:liveness 绝不能去 ping 数据库。如果数据库抖一下,liveness 失败会导致容器被反复重启,把一次依赖故障放大成雪崩。liveness 只应回答「我这个进程有没有卡死」。

16.3.2 /healthz:永远轻量

liveness 要的就是「无脑返回 200」:

mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "ok")
})

实测响应:

$ curl -s -i http://127.0.0.1:6061/healthz
HTTP/1.1 200 OK
Content-Length: 3
Content-Type: text/plain; charset=utf-8

它不碰任何外部资源,永远在微秒级返回。如果进程真的死锁了,连这个 handler 都调度不到,探针超时,编排系统才重启——这正是期望行为。

16.3.3 /readyz:可撤销的就绪状态

readiness 会随运行状态变化:进程刚启动时依赖还没连上,不该收流量;运行中依赖断了,也要临时摘除。用一个原子布尔量表达:

var ready atomic.Bool

mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
	if !ready.Load() {
		http.Error(w, "not ready", http.StatusServiceUnavailable)
		return
	}
	fmt.Fprintln(w, "ready")
})

返回 503 表示「暂时别给我流量」,编排系统只是摘除、不会重启。等依赖恢复后 ready.Store(true),下一轮探针就把它加回来。用 atomic.Bool(第 11 章)是因为探针 goroutine 与主 goroutine 并发读写它。

readiness 还要和优雅关闭联动(第 12 章)。收到 SIGTERM 后、Shutdown 排空前,应该先把 ready 置回 false,让负载均衡停止发新请求,再慢慢排空在途请求:

<-ctx.Done()               // 收到 SIGTERM
ready.Store(false)         // 先摘流量
_ = httpSrv.Shutdown(shutdownCtx) // 再排空

这个顺序很关键:先摘流量再排空,能避免「关了一半还在收新请求」。第 17.3 节的零停机重启会把这个顺序用到极致。

如果健康端点要给运维看更多上下文(版本、启动时间、依赖名),可以返回 JSON 而不是纯文本:

func readyz(w http.ResponseWriter, r *http.Request) {
	if !ready.Load() {
		writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ready": false})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ready": true, "version": version})
}

但探针只关心状态码,JSON body 是给人看的,两者不冲突。

16.3.4 指标中间件:计数与计时

/metrics 要暴露的典型指标是「处理了多少请求」和「总共花了多少时间」。用一个中间件包住业务 handler,在请求前后打点:

var (
	reqTotal   atomic.Int64
	reqSeconds atomic.Int64 // 累计耗时(毫秒)
)

func instrument(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		reqTotal.Add(1)
		reqSeconds.Add(time.Since(start).Milliseconds())
	})
}

这就是第 13.3 节中间件思想的一个具体应用:横切关注点(计数、计时)与业务逻辑分离。atomic.Int64 让自增无锁,符合第 11 章「能用原子量就别上锁」的原则。

16.3.5 Prometheus 文本格式

监控系统认的是 Prometheus 的文本 exposition 格式。核心规则:每个指标前有两行元数据,# HELP 是说明、# TYPE 是类型,然后才是 指标名 值:

func metrics(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
	total := reqTotal.Load()
	secs := float64(reqSeconds.Load()) / 1000.0
	fmt.Fprintf(w, "# HELP http_requests_total Total requests handled.\n")
	fmt.Fprintf(w, "# TYPE http_requests_total counter\n")
	fmt.Fprintf(w, "http_requests_total %d\n", total)
	fmt.Fprintf(w, "# TYPE http_request_duration_seconds_sum counter\n")
	fmt.Fprintf(w, "http_request_duration_seconds_sum %.3f\n", secs)
	fmt.Fprintf(w, "# TYPE taskapi_ready gauge\n")
	fmt.Fprintf(w, "taskapi_ready %d\n", b2i(ready.Load()))
}

三种基本类型要分清:counter 只增不减(请求总数)、gauge 可上可下(就绪状态、队列深度)、histogram 分桶统计(延迟分布)。本节为了不引第三方库,用 atomic.Int64 手工累加 counter;第 18 章会讨论什么时候该上 prometheus/client_golang。

延迟指标如果只看总和,是看不出分布的——平均 50ms 里可能混着一堆 500ms 的慢请求。要暴露分布就得自己分桶,把落在每个区间里的请求数记下来:

var buckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1}
var bucketCounts [9]atomic.Int64 // 末位是 +Inf 桶

func observe(seconds float64) {
	for i, b := range buckets {
		if seconds <= b {
			bucketCounts[i].Add(1)
			return
		}
	}
	bucketCounts[len(buckets)].Add(1) // 溢出到 +Inf
}

输出时把每个桶写成 http_request_duration_seconds_bucket{le="0.05"} 123 这样的累计值,监控端就能算出 P50、P99 分位。这就是 histogram 类型在手工实现下的样子——也正因如此,第 18 章会建议真上生产时直接引入成熟的 Prometheus 客户端库。

16.3.6 实测:跑起来看指标

把三个端点组装进一个小服务,instrument 包在最外层,访问几次 /tasks 后再抓 /metrics:

$ for i in 1 2 3; do curl -s -o /dev/null http://127.0.0.1:6061/tasks; done
$ curl -s http://127.0.0.1:6061/metrics
# HELP http_requests_total Total requests handled.
# TYPE http_requests_total counter
http_requests_total 5
# TYPE http_request_duration_seconds_sum counter
http_request_duration_seconds_sum 0.017
# TYPE taskapi_ready gauge
taskapi_ready 1

http_requests_total 是 5:3 次 /tasks 加上前面的 /healthz、/readyz——因为中间件包住了整个 mux,所有请求都被计数。这通常不是我们想要的,第 16.3.7 节会讲怎么只统计业务路由。http_request_duration_seconds_sum 是 0.017 秒,对应 3 次各约 5ms 的 /tasks 加上健康检查的零耗时。

16.3.7 只统计业务路由

把 instrument 从最外层挪到只包业务子 mux,健康检查与指标端点自己不计入:

func buildHandler() http.Handler {
	business := http.NewServeMux()
	business.HandleFunc("GET /tasks", s.list)
	// ... 其余业务路由 ...

	root := http.NewServeMux()
	root.Handle("/tasks", instrument(business))
	root.HandleFunc("/healthz", healthz)
	root.HandleFunc("/readyz", readyz)
	root.HandleFunc("/metrics", metrics)
	return root
}

这样 /metrics 里的 http_requests_total 就只反映真实业务流量。另一种常见做法是给指标打上 path 标签做维度拆分,但那会引入标签基数爆炸的风险——/tasks/{id} 里的 ID 不能当标签值,否则每个任务都生成一条时间序列。

16.3.8 探针与安全配置

在 Kubernetes 里,两类探针分别指向两个端点:

livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /readyz, port: 8080 }
  periodSeconds: 5
  failureThreshold: 1

参数含义要记牢:periodSeconds 是探测间隔,failureThreshold 是连续失败几次才判定,initialDelaySeconds 是启动宽限。readiness 的 failureThreshold 通常设 1(快速摘除),liveness 设 3(避免抖动误杀)。

安全边界:/metrics 会泄露路由结构、QPS、耗时等内部信息,应绑定内网端口或加鉴权,不要和业务端口一起裸奔公网。pprof 同理。把观测端点放在独立的、只对内网开放的监听地址上,是最省事的做法。

16.3.9 常见坑

  • liveness 查依赖:最常见的生产事故来源,务必只检查自身。
  • /metrics 也走中间件:导致指标自我污染,要单独排除。
  • 标签用高基数值:用户 ID、请求 ID 做标签会让监控存储爆炸。
  • 探针路径写错:端口对但路径 404,会被当成失败反复重启,部署前务必 curl 一次。
  • readiness 启动即真:依赖没连上就先置 true,会放进来一批必然失败的请求。
  • 指标名不带前缀:和运行时库的指标撞名,加 taskapi_ 前缀最省心。
  • 忘了关指标端点:本地开发随手暴露 /metrics,上线忘删,等于把内网拓扑图挂到公网。
  • 健康端点做了慢查询:探针每 5 秒一次,handler 里跑一次全表统计会把数据库拖垮。

小结

  • liveness 只回答「进程活着吗」,失败即重启;readiness 回答「能收流量吗」,失败只摘除。二者绝不能混。
  • 指标用中间件在请求前后打点,atomic 累加,零锁开销。
  • /metrics 输出 Prometheus 文本格式:# HELP + # TYPE + 指标名 值;counter / gauge / histogram 三种类型要分清。
  • 观测端点要绑内网、加鉴权;探针路径与阈值上线前必须实测。

到这里 TaskAPI 已经「可观测」了:有结构化日志、有 pprof、有健康检查与指标。但它还只是本机上的一个进程。下一章我们把它交叉编译成 Linux 二进制、打成最小 Docker 镜像、写部署脚本,让它真正能上服务器。

阅读导航:上一节:16.2 pprof 与 expvar 看运行时 · 下一节:17.1 交叉编译与静态构建 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练