9、健康检查与心跳模式:健康检查端点设计、心跳机制、探活策略(TCP、HTTP、gRPC)、Kubernetes探针
各位同学,咱们今天聊一个看似简单、实则坑特别多的模式——健康检查与心跳。你想想看,一个服务部署上去,你怎么知道它还活着?是不是还在干活?还是说它已经“脑死亡”了,只是进程没挂?
我见过太多线上事故了。服务进程明明在,但线程池全堵死了,请求进来就超时。这时候如果你只看进程是否存活,那监控面板上一片绿,用户那边已经骂翻天了。所以,健康检查不是“有没有在运行”,而是“能不能正常服务”。
9.1 健康检查端点设计
先说最简单的做法——暴露一个 HTTP 端点,比如 /health。但这里有个细节:这个端点该检查什么?
我个人习惯把健康检查分成三个层次:
- Liveness(存活):进程是否活着。说白了就是“有没有挂”。
- Readiness(就绪):服务是否准备好接收流量。比如刚启动时还在加载缓存,这时候不应该放流量进来。
- Dependency(依赖):下游依赖是否正常。比如数据库、Redis、消息队列。
我在项目中遇到过一个问题:某个服务依赖的 Redis 集群挂了,但健康检查只检查了进程本身。结果网关还在往这个服务发请求,每个请求都超时,最终导致雪崩。从那以后,我所有的健康检查都会带上依赖检查。
一个典型的健康检查端点实现(Go 语言示例):
// 健康检查处理器
func HealthHandler(w http.ResponseWriter, r *http.Request) {
status := map[string]string{
"status": "ok",
"version": "1.2.3",
}
// 检查数据库
if err := db.Ping(); err != nil {
status["database"] = "unhealthy"
status["status"] = "degraded"
}
// 检查 Redis
if err := redis.Ping(); err != nil {
status["redis"] = "unhealthy"
// 不改变整体状态,因为可以降级
}
w.Header().Set("Content-Type", "application/json")
if status["status"] != "ok" {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(status)
}
9.2 心跳机制
健康检查是“别人来问我”,心跳是“我主动告诉别人我还活着”。这两种方式各有适用场景。
心跳机制通常用在集群管理、选主场景。比如 etcd、ZooKeeper 里的 session 保活,或者你自定义的分布式系统中,每个节点定期向协调者发送心跳。
这里有个关键参数:心跳间隔和超时时间。我见过有人把心跳间隔设成 1 秒,超时设成 2 秒。结果网络稍微抖动一下,整个集群就开始震荡——节点被频繁踢出再加入。嗯,这其实是个经典的反模式。
心跳消息的设计也很重要。不要只发一个“我还活着”的空包。我习惯在心跳里带上:
- 节点 ID
- 当前时间戳
- 负载指标(CPU、内存、连接数)
- 处理中的请求数
这样协调者不仅能知道节点是否存活,还能做负载均衡决策。
9.3 探活策略:TCP、HTTP、gRPC
说到探活,其实就是“怎么检查”。三种主流方式,各有优劣。
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TCP | 简单、轻量、无侵入 | 只能检查端口是否监听,无法判断业务状态 | 四层负载均衡、基础存活检查 |
| HTTP | 能检查业务逻辑,返回状态码和 body | 需要实现端点,有额外开销 | 七层负载均衡、Kubernetes 探针 |
| gRPC | 高效、支持流式、强类型 | 需要实现 gRPC 健康检查协议,调试稍麻烦 | 微服务间通信、高性能场景 |
TCP 探活是最基础的。说白了就是“三次握手能不能成功”。但你要注意,很多服务有“连接 backlog”限制。如果 backlog 满了,TCP 连接会被拒绝,但服务本身可能还在正常运行。这时候 TCP 探活会误报。
HTTP 探活是最常用的。Kubernetes 的 livenessProbe 和 readinessProbe 就是基于 HTTP 的。我建议你为每个服务实现两个端点:/healthz(存活)和 /readyz(就绪)。
gRPC 探活稍微复杂一点。gRPC 官方提供了一个健康检查协议(grpc.health.v1.Health),你只需要实现 Check 和 Watch 两个 RPC 方法。我个人觉得 gRPC 探活的好处是:它和你的业务 RPC 走同一个连接,能真实反映服务的处理能力。
// gRPC 健康检查实现(Go 语言)
type HealthServer struct {
mu sync.RWMutex
status map[string]healthpb.HealthCheckResponse_ServingStatus
}
func (s *HealthServer) Check(ctx context.Context, req *healthpb.HealthCheckRequest) (*healthpb.HealthCheckResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
status := s.status[req.Service]
if status == healthpb.HealthCheckResponse_UNKNOWN {
return &healthpb.HealthCheckResponse{
Status: healthpb.HealthCheckResponse_NOT_SERVING,
}, nil
}
return &healthpb.HealthCheckResponse{Status: status}, nil
}
9.4 Kubernetes 探针
Kubernetes 把健康检查玩出了花。它提供了三种探针:
- livenessProbe:检查容器是否存活。如果失败,Kubelet 会重启容器。
- readinessProbe:检查容器是否就绪。如果失败,Service 不会把流量转发过来。
- startupProbe:检查容器是否启动完成。用于启动慢的应用,防止被 livenessProbe 误杀。
这里有个常见的坑:很多人把 livenessProbe 和 readinessProbe 配置成一样的。你想想看,如果数据库挂了,livenessProbe 失败,Kubernetes 会重启容器。但重启能解决数据库问题吗?不能。结果就是容器反复重启,陷入 CrashLoopBackOff。
一个合理的 Kubernetes 探针配置示例:
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: my-app
image: my-app:1.0.0
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /startupz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30 # 给足启动时间
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 2
注意这里的 failureThreshold。我习惯把 livenessProbe 的阈值设大一点(3-5次),避免网络抖动导致误杀。readinessProbe 的阈值可以小一点(1-2次),因为就绪状态变化频繁一点没关系。
最后说一句:健康检查的日志一定要控制好。我见过有人每秒钟打印一次健康检查日志,一天下来几个 GB。建议健康检查的日志级别设为 DEBUG,或者干脆不打印。
这张图把今天的内容串起来了。从健康检查端点设计,到心跳机制,再到三种探活策略,最后落到 Kubernetes 探针。你把这个图记在脑子里,面试的时候也能讲得清清楚楚。
公众号:蓝海资料掘金营,微信deep3321