本节把 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 交叉编译与静态构建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。