3.1 服务注册发现:别再硬编码地址

场景:一次机器重启引发的线上报错

服务刚拆完那阵子,订单服务调商品服务,地址直接写在配置文件里:

product:
  host: 10.0.3.21
  port: 8081

这在本地 docker-compose 里跑得好好的,上云第一周就出事了。那天凌晨安全补丁更新,商品服务所在的 CVM 重启,端口被同机另一个进程先占了,商品服务只能起在 8082 上。订单服务还照着 8081 发请求,一下午报错两千多条,运营群里直接炸锅。运维同学爬起来手动改配置、重启订单服务才恢复。

这事儿给了我们三个教训:地址一定会变(端口漂移、机器替换、扩容伸缩),靠人改配置一定慢,而且配置一多就乱——当时三个服务两两调用,已经六七个地址要维护,全散落在各个仓库的 yaml 里。拆分的初衷是独立部署,结果地址一变就要跟着发版,这跟单体有什么区别。

方案对比:四种找地址的方式

我们在白板上列了四种方案的对照表,最后选了 Consul:

方式 实现成本 变更感知速度 适用场景
硬编码配置 零成本 靠人改重启 永远不变的静态环境
内网 DNS 低 分钟级,受 TTL 限制 服务少、变更不频繁
注册中心 中 秒级推送 服务多、频繁伸缩
云负载均衡加域名 低 秒级 不想自己维护注册中心

说实话腾讯云 CLB 加内网域名也能覆盖大部分需求,但我们当时有本地 docker-compose 开发环境,希望本地和云端用同一套发现逻辑,就先用 Consul 做注册中心,云端部署的知识可以回头看 服务上云那篇。选型背后的整体思路在 技术选型对齐表 里有更完整的展开。

注册中心本身也有好几个候选,我们当时的对比如下:

注册中心 一致性协议 健康检查能力 多数据中心 最终取舍
Consul Raft tcp、http、grpc 原生支持 原生支持 选中,开箱即用
etcd Raft 需要业务自己做 需额外搭 简单但手活多
Nacos Raft 加 AP 模式 tcp、http 支持 配置中心更强
Zookeeper ZAB 长连接会话 一般 太重,未考虑

我们并不需要极致的写性能,更在意健康检查和 gRPC 生态的契合度,Consul 的 grpc 健康检查开箱即用,省去了自己写探针的活儿。

落地:Consul 集群与服务注册

Consul 集群我们走了最简方案:三台 CVM 各跑一个 Consul server,业务服务全部是 client 模式注册。docker-compose 本地起一个单节点 server 做开发,云端用 systemd 守护三个 server,这套部署方式和 本地最小集群 里介绍的流程一致。

Go 服务注册的代码不复杂,关键是把健康检查接上 gRPC:

import (
    "github.com/hashicorp/consul/api"
    "google.golang.org/grpc/health"
    healthpb "google.golang.org/grpc/health/grpc_health_v1"
)

func registerConsul() error {
    cfg := api.DefaultConfig()
    cfg.Address = "consul.internal:8500"
    cli, err := api.NewClient(cfg)
    if err != nil {
        return err
    }
    reg := &api.AgentServiceRegistration{
        ID:      "order-svc-10.0.3.31",
        Name:    "order-svc",
        Port:    50051,
        Address: "10.0.3.31",
        Check: &api.AgentServiceCheck{
            GRPC:                          "10.0.3.31:50051",
            Interval:                      "10s",
            Timeout:                       "3s",
            DeregisterCriticalServiceAfter: "30s",
        },
    }
    return cli.Agent().ServiceRegister(reg)
}

服务启动时先注册,再开始接收流量;同时用 grpc health server 暴露健康状态,Consul 每 10 秒来探一次。

第三步,客户端用名字而不是地址调用。我们给 Consul 集群配了内网域名解析到三个 server 节点,避免某个节点重启时客户端连不上;云端监控里还加了 Consul 存活告警,它一倒,所有新起来的服务都会注册失败。调用方的代码长这样:

conn, err := grpc.Dial(
    "consul://127.0.0.1:8500/product-svc",
    grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)

这一改,最直观的感受是扩容不用再通知任何人:新实例起来自动注册,客户端轮询自然就分到了流量;实例挂了,30 秒内自动摘除。

这里有个细节值得强调:gRPC 客户端默认的负载均衡策略是 pick_first,也就是连一个实例到底,挂了才换下一个,这对多实例完全起不到分流作用。必须显式设置成 round_robin,或者按场景用更复杂的策略。我们线上用的是 round_robin,简单可靠;有状态服务则用一致性哈希策略,保证同一用户的请求尽量落同一实例。关于拆分后各服务的职责划分,可以参考 按业务切分边界。

踩坑记录

第一个坑是 DeregisterCriticalServiceAfter 一开始没配。某台机器假死,健康检查失败但服务注册信息一直留着,请求轮到它就超时,整条链路忽好忽坏。配上之后 critical 状态超过 30 秒就自动注销,世界清净了。

第二个坑是优雅注销。服务收到 SIGTERM 时如果直接退出,Consul 里的注册信息还要等一个检查周期才摘掉,这段时间会有流量打到正在关闭的实例上。正确做法是在信号处理里先反注册,再等存量请求处理完,最后退出进程。

第三个坑是本地与云端 Consul 串了。有同事本地起了 Consul server 但 address 写成了云端地址,开发机上的测试服务注册到了线上集群,半夜被监控告警吵醒。后来本地环境一律用 consul.local 这个 hosts 别名隔离,构建镜像时注入不同配置。

第四个坑是健康检查太严格。我们把数据库连通性放进健康检查里,结果数据库一抖,所有服务瞬间变 critical 被全部摘除,流量直接打穿。教训是健康检查只反映服务自身进程是否健康,下游依赖的状态应该放到存活探针之外的指标里,监控的活儿交给 云监控告警 那套体系。

第五个坑是注册中心的可用性。Consul 集群三个节点挂了一个,业务无感知;但有一次运维同学做实验一口气停了两个,剩下的一个把写请求全挡在门外,新起来的服务注册不上。注册中心本身也要按关键基础设施对待,机器和配额都是按三副本的规格申请的。

小结

注册发现落地后,我们做了一次回顾:最明显的收益不是扩容方便,而是心智负担消失了。以前改一次地址要在三个仓库发版、还要挑低峰期操作,现在伸缩完全静默。这大概是微服务基础设施的普遍规律——第一眼看不见,但每天都在省事。

小结

服务注册发现的本质是把地址这个易变的东西从配置里挪到一个会自动更新的地方。consul server 的机器本身也是 CVM,新购实例可以走 腾讯云 CVM 新用户特惠,成本不高。下节我们聊聊把 HTTP+JSON 换成 gRPC 之后,调用本身能省多少事。

延伸阅读