Consul健康检查必须与Gin的/health路由解耦,因其通过独立HTTP或gRPC调用,不经过Gin路由层;注册时Check字段不能为空,否则服务状态恒为Critical;/readyz需并发探测依赖并设独立超时,避免雪崩;查询服务必须启用PassingOnly=true,防止调用已下线实例。

Consul健康检查必须和Gin的/health路由解耦
Consul不会调用你的/health HTTP接口——它要么走HTTP GET(配Check.HTTP),要么走gRPC HealthCheck(配Check.GRPC),完全绕过Gin路由层。你写在Gin里的/health handler只对Kubernetes探针或人工curl有效,对Consul注册中心毫无意义。
常见错误是以为“我写了r.GET("/health", ...),Consul就能自动感知”,结果服务注册后一直显示Critical。根本原因是Consul Agent发起的是独立HTTP请求,它不走Gin中间件、不触发Gin上下文,甚至不经过Gin的mux。
- 若用HTTP方式:Consul会发GET到你配置的
Check.HTTP = "http://192.168.1.31:8080/healthz",这个路径必须能被Consul节点网络直连,且响应状态码为200 - 若用gRPC方式:必须提前注册
grpc_health_v1.RegisterHealthServer(),且Check.GRPC填的是"192.168.1.31:50051"这类可路由地址,不是"localhost:50051" - Gin的
/health可以保留,但仅用于K8slivenessProbe;Consul健康检查必须另起一套逻辑,或复用同一端点但确保它不依赖Gin中间件(如JWT、CORS)
Consul注册时Check字段不能为空,否则状态恒为Critical
用api.AgentServiceRegistration向Consul注册服务时,Check字段是强制项。漏掉它,Consul会认为“这个服务没提供任何健康验证手段”,直接标记为Critical,并按DeregisterCriticalServiceAfter倒计时下线。
正确写法必须显式构造api.AgentServiceCheck:
check := &api.AgentServiceCheck{
HTTP: fmt.Sprintf("http://%s:%d/healthz", localIP(), cfg.Port),
Timeout: "3s",
Interval: "15s",
DeregisterCriticalServiceAfter: "3m",
}
reg := &api.AgentServiceRegistration{
ID: srvID,
Name: srvName,
Address: localIP(),
Port: cfg.Port,
Check: check,
}
-
Timeout要略大于实际handler处理耗时(比如DB Ping加Redis Ping共耗时2.2s,这里设"3s") -
Interval="15s"和DeregisterCriticalServiceAfter="3m"是黄金组合:允许连续12次失败才下线,抗网络抖动 - 别把
DeregisterCriticalServiceAfter设成"30s"——GC STW或磁盘IO高峰时一次超时就下线,等于自废武功
Gin中/readyz必须并发探测依赖,不能只返回固定JSON
Kubernetes readinessProbe指向/readyz,它的语义是“这个实例现在能不能接真实流量”。如果还用c.JSON(200, gin.H{"status": "ok"}),数据库连接池已满、Redis实例宕机、下游gRPC超时,它照样返回200,K8s就会持续导流,引发雪崩。
正确做法是用errgroup.Group并发执行所有关键依赖检查,并控制单个超时:
func readyzHandler(c *gin.Context) {
g, ctx := errgroup.WithContext(c.Request.Context())
var dbStatus, redisStatus string
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
dbStatus = "unavailable"
return err
}
dbStatus = "healthy"
return nil
})
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if _, err := redisClient.Ping(ctx).Result(); err != nil {
redisStatus = "unavailable"
return err
}
redisStatus = "healthy"
return nil
})
if err := g.Wait(); err != nil {
c.JSON(503, gin.H{"db": dbStatus, "redis": redisStatus, "error": err.Error()})
return
}
c.JSON(200, gin.H{"db": dbStatus, "redis": redisStatus})
}
- 每个检查必须带独立
context.WithTimeout,避免一个慢依赖拖垮整个/readyz响应 - 响应体里明确写出各依赖状态,方便SRE快速定位是DB还是Redis挂了
- 注册路由时跳过所有中间件:
r.NoRoute(func(c *gin.Context) { if c.Request.URL.Path == "/readyz" { readyzHandler(c); return } })
Consul客户端查服务必须加PassingOnly: true
你在代码里用api.Health().Service("user-service", "", false, nil)查服务列表,得到的可能是已经挂掉但还没被Consul清理的“脏数据”。因为第三个参数passingOnly设为false,它会返回所有注册过的实例,不管当前健康状态。
生产环境必须显式设为true:
services, _, err := client.Health().Service("user-service", "", true, nil)
- 不加
true,哪怕Consul Web UI上显示该服务是Critical,你的调用方仍可能拿到并尝试连接已下线的IP - 这个参数不是可选项,是保命开关。很多线上故障回溯都卡在这一步——查到了实例,连上去就timeout
- 如果你用的是
consul-template或nginx-upsync-module,它们内部已默认启用PassingOnly,但手写SDK调用时极易遗漏
真正的难点不在注册,而在“谁来定义什么是健康”。/health说进程活着,/readyz说依赖可用,Consul的Check则说“我能连上你暴露的那个端点”——三者目标不同、触发路径不同、超时策略不同,混用任何一个都会让监控变成幻觉。











