Go 语言 服务发现 实战:从原理到落地
问题是怎么来的
前段时间把一个 Go 单体服务拆成了三四个小服务,拆的时候觉得挺爽,结果一部署就遇到个尴尬事:订单服务要调用用户服务,地址写啥?写死在配置文件里,实例只有一个的时候还行,后来用户服务扩到了三个实例,机器还时不时迁移,每次变动都得去改一遍配置再重启,烦得要命。
一开始想偷懒用 nginx upstream 转一下,但加减节点还是要手动改配置 reload……忍了两次之后受不了了,决定正经把服务发现搞起来。选型上没太纠结,项目本来就在用 etcd 存配置,就没必要再引入 Consul 之类的一套新东西,复用现成的最好。肯定有人要问为啥不上 k8s 的 Service,我们这堆东西就是跑在几台裸机上的,没有 k8s,为这点事搭一套有点杀鸡用牛刀了。
说白了,原理就三件事
写代码前我先给自己补了补原理,其实真不复杂,整个服务发现就是一个循环:
- 注册:服务实例启动时往 etcd 里写一个 key,比如
/services/user/192.168.1.10:8001,value 就是自己的地址 - 心跳:把这个 key 绑在一个带 TTL 的租约上,定时续约;进程挂了或者机器宕机,没人续约,TTL 一到 etcd 就自动把 key 删掉
- 发现:调用方要调用时,把前缀下的所有 key 拉出来,就拿到了活着的实例列表,同时 Watch 这个前缀,实例上下线时更新自己的列表
说白了就是早自习点名那套:来了把名字写黑板上,长时间没动静的名字被擦掉,班长找人看一眼黑板就行。网上不少文章把这事儿讲得神乎其神,真没必要。
动手:注册 + 心跳
先装官方客户端:
go get go.etcd.io/etcd/client/v3
注册的代码,注释都写上了,应该一眼能看懂(import 无非是 context、fmt、strings、clientv3 和 mvccpb,就不全贴了):
func Register(ctx context.Context, cli *clientv3.Client, srvName, addr string) error {
key := fmt.Sprintf("/services/%s/%s", srvName, addr)
// 10 秒 TTL,心跳断了 etcd 会自己删 key
grant, err := cli.Grant(ctx, 10)
if err != nil {
return err
}
if _, err = cli.Put(ctx, key, addr, clientv3.WithLease(grant.ID)); err != nil {
return err
}
// KeepAlive 会自动定时续约,返回一个 channel
ch, err := cli.KeepAlive(ctx, grant.ID)
if err != nil {
return err
}
go func() {
for range ch {
// 消费掉就行,不用处理
}
}()
return nil
}
有个细节:程序正常退出的时候记得主动把 key 删掉,不然得干等 10 秒 TTL 到期,实例才从列表里消失,这段时间调用方就可能打到一台已经死掉的节点上:
func Deregister(ctx context.Context, cli *clientv3.Client, srvName, addr string) {
key := fmt.Sprintf("/services/%s/%s", srvName, addr)
cli.Delete(ctx, key)
}
顺带提醒一句,信号处理里别直接复用已经 cancel 掉的主 ctx,重新用 context.WithTimeout(context.Background(), 2*time.Second) 建一个传进去,不然 Delete 会直接失败。
调用方:先拉全量,再 Watch
发现这边有个顺序容易搞错:不能上来就 Watch,因为 Watch 只推送它启动之后的变化,你进程起来之前 etcd 里已经有的实例全看不见。正确姿势是先 Get 拉一次当前的全量列表,紧接着再启动 Watch,并且用 WithRev 指定从 Get 时的版本往后开始,这样中间的变化一个都不会漏:
func Watch(ctx context.Context, cli *clientv3.Client, prefix string, onUpdate func([]string)) {
resp, err := cli.Get(ctx, prefix, clientv3.WithPrefix())
if err != nil {
return
}
var addrs []string
for _, kv := range resp.Kvs {
addrs = append(addrs, string(kv.Value))
}
onUpdate(addrs)
rch := cli.Watch(ctx, prefix,
clientv3.WithPrefix(),
clientv3.WithRev(resp.Header.Revision+1))
for wresp := range rch {
for _, ev := range wresp.Events {
switch ev.Type {
case mvccpb.PUT: // 新实例注册,或者地址更新
addrs = upsert(addrs, string(ev.Kv.Value))
case mvccpb.DELETE: // 实例下线,地址在被删的 key 里
addrs = removeAddr(addrs, addrFromKey(string(ev.Kv.Key)))
}
}
onUpdate(addrs)
}
}
upsert、removeAddr 就是简单的切片增删加去重,addrFromKey 取 key 的最后一段(key 的格式是 /services/user/192.168.1.10:8001),就不贴了。
拿到列表后随便挑一个发请求就行,负载均衡用轮询足够了,我个人觉得大多数场景轮询真就够了,最小连接数那些等真有需要再说。
几个实际踩到的坑
注册别太早。 我最开始把 Register 放在了 HTTP server 启动监听之前,结果 etcd 里 key 已经有了,端口还没开,调用方一打过来就是 connection refused。后来改成先 net.Listen 把端口占住,再注册,最后 http.Serve(ln, mux),就正常了。
TTL 别设太小。 我一开始设的 3 秒,结果机器跟 etcd 之间网络抖了两秒,续约没来得及,实例直接被干掉,调用方立刻把节点摘了,报了一波错。后来改成 10 秒稳定多了。TTL 长了真故障时发现得慢,短了又容易误杀,这是个权衡,按自己的网络情况掂量。
KeepAlive 会断。 连接断开或者租约过期时,KeepAlive 返回的 channel 会被关闭,for range ch 那个循环就退出了,没人续约了。我最开始的版本没处理这个,服务跑了个周末实例悄悄从列表里消失,查了半天才发现。后来加了个兜底:一旦发现 channel 关了,就重新 Grant、重新 Put,等于再注册一次。
etcd 本身得高可用。 别拿单节点 etcd 来跑正经服务,不然 etcd 自己成了单点。我的做法是内存里始终留一份最新的列表,watch 断连期间照样用本地缓存顶住,千万别一断连就把列表清空。另外 serious 一点的场景,老老实实上 3 节点集群。
最后
到这里,注册、心跳、发现、变更通知这一整套就跑起来了,上线一个多月没再改过配置文件,感觉是真不错。全部代码加上调用方也就两百行左右,不用引入什么大而全的框架。如果你也是裸机跑几个 Go 服务、手里正好有 etcd,建议就照这个路子自己搓一个,比一上来就搬服务治理框架省心多了——那玩意儿里面的水可深着呢。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。