核心是Shutdown()必须在ServiceDeregister()前完成、client.Health().Service()必须设passingOnly=true、健康检查需真实反映服务就绪状态,三者缺一不可,否则请求会打到已关闭但未下线的实例。

直接集成 Consul 实现 Gin 服务的优雅停机与下线,核心不是“能不能配”,而是Shutdown()和ServiceDeregister()的执行顺序、健康检查状态一致性、以及客户端查询时的过滤逻辑——三者错一个,就会出现请求打到已关闭但未下线的实例上。
gin.Server.Shutdown() 必须在反注册前完成
很多人把 client.Agent().ServiceDeregister() 放在信号捕获后立刻执行,结果 http.Server 还没真正停止 accept,Consul 就删掉了节点。下游还在往这个已 shutdown 但未 deregister 的实例发请求,直接报 connection refused。
-
srv.Shutdown()是阻塞调用,它只停止新连接接入,但会等已有 HTTP 请求自然结束(比如正在处理的 /order 接口) - 必须等
srv.Shutdown()返回后再调client.Agent().ServiceDeregister(),否则 Consul 里残留“假离线”节点 - 别用
defer包裹反注册:主 goroutine 可能早于Shutdown()结束就退出了 - 推荐写法:收到
SIGTERM后启一个 goroutine 执行srv.Shutdown(),等它返回再执行反注册
client.Health().Service() 查询必须加 passingOnly=true
不加这个参数,client.Health().Service("user-service", "", false, nil) 会返回所有状态的服务实例,包括 critical 和 warning 的——这些节点其实已经挂了,但还没被 Consul 清理完,下游拿到它们就会失败。
-
passingOnly=true是硬性要求,它让 Consul 过滤掉非 passing 状态的节点 - 别依赖
client.Agent().Services():它只返回本机注册的服务列表,对服务发现毫无意义 - 如果服务刚 deregister 但下游缓存没刷新,可配合
TagFilter做灰度隔离,比如只查tags=["v2"]实例
Consul 健康检查配置必须匹配服务真实就绪状态
注册时写的 Check.HTTP = "http://127.0.0.1:8080/health",但服务还没启动完、或 /health 接口返回非 200,Consul 就会标记为 critical;反过来,Interval="1s" 会导致 Consul 集群被打爆,触发限流后健康检查全部失效。
-
/health必须真实存在、返回 200、且不依赖其他未就绪组件(比如 DB 连接池还没建好就返回 200,后续请求全崩) -
Interval建议设为 5–10s,Timeout不超过Interval的 1/3 - 注册时必须显式传
Check字段,否则即使注册成功也会因无健康检查被标记为critical
Gin 启动流程要拆开控制,不能只用 r.Run()
r.Run() 内部其实是调用 http.ListenAndServe(addr, r),它把 Gin 的 Engine 当作 http.Handler 交给了标准库。但你要做优雅停机,就必须自己管理 http.Server 实例,而不是交给 Run() 封装掉。
- 用
&http.Server{Addr: ":8080", Handler: r}显式创建 server 实例 - 监听
SIGTERM和SIGINT,收到后触发srv.Shutdown() - 别在
main()函数里直接log.Fatal(srv.ListenAndServe()),否则无法捕获信号 - 若同时跑 gRPC 服务,需额外管理
grpc.Server.GracefulStop(),并确保它在 HTTPShutdown()完成后才执行
最容易被忽略的点是:Consul 的服务发现延迟 + 客户端本地缓存 + 负载均衡器的刷新周期,这三者叠加会让“已下线但仍有请求进来”成为常态。所以除了服务端反注册,你还得在客户端侧做兜底——比如用 registry.Watch + 本地缓存 + 失败重试,而不是每次调用都临时查一次 Health().Service()。











